From 617e842341cb407cd2fa5c924a4277db4f14b1ff Mon Sep 17 00:00:00 2001 From: Allan Leary Date: Tue, 7 Jul 2026 08:21:23 +0100 Subject: [PATCH] Replace review-triggered labelling with a scheduled scan Review events on fork PRs run with a read-only token, so labels can't be added from the pull_request_review trigger. Move label-adding to a daily scheduled scan, which also covers PRs with merge conflicts. The event-driven workflow now handles removal only. --- .github/workflows/awaiting-response-label.yml | 54 +++------ .github/workflows/awaiting-response-scan.yml | 104 ++++++++++++++++++ 2 files changed, 122 insertions(+), 36 deletions(-) create mode 100644 .github/workflows/awaiting-response-scan.yml diff --git a/.github/workflows/awaiting-response-label.yml b/.github/workflows/awaiting-response-label.yml index 3feba0a6..fd3c2bbb 100644 --- a/.github/workflows/awaiting-response-label.yml +++ b/.github/workflows/awaiting-response-label.yml @@ -1,9 +1,6 @@ name: Awaiting Response Label Sync on: - # Fires when someone finishes reviewing a PR (e.g. picks "Request changes") - pull_request_review: - types: [submitted] # Fires when the PR author pushes new commits pull_request_target: types: [synchronize] @@ -24,7 +21,7 @@ jobs: github.event.issue.pull_request != null runs-on: ubuntu-latest steps: - - name: Sync "awaiting response" label + - name: Clear "awaiting response" label on author response uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 env: AWAITING_LABEL: 'awaiting response' @@ -37,8 +34,8 @@ jobs: : context.payload.pull_request.number; const { owner, repo } = context.repo; - // Check whether the label is already on the PR, so we don't add it twice or - // try to remove something that isn't there + // Check whether the label is already on the PR, so we don't try to + // remove something that isn't there const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: prNumber, }); @@ -46,37 +43,22 @@ jobs: (typeof l === 'string' ? l : l.name) === awaitingLabel ); - // A reviewer chose "Request changes" -> the author needs to respond - if (context.eventName === 'pull_request_review' && context.payload.review.state === 'changes_requested') { - if (!hasLabel) { - await github.rest.issues.addLabels({ - owner, repo, issue_number: prNumber, labels: [awaitingLabel], - }); - core.info(`Added "${awaitingLabel}".`); - } else { - core.info('Label already applied.'); - } + if (!hasLabel) { + core.info('Label not present; nothing to do.'); + return; + } + // The author pushed new commits -> treat that as their response and clear the label - } else if (context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') { - if (hasLabel) { - // If the label was already gone for some reason, that's fine, not an error - await github.rest.issues.removeLabel({ - owner, repo, issue_number: prNumber, name: awaitingLabel, - }).catch(e => core.warning(`removeLabel failed: ${e.message}`)); - core.info(`Removed "${awaitingLabel}".`); - } else { - core.info('Label not present; nothing to do.'); - } - // The PR author left a comment -> treat any reply from them as a response - } else if (context.eventName === 'issue_comment' && context.payload.comment.user.login === context.payload.issue.user.login) { - if (hasLabel) { - await github.rest.issues.removeLabel({ - owner, repo, issue_number: prNumber, name: awaitingLabel, - }).catch(e => core.warning(`removeLabel failed: ${e.message}`)); - core.info(`Removed "${awaitingLabel}".`); - } else { - core.info('Label not present; nothing to do.'); - } + const authorPushed = context.eventName === 'pull_request_target' && context.payload.action === 'synchronize'; + // The PR author left a comment -> treat any reply from them as a response too + const authorCommented = context.eventName === 'issue_comment' && context.payload.comment.user.login === context.payload.issue.user.login; + + if (authorPushed || authorCommented) { + // If the label was already gone for some reason, that's fine, not an error + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: awaitingLabel, + }).catch(e => core.warning(`removeLabel failed: ${e.message}`)); + core.info(`Removed "${awaitingLabel}".`); } else { core.info('Event does not require a label change.'); } diff --git a/.github/workflows/awaiting-response-scan.yml b/.github/workflows/awaiting-response-scan.yml new file mode 100644 index 00000000..1ae9d4f1 --- /dev/null +++ b/.github/workflows/awaiting-response-scan.yml @@ -0,0 +1,104 @@ +name: Awaiting Response Scheduled Scan + +on: + schedule: + # Runs daily at 02:45 UTC (offset from the 01:30 CLA stale scan). + - cron: '45 2 * * *' + workflow_dispatch: {} + +permissions: + pull-requests: write + issues: write + contents: read + +env: + AWAITING_LABEL: 'awaiting response' + # Cap on PRs examined per run, most recently updated first. + MAX_PRS: 200 + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - name: Label open PRs that are awaiting an author response + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0 + with: + script: | + const awaitingLabel = process.env.AWAITING_LABEL; + const maxPrs = parseInt(process.env.MAX_PRS, 10); + const { owner, repo } = context.repo; + + // This scan only ADDS the label. Removal is handled by the + // event-driven workflow (awaiting-response-label.yml) when the + // author pushes or comments, or manually by a maintainer. + const prs = await github.paginate(github.rest.pulls.list, { + owner, repo, state: 'open', sort: 'updated', direction: 'desc', per_page: 100, + }); + + let examined = 0; + for (const prSummary of prs) { + if (examined >= maxPrs) { + core.info(`Reached MAX_PRS (${maxPrs}); stopping.`); + break; + } + examined++; + + if (prSummary.labels.some(l => l.name === awaitingLabel)) { + continue; + } + + // The list endpoint doesn't include merge state, so fetch the full PR + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prSummary.number, + }); + + // Case 1: merge conflict -> the author needs to resolve it. + // mergeable_state can be temporarily "unknown" while GitHub computes + // it; those PRs are picked up on a later run instead of retrying now. + if (pr.mergeable_state === 'dirty') { + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [awaitingLabel], + }); + core.info(`#${pr.number}: added "${awaitingLabel}" (merge conflict).`); + continue; + } + + // Case 2: a reviewer requested changes and the author hasn't responded. + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + // Note: the REST API reports review states in UPPERCASE + // ("CHANGES_REQUESTED"), unlike webhook payloads which are lowercase. + const decisive = reviews.filter(r => + r.state === 'CHANGES_REQUESTED' || r.state === 'APPROVED' + ); + const latest = decisive[decisive.length - 1]; + if (!latest || latest.state !== 'CHANGES_REQUESTED') { + continue; + } + + // If the author has pushed since the review, treat that as a response + // (same rule as the event-driven removal workflow). + if (latest.commit_id !== pr.head.sha) { + continue; + } + + // If the author has commented since the review, that also counts + // as a response. + const comments = await github.paginate(github.rest.issues.listComments, { + owner, repo, issue_number: pr.number, since: latest.submitted_at, per_page: 100, + }); + const authorResponded = comments.some(c => + (c.user?.login || '') === pr.user.login + ); + if (authorResponded) { + continue; + } + + await github.rest.issues.addLabels({ + owner, repo, issue_number: pr.number, labels: [awaitingLabel], + }); + core.info(`#${pr.number}: added "${awaitingLabel}" (changes requested, no response).`); + } + + core.info(`Scan complete; examined ${examined} PR(s).`);