Skip to content

fix: honor runtime streaming callback precedence #3492

fix: honor runtime streaming callback precedence

fix: honor runtime streaming callback precedence #3492

name: Linked issue review sync

Check warning on line 1 in .github/workflows/linked_issue_review.yml

View workflow run for this annotation

GitHub Actions / Linked issue review sync

Workflow execution policy warning (evaluate mode)

On December 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
# When a PR links an issue (via "Fixes #123" & co.), assign the PR's requested
# reviewer(s) to that issue and move it to the review column of the Open Source
# GitHub project (https://github.com/orgs/deepset-ai/projects/5). An assigned
# issue signals other contributors (and their coding agents) that the issue is
# taken, which prevents duplicate PRs.
#
# When such a PR is closed without being merged and no other open PR links the
# issue, the assignees this workflow added are removed again and the issue moves
# from the review column back to the backlog (or to new if it has no priority).
#
# A daily sweep applies both rules to all open PRs and issues. It catches events
# that never trigger this workflow, e.g. PRs closed by other workflows with
# GITHUB_TOKEN or links GitHub resolved after the PR events ran.
#
# Uses pull_request_target so it also has write permissions on fork PRs; this
# is safe because the workflow never checks out or executes PR code.
on:
pull_request_target:
types: [opened, ready_for_review, review_requested, closed]
schedule:
- cron: "30 5 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
pull-requests: read
concurrency:
group: linked-issue-review-${{ github.event.pull_request.number || 'sweep' }}
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-slim
# Draft PRs (e.g. gated by the CLA draft gate) are handled once they become
# ready for review.
if: >-
${{ github.event_name != 'pull_request_target' || github.event.action == 'closed' ||
!github.event.pull_request.draft }}
steps:
- name: Sync assignees of linked issues
id: sync
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
// Actor of assignment events created with GITHUB_TOKEN.
const WORKFLOW_ACTOR = "github-actions";
const PRIORITIES = ["P0", "P1", "P2", "P3"];
const ISSUE_FIELDS = `
id number state body repository { nameWithOwner }
assignees(first: 20) { nodes { login } }
labels(first: 100) { nodes { name } }
closedByPullRequestsReferences(first: 10, includeClosedPrs: false) { nodes { number } }
timelineItems(itemTypes: [ASSIGNED_EVENT], last: 100) {
nodes { ... on AssignedEvent { actor { login } assignee { ... on User { login } } } }
}`;
// Project board moves for the next step.
const moves = [];
const openIssuesInRepo = (issues) =>
issues.filter(
(issue) => issue.repository.nameWithOwner === `${owner}/${repo}` && issue.state === "OPEN",
);
async function fetchLinkedIssues(number) {
const result = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 10) { nodes { ${ISSUE_FIELDS} } }
}
}
}`,
{ owner, repo, number },
);
return openIssuesInRepo(result.repository.pullRequest.closingIssuesReferences.nodes);
}
// handled_internally_pr_guard.yml closes community PRs for issues the
// team handles internally, so don't claim those issues for such a PR.
// author_association is unreliable for team membership (private org
// members appear as CONTRIBUTOR/NONE), hence the permission fallback.
const teamMembers = new Map();
async function isTeamMember(login) {
if (!teamMembers.has(login)) {
let member = false;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner, repo, username: login,
});
member = ["admin", "write"].includes(data.permission);
} catch {}
teamMembers.set(login, member);
}
return teamMembers.get(login);
}
const isInternal = (issue) =>
issue.labels.nodes.some((l) => l.name === "handled internally") ||
(issue.body ?? "").includes("isn't open for external contributions");
// Assigns the PR's reviewers to the issues nobody is assigned to and
// queues every issue for the review column.
async function claim(pr, issues) {
if (
issues.some(isInternal) &&
!["MEMBER", "OWNER", "COLLABORATOR"].includes(pr.authorAssociation) &&
!(await isTeamMember(pr.author))
) {
issues = issues.filter((issue) => !isInternal(issue));
}
for (const issue of issues) {
if (issue.assignees.nodes.length) {
core.info(`Issue #${issue.number} already has an assignee, skipping assignment.`);
} else {
await github.rest.issues.addAssignees({
owner, repo, issue_number: issue.number, assignees: pr.reviewers,
});
core.info(`Assigned ${pr.reviewers.join(", ")} to issue #${issue.number} (PR #${pr.number})`);
}
moves.push({ id: issue.id, to: "review" });
}
}
// Removes the assignees this workflow added once no open PR links the
// issue, ignoring `closedPr`, and queues the issue to leave the review
// column if nobody is left assigned.
async function release(issue, closedPr) {
// An assignee counts as added by this workflow if their most recent
// "assigned" event is ours. Assignees set by hand stay.
const assignedBy = new Map();
for (const event of issue.timelineItems.nodes) {
if (event.assignee?.login) assignedBy.set(event.assignee.login, event.actor?.login);
}
const assignees = issue.assignees.nodes.map((a) => a.login);
const toRemove = assignees.filter((login) => assignedBy.get(login) === WORKFLOW_ACTOR);
// The sweep only cleans up what this workflow claimed.
if (closedPr === undefined && !toRemove.length) return;
if (issue.closedByPullRequestsReferences.nodes.some((pr) => pr.number !== closedPr)) {
core.info(`Issue #${issue.number} has an open linked PR, skipping.`);
return;
}
if (toRemove.length) {
await github.rest.issues.removeAssignees({
owner, repo, issue_number: issue.number, assignees: toRemove,
});
core.info(`Unassigned ${toRemove.join(", ")} from issue #${issue.number}`);
}
// Somebody assigned by hand still owns the issue, so it stays in review.
if (toRemove.length < assignees.length) {
core.info(`Issue #${issue.number} still has an assignee, keeping its project status.`);
return;
}
const hasPriority = issue.labels.nodes.some((l) => PRIORITIES.includes(l.name));
moves.push({ id: issue.id, to: hasPriority ? "backlog" : "new", from: "review" });
}
async function onPrClaimed(pr) {
if (pr.user?.type === "Bot") return;
const reviewers = (pr.requested_reviewers ?? []).map((u) => u.login);
if (!reviewers.length) {
// CODEOWNERS assignment triggers a later review_requested event.
core.info("No individual reviewers requested yet, nothing to do.");
return;
}
// The event payload can be stale when the PR was closed while this run
// was queued.
const { data: current } = await github.rest.pulls.get({ owner, repo, pull_number: pr.number });
if (current.state !== "open") {
core.info("PR is no longer open, nothing to do.");
return;
}
// GitHub links issues from closing keywords asynchronously, so right
// after a PR is opened the query can come back empty. Retry while the
// body contains a closing keyword.
const closingKeyword =
/\b(close[sd]?|fix(e[sd])?|resolve[sd]?):?\s+(\S*#\d+|https:\/\/github\.com\/\S+\/issues\/\d+)/i;
let issues = await fetchLinkedIssues(pr.number);
for (let attempt = 1; !issues.length && attempt <= 3 && closingKeyword.test(pr.body ?? ""); attempt++) {
core.info(`No linked issues yet but the PR body has a closing keyword, retrying (${attempt}/3).`);
await new Promise((resolve) => setTimeout(resolve, 10_000));
issues = await fetchLinkedIssues(pr.number);
}
if (!issues.length) {
core.info("PR has no linked issues.");
return;
}
await claim(
{ number: pr.number, author: pr.user.login, authorAssociation: pr.author_association, reviewers },
issues,
);
}
async function onPrClosed(pr) {
// Merged PRs close their linked issues.
if (pr.merged) return;
for (const issue of await fetchLinkedIssues(pr.number)) await release(issue, pr.number);
}
// Runs `handle` on every node of a paginated connection, logging failures
// so the remaining nodes still get synced. Returns the failed labels.
async function forEachPage(query, connection, handle) {
const failed = [];
let cursor = null;
do {
const result = await github.graphql(query, { owner, repo, cursor });
const page = result.repository[connection];
for (const node of page.nodes) {
try {
await handle(node);
} catch (error) {
core.error(`Failed to sync #${node.number}: ${error.message}`);
failed.push(`#${node.number}`);
}
}
cursor = page.pageInfo.hasNextPage ? page.pageInfo.endCursor : null;
} while (cursor);
return failed;
}
async function sweep() {
// Claim unassigned issues of open, ready PRs with requested reviewers.
const failed = await forEachPage(
`query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(states: OPEN, first: 25, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes {
number isDraft authorAssociation author { __typename login }
reviewRequests(first: 10) { nodes { requestedReviewer { ... on User { login } } } }
closingIssuesReferences(first: 10) { nodes { ${ISSUE_FIELDS} } }
}
}
}
}`,
"pullRequests",
async (pr) => {
if (pr.isDraft || pr.author?.__typename === "Bot") return;
const reviewers = pr.reviewRequests.nodes.map((r) => r.requestedReviewer?.login).filter(Boolean);
const issues = openIssuesInRepo(pr.closingIssuesReferences.nodes).filter(
(issue) => !issue.assignees.nodes.length,
);
if (!reviewers.length || !issues.length) return;
await claim(
{ number: pr.number, author: pr.author?.login, authorAssociation: pr.authorAssociation, reviewers },
issues,
);
},
);
// Release assigned issues whose linked PRs were all closed.
failed.push(
...(await forEachPage(
`query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
issues(states: OPEN, filterBy: { assignee: "*" }, first: 50, after: $cursor) {
pageInfo { hasNextPage endCursor }
nodes { ${ISSUE_FIELDS} }
}
}
}`,
"issues",
(issue) => release(issue),
)),
);
if (failed.length) core.setFailed(`Failed to sync: ${failed.join(", ")}`);
}
if (context.eventName !== "pull_request_target") {
await sweep();
} else if (context.payload.action === "closed") {
await onPrClosed(context.payload.pull_request);
} else {
await onPrClaimed(context.payload.pull_request);
}
core.setOutput("moves", JSON.stringify(moves));
- name: Move linked issues in the project board
if: always() && steps.sync.outputs.moves && steps.sync.outputs.moves != '[]'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
MOVES: ${{ steps.sync.outputs.moves }}
PROJECT_ORG: deepset-ai
PROJECT_NUMBER: "5"
# Names of the Status options issues move between; matched
# case-insensitively, falling back to a substring match
# (e.g. the actual review option is ":eyes: In review").
STATUS_NAMES: '{"review": "In review", "backlog": "Backlog", "new": "New"}'
with:
# The default GITHUB_TOKEN cannot access org-level projects.
github-token: ${{ secrets.GH_PROJECT_PAT }}
script: |
const moves = JSON.parse(process.env.MOVES);
const statusNames = JSON.parse(process.env.STATUS_NAMES);
const result = await github.graphql(
`query($org: String!, $number: Int!) {
organization(login: $org) {
projectV2(number: $number) {
id
field(name: "Status") {
... on ProjectV2SingleSelectField { id options { id name } }
}
}
}
}`,
{ org: process.env.PROJECT_ORG, number: Number(process.env.PROJECT_NUMBER) },
);
const project = result.organization.projectV2;
const field = project.field;
function findOption(key) {
const name = statusNames[key].toLowerCase();
const option =
field.options.find((o) => o.name.toLowerCase() === name) ??
field.options.find((o) => o.name.toLowerCase().includes(name));
if (!option) {
core.warning(
`No Status option matching "${statusNames[key]}" in project ` +
`${process.env.PROJECT_NUMBER}. Available: ${field.options.map((o) => o.name).join(", ")}`,
);
}
return option;
}
for (const move of moves) {
const option = findOption(move.to);
if (!option) continue;
const node = await github.graphql(
`query($id: ID!) {
node(id: $id) {
... on Issue {
projectItems(first: 50) {
nodes {
id project { id }
status: fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { optionId }
}
}
}
}
}
}`,
{ id: move.id },
);
let item = node.node.projectItems.nodes.find((n) => n.project.id === project.id);
// Only move issues out of the column they are expected in, so a status
// set by hand stays.
if (move.from) {
const from = findOption(move.from);
if (!from || item?.status?.optionId !== from.id) {
core.info(`Issue ${move.id} is not in "${statusNames[move.from]}", leaving it.`);
continue;
}
}
if (!item) {
const added = await github.graphql(
`mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) {
item { id }
}
}`,
{ projectId: project.id, contentId: move.id },
);
item = added.addProjectV2ItemById.item;
}
await github.graphql(
`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: {
projectId: $projectId, itemId: $itemId, fieldId: $fieldId,
value: { singleSelectOptionId: $optionId }
}
) { projectV2Item { id } }
}`,
{ projectId: project.id, itemId: item.id, fieldId: field.id, optionId: option.id },
);
core.info(`Moved issue ${move.id} to "${option.name}"`);
}