b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
232 lines
8.5 KiB
YAML
232 lines
8.5 KiB
YAML
name: Review State
|
|
|
|
# Event-driven interpreter that maintains four mutually-exclusive review/*
|
|
# labels on open PRs so maintainers can see review status at a glance.
|
|
# Pure actions/github-script: no checkout, no secrets, no agent.
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, reopened, synchronize, ready_for_review]
|
|
pull_request_review:
|
|
types: [submitted, dismissed]
|
|
schedule:
|
|
# Backstop sweep so state stays correct even if an event was missed.
|
|
- cron: "15 */6 * * *"
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
jobs:
|
|
review-state:
|
|
name: Update review state labels
|
|
runs-on: ubuntu-latest
|
|
timeout-minutes: 10
|
|
permissions:
|
|
contents: read
|
|
pull-requests: write
|
|
concurrency:
|
|
group: review-state-${{ github.event.pull_request.number || 'sweep' }}
|
|
cancel-in-progress: false
|
|
steps:
|
|
- name: Apply review state labels
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
with:
|
|
script: |
|
|
const owner = context.repo.owner;
|
|
const repo = context.repo.repo;
|
|
|
|
// The four mutually-exclusive review states.
|
|
const stateLabels = {
|
|
'review/needs-review': {
|
|
color: 'fbca04',
|
|
description: 'No maintainer or bot review yet',
|
|
},
|
|
'review/awaiting-author': {
|
|
color: 'c5def5',
|
|
description: 'Reviewed; waiting on the author to respond',
|
|
},
|
|
'review/needs-rereview': {
|
|
color: 'd93f0b',
|
|
description: 'New commits since the last review',
|
|
},
|
|
'review/approved': {
|
|
color: '0e8a16',
|
|
description: 'Approved; no new commits since',
|
|
},
|
|
};
|
|
const stateNames = Object.keys(stateLabels);
|
|
|
|
// --- Ensure the four labels exist (mirrors pr-sweep.yml) ---
|
|
const existingLabels = new Set();
|
|
for await (const response of github.paginate.iterator(
|
|
github.rest.issues.listLabelsForRepo,
|
|
{ owner, repo, per_page: 100 }
|
|
)) {
|
|
for (const label of response.data) {
|
|
existingLabels.add(label.name);
|
|
}
|
|
}
|
|
for (const [name, meta] of Object.entries(stateLabels)) {
|
|
if (!existingLabels.has(name)) {
|
|
await github.rest.issues.createLabel({
|
|
owner,
|
|
repo,
|
|
name,
|
|
color: meta.color,
|
|
description: meta.description,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Remove a label, ignoring 404 if it's already gone (mirrors pr-sweep.yml).
|
|
async function safeRemoveLabel(prNumber, name) {
|
|
try {
|
|
await github.rest.issues.removeLabel({
|
|
owner,
|
|
repo,
|
|
issue_number: prNumber,
|
|
name,
|
|
});
|
|
} catch (e) {
|
|
if (e.status !== 404) throw e;
|
|
}
|
|
}
|
|
|
|
// --- Determine the set of PRs to process ---
|
|
let prs;
|
|
if (
|
|
context.eventName === 'pull_request_target' ||
|
|
context.eventName === 'pull_request_review'
|
|
) {
|
|
prs = [context.payload.pull_request];
|
|
} else {
|
|
// schedule / workflow_dispatch: sweep every open PR.
|
|
prs = await github.paginate(github.rest.pulls.list, {
|
|
owner,
|
|
repo,
|
|
state: 'open',
|
|
per_page: 100,
|
|
});
|
|
}
|
|
|
|
core.info(`Processing ${prs.length} PR(s) for review state`);
|
|
|
|
for (const pr of prs) {
|
|
if (!pr) continue;
|
|
try {
|
|
const currentLabels = new Set((pr.labels || []).map(l => l.name));
|
|
|
|
// Drafts get no review state: strip any review/* labels and move on.
|
|
if (pr.draft === true) {
|
|
for (const name of stateNames) {
|
|
if (currentLabels.has(name)) {
|
|
await safeRemoveLabel(pr.number, name);
|
|
}
|
|
}
|
|
core.info(`#${pr.number}: draft, cleared review state`);
|
|
continue;
|
|
}
|
|
|
|
// Skip bot-authored PRs entirely (no labeling).
|
|
if (pr.user && pr.user.type === 'Bot') {
|
|
core.info(`#${pr.number}: bot author, skipping`);
|
|
continue;
|
|
}
|
|
|
|
// --- Qualifying reviews ---
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
|
owner,
|
|
repo,
|
|
pull_number: pr.number,
|
|
per_page: 100,
|
|
});
|
|
const qualifying = reviews.filter(review =>
|
|
review.user &&
|
|
review.user.login !== pr.user.login &&
|
|
(
|
|
review.user.type === 'Bot' ||
|
|
['OWNER', 'MEMBER', 'COLLABORATOR'].includes(review.author_association)
|
|
) &&
|
|
['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED'].includes(review.state)
|
|
);
|
|
|
|
let lastReview = null;
|
|
for (const review of qualifying) {
|
|
if (
|
|
!lastReview ||
|
|
new Date(review.submitted_at) > new Date(lastReview.submitted_at)
|
|
) {
|
|
lastReview = review;
|
|
}
|
|
}
|
|
|
|
// --- Last non-merge commit ---
|
|
// Only NON-merge commits count as new work. Merge commits (parents > 1)
|
|
// are "Update branch" / `git merge main`, so syncing a branch with main
|
|
// does not flip a reviewed PR back to needs-rereview.
|
|
const commits = await github.paginate(github.rest.pulls.listCommits, {
|
|
owner,
|
|
repo,
|
|
pull_number: pr.number,
|
|
per_page: 100,
|
|
});
|
|
let lastCommitAt;
|
|
for (const commit of commits) {
|
|
if (commit.parents && commit.parents.length > 1) continue;
|
|
const committed = commit.commit?.committer?.date;
|
|
if (!committed) continue;
|
|
if (!lastCommitAt || new Date(committed) > new Date(lastCommitAt)) {
|
|
lastCommitAt = committed;
|
|
}
|
|
}
|
|
|
|
// --- Decide the state ---
|
|
// lastReview may be a COMMENTED review; approvals should still win unless a later
|
|
// decision review (APPROVED/CHANGES_REQUESTED) supersedes them.
|
|
let lastDecisionReview = null;
|
|
for (const review of qualifying) {
|
|
if (review.state === 'COMMENTED') continue;
|
|
if (
|
|
!lastDecisionReview ||
|
|
new Date(review.submitted_at) > new Date(lastDecisionReview.submitted_at)
|
|
) {
|
|
lastDecisionReview = review;
|
|
}
|
|
}
|
|
|
|
let desired;
|
|
if (!lastReview) {
|
|
desired = 'review/needs-review';
|
|
} else if (
|
|
lastCommitAt &&
|
|
new Date(lastCommitAt) > new Date(lastReview.submitted_at)
|
|
) {
|
|
desired = 'review/needs-rereview';
|
|
} else if (lastDecisionReview?.state === 'APPROVED') {
|
|
desired = 'review/approved';
|
|
} else {
|
|
desired = 'review/awaiting-author';
|
|
}
|
|
|
|
// --- Apply: add the chosen label, remove the other three ---
|
|
if (!currentLabels.has(desired)) {
|
|
await github.rest.issues.addLabels({
|
|
owner,
|
|
repo,
|
|
issue_number: pr.number,
|
|
labels: [desired],
|
|
});
|
|
}
|
|
for (const name of stateNames) {
|
|
if (name !== desired && currentLabels.has(name)) {
|
|
await safeRemoveLabel(pr.number, name);
|
|
}
|
|
}
|
|
|
|
core.info(`#${pr.number}: ${desired}`);
|
|
} catch (e) {
|
|
core.warning(`#${pr.number}: failed to update review state: ${e.message}`);
|
|
}
|
|
}
|