Files
emdash-cms--emdash/.github/workflows/review-state.yml
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

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}`);
}
}