Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions .github/workflows/awaiting-response-label.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
name: Awaiting Response Label Sync

on:
# Fires when the PR author pushes new commits
pull_request_target:
types: [synchronize]
# Fires when someone comments on a PR (also fires for plain issues, filtered out below)
issue_comment:
types: [created]

permissions:
pull-requests: write
issues: write
contents: read

jobs:
sync-label:
# issue_comment fires for issues too, so only run it for PR comments
if: >-
github.event_name != 'issue_comment' ||
github.event.issue.pull_request != null
runs-on: ubuntu-latest
steps:
- name: Clear "awaiting response" label on author response
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 #v9.0.0
env:
AWAITING_LABEL: 'awaiting response'
with:
script: |
const awaitingLabel = process.env.AWAITING_LABEL;
// Resolve the PR number for whichever event triggered this run
const prNumber = context.eventName === 'issue_comment'
? context.payload.issue.number
: context.payload.pull_request.number;
const { owner, repo } = context.repo;

// 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,
});
const hasLabel = issue.labels.some(l =>
(typeof l === 'string' ? l : l.name) === awaitingLabel
);

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
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.');
}
104 changes: 104 additions & 0 deletions .github/workflows/awaiting-response-scan.yml
Original file line number Diff line number Diff line change
@@ -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).`);