Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1586351
feat(label): mark stacked pull requests so reviewers can filter them
Oct 5, 2026
7752417
test(label): drop the debug write left in the stacked-label test
Oct 5, 2026
47e9f4b
Merge branch 'main' into feat/label-stacked-prs
easonLiangWorldedtech Oct 6, 2026
b098aa6
fix(workflow): keep review-state reconciliation running when the open…
Oct 6, 2026
9e772e2
test(label): use the harness reviews option in the failed-lookup case
Oct 6, 2026
623f0f4
test(label): cover the stacked label add and removal failure paths
Oct 6, 2026
34d5f07
test(label): assert the stacked label is created and removed on the b…
Oct 6, 2026
9698e50
test(label): prove a stacked label survives an unreadable parent commit
Oct 6, 2026
b3d4cba
test(label): assert the definition, the recorded label state, and the…
Oct 6, 2026
092b03d
fix(workflow): a failed stacked label creation must not block reconci…
Oct 6, 2026
2d742c6
Merge branch 'main' into feat/label-stacked-prs
easonLiangWorldedtech Oct 9, 2026
980566f
Merge upstream/main 9c069e94e into feat/label-stacked-prs
easonliang28 Oct 11, 2026
16ac4c4
style: satisfy the compile job format:check gate on this PR's test file
easonliang28 Oct 12, 2026
2be3bcb
Merge upstream/main 995167eed into feat/label-stacked-prs
easonliang28 Oct 12, 2026
4f41973
Merge upstream/main fafad20b0 into feat/label-stacked-prs
easonliang28 Oct 12, 2026
bb7295f
Move the open-PR head map into the resolve job
easonliang28 Oct 12, 2026
50734f7
Treat an empty open-PR list as an unavailable head map
easonliang28 Oct 12, 2026
2ce4e36
Cover the anomalous empty-list case with the target open
easonliang28 Oct 12, 2026
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
105 changes: 103 additions & 2 deletions .github/workflows/label-pr-review-state.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ permissions:
issues: write
checks: read
statuses: write
# Reading a commit's parents is metadata only; no PR code is checked out or executed.
contents: read

# Concurrency is keyed at two levels (issue #1707):
#
Expand Down Expand Up @@ -91,6 +93,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
pr_numbers: ${{ steps.resolve.outputs.pr_numbers }}
open_pr_heads: ${{ steps.resolve.outputs.open_pr_heads }}
env:
# Mirrors the workflow-level concurrency group so the run's queueing
# identity is visible in the logs (kept in sync by the workflow tests).
Expand Down Expand Up @@ -131,6 +134,7 @@ jobs:
// associated PRs, with a fork-safe backfill by exact head owner,
// branch, and SHA when GitHub left pull_requests empty.
let prNumbers = [];
let openPrsCache = null;
if (context.payload.pull_request?.number) {
prNumbers = [context.payload.pull_request.number];
} else if (context.eventName === 'issue_comment') {
Expand Down Expand Up @@ -163,14 +167,14 @@ jobs:
);
}
} else {
const openPrs = await github.paginate(github.rest.pulls.list, {
openPrsCache = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
// GitHub rejects matrices over 256 jobs, so the sweep pages the
// sorted open-PR list: consecutive runs rotate through
// fixed-size windows by run number, covering every open PR on a
// bounded cadence without ever exceeding the limit.
const allNumbers = openPrs.map(pr => pr.number).sort((a, b) => a - b);
const allNumbers = openPrsCache.map(pr => pr.number).sort((a, b) => a - b);
const sweepPageSize = 200;
const pageCount = Math.max(1, Math.ceil(allNumbers.length / sweepPageSize));
const page = context.runNumber % pageCount;
Expand All @@ -189,6 +193,32 @@ jobs:
);
core.setOutput('pr_numbers', JSON.stringify(prNumbers));

// Build the open-PR head map once here for every shard: a sweep would otherwise
// multiply the paginated open-PR read by every shard against the Actions token
// budget. Sweep triggers already hold the full open list, so the map costs them
// no extra read. An empty output means the map was unavailable (or nothing was
// resolved); shards then skip the stacked label rather than deciding from an
// empty map.
let openPrHeadsOutput = '';
if (prNumbers.length > 0) {
try {
const openPrsForMap = openPrsCache ?? (await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
}));
const heads = {};
for (const candidate of openPrsForMap) {
if (candidate.head?.sha) heads[candidate.head.sha.toLowerCase()] = candidate.number;
}
// An open-PR list with no entries is anomalous (the reconciliation
// target itself is open), so treat it like an unavailable map:
// shards must not decide from a map that looks empty.
if (Object.keys(heads).length > 0) openPrHeadsOutput = JSON.stringify(heads);
} catch (error) {
core.warning(`Could not read the open pull request map: ${error.message}; shards will skip the stacked label this run`);
}
}
core.setOutput('open_pr_heads', openPrHeadsOutput);

reconcile:
name: Zoo Code / reconcile PR review state
needs: resolve
Expand Down Expand Up @@ -230,6 +260,7 @@ jobs:
runs-on: ubuntu-latest
env:
PR_NUMBER: ${{ matrix.pr_number }}
OPEN_PR_HEADS: ${{ needs.resolve.outputs.open_pr_heads }}
# Mirrors this job's concurrency group so a superseded shard's queueing
# identity is visible in the logs (kept in sync by the workflow tests).
CONCURRENCY_GROUP: >-
Expand Down Expand Up @@ -286,6 +317,11 @@ jobs:
color: 'c2e0c6',
description: 'Fresh community approval on the current head; maintainer review still required',
},
{
name: 'stacked',
color: 'c2c2c2',
description: 'Head commit sits on another open PR, so its diff is only this unit',
},
];
const guideMarker = '<!-- zoo-code-pr-review-process -->';
const codeRabbitLabelMarkerPrefix = '<!-- coderabbit-review-label:';
Expand Down Expand Up @@ -329,6 +365,7 @@ jobs:
return pr.head?.repo?.full_name && pr.head.repo.full_name !== pr.base?.repo?.full_name;
}

const stackedLabel = 'stacked';
if (!isReadOnlyRun) {
// One paginated list keeps the per-shard API budget flat no matter
// how many labels are managed; per-label getLabel calls would
Expand All @@ -342,6 +379,14 @@ jobs:
try {
await github.rest.issues.createLabel({ owner, repo, ...label });
} catch (createError) {
if (label.name === stackedLabel) {
// stacked is only a reviewer filter, so a failed provisioning must not stop
// reconciliation for every PR. Every other managed label still fails closed,
// because reconciliation cannot work without it. A concurrent shard winning
// the creation race lands the label anyway; the next sweep reconciles it.
core.warning(`Could not create the ${stackedLabel} label: ${createError.message}; continuing without it`);
continue;
}
// Concurrent shards for other PRs can win the creation race.
// Tolerate only the duplicate-label payload; any other 422
// must fail the shard.
Expand Down Expand Up @@ -417,6 +462,43 @@ jobs:
}
}

// A stacked unit is detected from the commit graph: its head commit has exactly one
// parent, and that parent is another open PR's head. The label describes that detected
// relationship only -- it does not claim the PR targets the branch below it, which is
// how GitHub describes a focused stacked diff. Reviewers filter on this label to tell a
// unit apart from a standalone PR. It is orthogonal to the review-state labels, so it is
// reconciled separately and never removed by reconcileLabels.

async function reconcileStackedLabel(pr, openPrHeads) {
if (openPrHeads === null) {
// The map could not be read this run; leave the label as it is rather than
// deciding from an empty map.
return;
}
if (isReadOnlyRun && isForkPR(pr)) {
core.info(`PR #${pr.number}: fork PR on a read-only run — skipping the stacked label`);
return;
}
try {
const { data: commit } = await github.rest.git.getCommit({ owner, repo, commit_sha: pr.head.sha });
const parents = Array.isArray(commit.parents) ? commit.parents : [];
const parentPr = parents.length === 1
? openPrHeads.get(String(parents[0].sha).toLowerCase())
: undefined;
const hasLabel = pr.labels.some(label => label.name === stackedLabel);
if (hasLabel && parentPr === undefined) {
await github.rest.issues.removeLabel({ owner, repo, issue_number: pr.number, name: stackedLabel });
pr.labels = pr.labels.filter(label => label.name !== stackedLabel);
} else if (parentPr && !hasLabel) {
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [stackedLabel] });
pr.labels = pr.labels.concat([{ name: stackedLabel }]);
core.info(`PR #${pr.number}: stacked on #${parentPr}`);
}
} catch (error) {
core.warning(`PR #${pr.number}: could not reconcile the stacked label: ${error.message}`);
}
}

async function findReviewGuide(pr) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
Expand Down Expand Up @@ -707,8 +789,27 @@ jobs:

const failures = [];

// The open-PR head map is built once in the resolve job and passed through the
// job env, so a sweep pays one paginated read instead of one per shard. An empty
// value means the map was unavailable this run: leave existing stacked labels as
// they are rather than deciding from an empty map, which would look like
// "no open parents" and remove valid labels.
let openPrHeads = null;
if (prs.length > 0) {
if (process.env.OPEN_PR_HEADS) {
try {
openPrHeads = new Map(Object.entries(JSON.parse(process.env.OPEN_PR_HEADS)));
} catch (error) {
core.warning(`Could not read the open pull request map: ${error.message}; skipping the stacked label for this run`);
}
} else {
core.warning('Could not read the open pull request map: the resolve job provided none; skipping the stacked label for this run');
}
}

for (const pr of prs) {
let failurePhase = 'ci-pending';
await reconcileStackedLabel(pr, openPrHeads);
try {
// The matrix job reports one check per shard named
// "<reconciliationCheckName> (<pr_number>)", so catch both the
Expand Down
Loading
Loading