Skip to content

Initial webpage release of 2020 LifeSim Technical Reference Manual. Updated applicable items to 2026. #108

Initial webpage release of 2020 LifeSim Technical Reference Manual. Updated applicable items to 2026.

Initial webpage release of 2020 LifeSim Technical Reference Manual. Updated applicable items to 2026. #108

name: Stage Progression
on:
pull_request:
types: [opened, reopened, synchronize, labeled, edited, review_requested, review_request_removed]
pull_request_review:
types: [submitted, dismissed]
permissions:
pull-requests: write
issues: write
contents: read
statuses: write
concurrency:
group: stage-progression-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
progress:
name: Manage review stage
runs-on: ubuntu-latest
steps:
- name: Run stage progression logic
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
if (!pr) return;
const prNumber = pr.number;
const eventName = context.eventName;
const action = context.payload.action;
// ── Constants ────────────────────────────────────────────
const LANE_LABELS = ['lane:new-doc', 'lane:major-revision', 'lane:minor-revision', 'lane:editorial-fix', 'lane:dev'];
const STAGE_LABELS = ['stage:needs-lane', 'stage:peer-review', 'stage:lead-civil-review', 'stage:ai-editor-review', 'stage:director-review', 'stage:ready-to-merge'];
const STAGE_DISPLAY = {
'stage:peer-review': 'Peer reviewer(s)',
'stage:lead-civil-review': 'Lead Civil reviewer(s)',
'stage:ai-editor-review': 'Technical editor(s)',
'stage:director-review': 'Director reviewer(s)',
};
const ACTIVE_REVIEW_STAGES = Object.keys(STAGE_DISPLAY);
const STAGE_STATUS_DESC = {
'stage:needs-lane': 'Awaiting lane assignment',
'stage:peer-review': 'Awaiting peer review',
'stage:lead-civil-review': 'Awaiting Lead Civil review',
'stage:ai-editor-review': 'Awaiting technical edit',
'stage:director-review': 'Awaiting Director review',
'stage:ready-to-merge': 'All reviews complete — ready to merge',
};
// ── Re-fetch PR to get fresh labels/SHA/body ─────────────
// Webhook payloads can be stale when events queue through the
// concurrency block and prior runs have added labels or the
// author has pushed new commits.
const currentPr = (await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
})).data;
const labels = currentPr.labels.map(l => l.name);
const headSha = currentPr.head.sha;
const branch = currentPr.head.ref;
const prBody = currentPr.body || '';
const prAuthor = currentPr.user.login;
const existingLane = labels.find(l => LANE_LABELS.includes(l));
const existingStage = labels.find(l => STAGE_LABELS.includes(l));
// ── Helpers ──────────────────────────────────────────────
function detectLane(b) {
if (b.startsWith('docs/new/')) return 'lane:new-doc';
if (b.startsWith('docs/major/')) return 'lane:major-revision';
if (b.startsWith('docs/minor/')) return 'lane:minor-revision';
if (b.startsWith('docs/fix/')) return 'lane:editorial-fix';
if (b.startsWith('docs/dev/')) return 'lane:dev';
return null;
}
async function addLabels(ls) {
if (ls.length) await github.rest.issues.addLabels({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, labels: ls,
});
}
async function removeLabel(l) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, name: l,
});
} catch (e) {}
}
async function postComment(body) {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, body,
});
}
// Fetches the PR's file list once and returns both whether it
// touches any docs/ files and whether every docs/ file is under
// docs/dev/. The dev-lane detection is content-based so that
// dev docs automatically use the lightweight lane regardless of
// branch naming — users don't have to remember a docs/dev/
// branch prefix to get the dev-lane treatment.
async function getDocsFileState() {
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner, repo: context.repo.repo,
pull_number: prNumber, per_page: 100,
});
const docsFiles = files.filter(f => f.filename.startsWith('docs/'));
return {
touchesDocs: docsFiles.length > 0,
allUnderDev: docsFiles.length > 0 && docsFiles.every(f => f.filename.startsWith('docs/dev/')),
};
}
// ── Commit status helper ─────────────────────────────────
// Sets the `review-workflow` commit status on the PR's head SHA.
// This status is the merge gate — branch protection on main requires
// it to be `success` before a PR can merge. The bot flips it to
// `success` only when the PR reaches stage:ready-to-merge (or for
// lane:editorial-fix, immediately on lane assignment).
async function setReviewStatus(state, description) {
await github.rest.repos.createCommitStatus({
owner: context.repo.owner, repo: context.repo.repo,
sha: headSha,
state,
context: 'review-workflow',
description,
});
}
// ── Review-state comment helpers ─────────────────────────
// A hidden bot comment on each PR tracks which individuals have
// been assigned as reviewers for each active review stage. The
// assignment is captured on `review_requested` webhook events
// (triggered when an admin assigns someone via the Reviewers
// sidebar). Approvals only advance the stage if the approver is
// in the assigned list for the current stage — this is the
// per-individual gating mechanism.
const STATE_MARKER = '<!-- review-state -->';
const STATE_DATA_RE = /<!-- state-data:(.*?) -->/;
function emptyState() {
return {
'stage:peer-review': [],
'stage:lead-civil-review': [],
'stage:ai-editor-review': [],
'stage:director-review': [],
};
}
function renderStateComment(state) {
const dataLine = `<!-- state-data:${JSON.stringify(state)} -->`;
const lines = ['📋 **Assigned reviewers for this PR**', ''];
for (const stage of ACTIVE_REVIEW_STAGES) {
const users = state[stage] || [];
const display = STAGE_DISPLAY[stage];
if (users.length === 0) {
lines.push(`- ${display}: _not assigned_`);
} else {
lines.push(`- ${display}: ${users.map(u => '@' + u).join(', ')}`);
}
}
lines.push('');
lines.push('_The first approval from any assigned reviewer at the current stage advances the PR. Approvals from non-assigned reviewers are logged but do not advance the stage._');
return `${STATE_MARKER}\n${dataLine}\n\n${lines.join('\n')}`;
}
async function findStateComment() {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, per_page: 100,
});
return comments.find(c => c.user.type === 'Bot' && c.body.includes(STATE_MARKER));
}
async function readState() {
const comment = await findStateComment();
if (!comment) return null;
const match = comment.body.match(STATE_DATA_RE);
if (!match) return null;
try { return JSON.parse(match[1]); } catch (e) { return null; }
}
async function writeState(state) {
const body = renderStateComment(state);
const existing = await findStateComment();
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner, repo: context.repo.repo,
comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, body,
});
}
}
async function ensureState() {
const s = await readState();
if (s) return s;
const blank = emptyState();
await writeState(blank);
return blank;
}
// ── Shared: initialize a lane (used on open, sync, and manual label) ──
async function initializeLane(lane) {
await ensureState();
const toAdd = [lane];
let comment;
if (lane === 'lane:editorial-fix' || lane === 'lane:dev') {
toAdd.push('stage:ready-to-merge');
const statusDesc = lane === 'lane:editorial-fix' ? 'Editorial fix — admin may merge' : 'Dev doc — admin may merge';
const laneTitle = lane === 'lane:editorial-fix' ? 'Editorial Fix' : 'Dev Doc';
await setReviewStatus('success', statusDesc);
comment = `📋 **Lane: ${laneTitle}**\n\nNo formal review required.\n\n@usace-rmc/docs-admin please review, merge, and approve the deploy.`;
} else {
toAdd.push('stage:peer-review');
await setReviewStatus('pending', STAGE_STATUS_DESC['stage:peer-review']);
const laneName = lane.replace('lane:', '').replace(/-/g, ' ');
const scope = lane === 'lane:new-doc' ? 'Peer → Lead Civil → Technical Edit → Director'
: lane === 'lane:major-revision' ? 'Peer → Lead Civil → Technical Edit'
: 'Peer → Technical Edit';
comment = `📋 **Lane: ${laneName}**\n\nReview scope: ${scope}.\n\nCurrently in **peer review**. @usace-rmc/docs-admin please assign the peer reviewer(s) via the Reviewers sidebar.`;
}
await addLabels(toAdd);
await postComment(comment);
}
// ═════════════════════════════════════════════════════════
// Event handlers
// ═════════════════════════════════════════════════════════
// ── pull_request opened/reopened ─────────────────────────
if (eventName === 'pull_request' && ['opened', 'reopened'].includes(action)) {
const { touchesDocs, allUnderDev } = await getDocsFileState();
if (!touchesDocs) return;
// Content-based dev detection runs before branch-name detection:
// if every changed docs file lives under docs/dev/, it's a dev
// doc PR regardless of what the branch is called.
const lane = existingLane || (allUnderDev ? 'lane:dev' : detectLane(branch));
if (!lane) {
await addLabels(['stage:needs-lane']);
await setReviewStatus('pending', STAGE_STATUS_DESC['stage:needs-lane']);
const reason = branch.startsWith('docs/')
? `Branch \`${branch}\` doesn't match a known sub-prefix (\`docs/new/\`, \`docs/major/\`, \`docs/minor/\`, \`docs/fix/\`, \`docs/dev/\`), so the lane can't be auto-detected.`
: `Branch \`${branch}\` doesn't follow the \`docs/{new,major,minor,fix,dev}/\` naming convention, and this PR modifies files under \`docs/\`.`;
await postComment(`📋 **Lane assignment needed**\n\n${reason}\n\n@usace-rmc/docs-admin please apply a \`lane:*\` label to route this PR.`);
return;
}
await initializeLane(lane);
return;
}
// ── pull_request synchronize (new commits pushed) ────────
if (eventName === 'pull_request' && action === 'synchronize') {
// Case A: no lane yet. Either a non-docs PR that just became
// docs-touching (run lane detection), or a stage:needs-lane PR
// that's still waiting for admin to assign a lane (no-op, just
// refresh the status on the new head SHA).
if (!existingLane) {
if (existingStage === 'stage:needs-lane') {
await setReviewStatus('pending', STAGE_STATUS_DESC['stage:needs-lane']);
return;
}
const { touchesDocs, allUnderDev } = await getDocsFileState();
if (!touchesDocs) return;
const lane = allUnderDev ? 'lane:dev' : detectLane(branch);
if (!lane) {
await addLabels(['stage:needs-lane']);
await setReviewStatus('pending', STAGE_STATUS_DESC['stage:needs-lane']);
const reason = branch.startsWith('docs/')
? `Branch \`${branch}\` doesn't match a known sub-prefix.`
: `Branch \`${branch}\` doesn't follow \`docs/{new,major,minor,fix,dev}/\` naming. This PR now modifies files under \`docs/\`.`;
await postComment(`📋 **Lane assignment needed**\n\n${reason}\n\n@usace-rmc/docs-admin please apply a \`lane:*\` label to route this PR.`);
return;
}
await initializeLane(lane);
return;
}
// Case B: lane + stage already set. Do NOT reset the stage —
// revisions during a review round are normal and expected.
if (existingStage === 'stage:ready-to-merge') {
// Post-approval commits invalidate the merge gate. Flip the
// status back to pending until an admin re-acknowledges via
// the `admin:approve-merge-after-push` label. This prevents
// silent merge of changes that bypassed the review chain.
await setReviewStatus('pending', 'New commits pushed after ready-to-merge — admin re-approval required');
await postComment(`⚠️ **New commits pushed after PR reached \`stage:ready-to-merge\`**\n\nThe \`review-workflow\` merge gate has been flipped back to **pending** until an admin re-acknowledges the new commits.\n\n@usace-rmc/docs-admin please review the new changes. When ready to merge, apply the label \`admin:approve-merge-after-push\` — the bot will re-set the status to success and remove the label automatically.`);
return;
}
if (existingStage && STAGE_STATUS_DESC[existingStage]) {
await setReviewStatus('pending', STAGE_STATUS_DESC[existingStage]);
}
const syncState = await readState();
const stageAssignees = (syncState && existingStage && syncState[existingStage]) || [];
if (stageAssignees.length > 0) {
// Only ping assignees who have actually submitted a review on
// this PR before. If a reviewer has never reviewed yet, there
// is nothing to "backcheck" — they'll see the new commits
// when they open the PR for the first time. This avoids
// pinging a newly-assigned reviewer every time the author
// pushes routine fixes before the review has started.
const reviewsForBackcheck = await github.paginate(github.rest.pulls.listReviews, {
owner: context.repo.owner, repo: context.repo.repo,
pull_number: prNumber, per_page: 100,
});
const hasReviewed = new Set(reviewsForBackcheck.map(r => r.user && r.user.login).filter(Boolean));
const toNotify = stageAssignees.filter(u => hasReviewed.has(u));
if (toNotify.length > 0) {
const mentions = toNotify.map(u => '@' + u).join(', ');
const stageDisplay = STAGE_DISPLAY[existingStage] || existingStage;
await postComment(`🔄 **New commits pushed by @${prAuthor}.**\n\n${mentions} — please backcheck the revisions for **${stageDisplay}**.`);
}
}
return;
}
// ── pull_request review_requested ────────────────────────
// An admin assigned a reviewer via the sidebar. Two paths:
// * Individual requests get recorded in the state comment
// under either the current stage or an explicitly-pinned
// stage (via an `assign:<stage>` label).
// * Team requests get a one-time guidance comment — the bot
// only gates on individual approvals, so admins still need
// to assign individuals.
if (eventName === 'pull_request' && action === 'review_requested') {
if (!existingLane || !existingStage) return;
if (!ACTIVE_REVIEW_STAGES.includes(existingStage)) return;
// Team request: post a one-time clarification so admins don't
// assume the team request is sufficient. Dedup'd via marker.
const requestedTeam = context.payload.requested_team && context.payload.requested_team.slug;
if (requestedTeam) {
const TEAM_REQUEST_MARKER = '<!-- team-request-guidance -->';
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner, repo: context.repo.repo,
issue_number: prNumber, per_page: 100,
});
const alreadyPosted = comments.some(c => c.body.includes(TEAM_REQUEST_MARKER));
if (!alreadyPosted) {
await postComment(`${TEAM_REQUEST_MARKER}\nℹ️ **Team review requests don't advance the stage.**\n\nA review request for **@${context.repo.owner}/${requestedTeam}** is recorded by GitHub but the bot only gates stage advancement on **individual** reviewer approvals.\n\n@usace-rmc/docs-admin please assign one or more individuals via the Reviewers sidebar so their approvals can advance this PR.`);
}
return;
}
const requested = context.payload.requested_reviewer && context.payload.requested_reviewer.login;
if (!requested) return;
// Allow admins to pre-assign a reviewer for a later stage by
// first applying an `assign:<short-stage>` label (e.g.
// `assign:lead-civil-review`). Without that label, the
// assignment is recorded under the current stage.
const ASSIGN_PREFIX = 'assign:';
const assignLabel = labels.find(l => l.startsWith(ASSIGN_PREFIX));
let targetStage = existingStage;
if (assignLabel) {
const candidate = `stage:${assignLabel.slice(ASSIGN_PREFIX.length)}`;
if (ACTIVE_REVIEW_STAGES.includes(candidate)) {
targetStage = candidate;
}
}
const state = await ensureState();
if (!state[targetStage]) state[targetStage] = [];
if (!state[targetStage].includes(requested)) {
state[targetStage].push(requested);
await writeState(state);
}
return;
}
// ── pull_request review_request_removed ──────────────────
// Mirror the assignment logic: remove the reviewer from
// whichever stage's bucket they're in (they may have been
// pre-pinned to a stage other than the current one via the
// `assign:<stage>` label mechanism).
if (eventName === 'pull_request' && action === 'review_request_removed') {
if (!existingLane || !existingStage) return;
const removed = context.payload.requested_reviewer && context.payload.requested_reviewer.login;
if (!removed) return;
const state = await readState();
if (!state) return;
let mutated = false;
for (const stage of ACTIVE_REVIEW_STAGES) {
if (!state[stage]) continue;
const idx = state[stage].indexOf(removed);
if (idx !== -1) {
state[stage].splice(idx, 1);
mutated = true;
}
}
if (mutated) await writeState(state);
return;
}
// ── pull_request labeled ─────────────────────────────────
if (eventName === 'pull_request' && action === 'labeled') {
const added = context.payload.label.name;
// Admin override: advance a PR past the technical edit stage
// without requiring the author to check the PR description
// checkbox. Used when the technical edit was done by a human,
// or when the author isn't around.
// - Lane 1 (new-doc): advances to Director review
// - Lanes 2 & 3 (major/minor revision): advances to ready-to-merge
if (added === 'admin:advance-to-director') {
const lanesWithTechEdit = ['lane:new-doc', 'lane:major-revision', 'lane:minor-revision'];
if (!lanesWithTechEdit.includes(existingLane) || existingStage !== 'stage:ai-editor-review') {
await removeLabel('admin:advance-to-director');
await postComment(`⚠️ **admin:advance-to-director** can only be applied to a PR currently at \`stage:ai-editor-review\` in a lane that includes technical edit. Label removed, no action taken.`);
return;
}
await removeLabel('admin:advance-to-director');
await removeLabel('stage:ai-editor-review');
if (existingLane === 'lane:new-doc') {
await addLabels(['stage:director-review']);
await setReviewStatus('pending', STAGE_STATUS_DESC['stage:director-review']);
await postComment(`✅ **Technical edit marked complete** by site admin override. Advancing to **Director review**.\n\n@usace-rmc/docs-admin please trigger the checkpoint deploy of \`${branch}\` and assign a director reviewer. See the Site Admin Workflow chapter of the Documentation Guide for the full sequence.`);
} else {
await addLabels(['stage:ready-to-merge']);
await setReviewStatus('success', STAGE_STATUS_DESC['stage:ready-to-merge']);
await postComment(`✅ **Technical edit marked complete** by site admin override. This PR is **ready for final merge and publication**.\n\n@usace-rmc/docs-admin please flip the \`draft\` flag, update \`00-version-history.mdx\`, merge to \`main\`, and approve the production deploy. See the Site Admin Workflow chapter of the Documentation Guide for the full sequence.`);
}
return;
}
// Admin override: re-approve a `stage:ready-to-merge` PR after
// post-approval commits were pushed. The bot flipped the merge
// gate to pending when those commits landed; this label flips
// it back to success and is then removed.
if (added === 'admin:approve-merge-after-push') {
if (existingStage !== 'stage:ready-to-merge') {
await removeLabel('admin:approve-merge-after-push');
await postComment(`⚠️ **admin:approve-merge-after-push** can only be applied to a PR currently at \`stage:ready-to-merge\`. Label removed, no action taken.`);
return;
}
await removeLabel('admin:approve-merge-after-push');
await setReviewStatus('success', STAGE_STATUS_DESC['stage:ready-to-merge']);
await postComment(`✅ **Post-push merge re-approved** by @${context.payload.sender.login}. The \`review-workflow\` status is back to success and the PR is mergeable.`);
return;
}
// Lane manually applied by an admin (overrides branch-name detection)
if (LANE_LABELS.includes(added) && (!existingStage || existingStage === 'stage:needs-lane')) {
await removeLabel('stage:needs-lane');
await ensureState();
if (added === 'lane:editorial-fix' || added === 'lane:dev') {
await addLabels(['stage:ready-to-merge']);
const statusDesc = added === 'lane:editorial-fix' ? 'Editorial fix — admin may merge' : 'Dev doc — admin may merge';
const laneLabel = added === 'lane:editorial-fix' ? 'editorial fix' : 'dev doc';
await setReviewStatus('success', statusDesc);
await postComment(`📋 Lane set to **${laneLabel}**.\n\n@usace-rmc/docs-admin please review and merge.`);
} else {
await addLabels(['stage:peer-review']);
await setReviewStatus('pending', STAGE_STATUS_DESC['stage:peer-review']);
await postComment(`📋 Lane set to **${added.replace('lane:', '').replace(/-/g, ' ')}**. Moving to peer review.\n\n@usace-rmc/docs-admin please assign the peer reviewer(s).`);
}
}
return;
}
// ── pull_request edited (author checks technical-edit box) ──
if (eventName === 'pull_request' && action === 'edited') {
if (existingStage === 'stage:ai-editor-review') {
const checkboxChecked = prBody.includes('[x] Technical edit comments addressed');
if (checkboxChecked) {
await removeLabel('stage:ai-editor-review');
if (existingLane === 'lane:new-doc') {
// Lane 1: advance to Director review with checkpoint deploy
await addLabels(['stage:director-review']);
await setReviewStatus('pending', STAGE_STATUS_DESC['stage:director-review']);
await postComment(`✅ **Technical edit marked complete** by the author. Advancing to **Director review**.\n\n@usace-rmc/docs-admin please trigger the checkpoint deploy of \`${branch}\` and assign a director reviewer. See the Site Admin Workflow chapter of the Documentation Guide for the full sequence.`);
} else {
// Lanes 2 & 3: no Director review — ready to merge
await addLabels(['stage:ready-to-merge']);
await setReviewStatus('success', STAGE_STATUS_DESC['stage:ready-to-merge']);
await postComment(`✅ **Technical edit marked complete** by the author. This PR is **ready for final merge and publication**.\n\n@usace-rmc/docs-admin please flip the \`draft\` flag, update \`00-version-history.mdx\`, merge to \`main\`, and approve the production deploy. See the Site Admin Workflow chapter of the Documentation Guide for the full sequence.`);
}
}
}
return;
}
// ── pull_request_review submitted / dismissed ────────────
// Three review outcomes are handled:
// * approved — advance the stage if the approver is in the
// assigned list for the current stage
// * changes_requested — acknowledge with a comment; no stage
// change (the author needs to push fixes first)
// * dismissed — warn admin that a prior approval was dropped;
// do not auto-revert the stage (too risky)
// The `commented` and `edited` cases are intentionally ignored.
if (eventName === 'pull_request_review') {
if (!existingLane || !existingStage) return;
if (!ACTIVE_REVIEW_STAGES.includes(existingStage)) return;
const reviewAction = context.payload.action;
const reviewState = context.payload.review.state;
const reviewer = context.payload.review.user.login;
if (reviewAction === 'submitted' && reviewState === 'changes_requested') {
const stageDisplay = STAGE_DISPLAY[existingStage] || existingStage;
await postComment(`📝 **Changes requested** by @${reviewer} at **${stageDisplay}**.\n\n@${prAuthor} please address the comments and push fixes. The stage will advance once an assigned reviewer approves.`);
return;
}
if (reviewAction === 'dismissed') {
const dismissedBy = (context.payload.sender && context.payload.sender.login) || 'unknown';
await postComment(`⚠️ **Review by @${reviewer} was dismissed** by @${dismissedBy}.\n\nThe stage label has not been changed automatically. @usace-rmc/docs-admin if this dismissal means a prior stage's approval is no longer valid, please manually revert the stage labels and request a re-review.`);
return;
}
// Only approval submissions reach here.
if (reviewAction !== 'submitted' || reviewState !== 'approved') return;
const state = await readState();
const assigned = (state && state[existingStage]) || [];
if (!assigned.includes(reviewer)) {
const assignedList = assigned.length ? assigned.map(u => '@' + u).join(', ') : '_(none assigned yet)_';
await postComment(`ℹ️ **Approval from @${reviewer} logged, but stage not advanced.**\n\n@${reviewer} is not in the list of assigned reviewers for **${STAGE_DISPLAY[existingStage] || existingStage}**. The stage will advance only when an approval is received from one of the assigned reviewers: ${assignedList}.\n\nIf @${reviewer} should be advancing the stage, @usace-rmc/docs-admin please assign them via the Reviewers sidebar and ask them to re-approve.`);
return;
}
let nextStage = null, comment = null;
if (existingLane === 'lane:new-doc') {
if (existingStage === 'stage:peer-review') {
nextStage = 'stage:lead-civil-review';
comment = `✅ **Peer review approved** by @${reviewer}. Advancing to **RMC Lead Civil review**.\n\n@usace-rmc/docs-admin please assign the Lead Civil reviewer(s) via the Reviewers sidebar. The Lead Civil reviews on the preview URL.`;
} else if (existingStage === 'stage:lead-civil-review') {
nextStage = 'stage:ai-editor-review';
comment = `✅ **Lead Civil review approved** by @${reviewer}. Advancing to **technical edit**.\n\n@usace-rmc/docs-admin please run the \`/technical-edit\` Claude Code skill against this PR (or assign a human technical editor). No live deploy is needed at this stage — see the Site Admin Workflow chapter of the Documentation Guide for the full sequence.`;
} else if (existingStage === 'stage:director-review') {
nextStage = 'stage:ready-to-merge';
comment = `✅ **Director review approved** by @${reviewer}. This PR is **ready for final merge and publication**.\n\n@usace-rmc/docs-admin please flip the \`draft\` flag, update \`00-version-history.mdx\`, merge to \`main\`, and approve the production deploy. See the Site Admin Workflow chapter of the Documentation Guide for the full sequence.`;
}
} else if (existingLane === 'lane:major-revision') {
if (existingStage === 'stage:peer-review') {
nextStage = 'stage:lead-civil-review';
comment = `✅ **Peer review approved** by @${reviewer}. Advancing to **RMC Lead Civil review**.\n\n@usace-rmc/docs-admin please assign the Lead Civil reviewer(s) via the Reviewers sidebar.`;
} else if (existingStage === 'stage:lead-civil-review') {
nextStage = 'stage:ai-editor-review';
comment = `✅ **Lead Civil review approved** by @${reviewer}. Advancing to **technical edit**.\n\n@usace-rmc/docs-admin please run the \`/technical-edit\` Claude Code skill against this PR (or assign a human technical editor).`;
}
} else if (existingLane === 'lane:minor-revision') {
if (existingStage === 'stage:peer-review') {
nextStage = 'stage:ai-editor-review';
comment = `✅ **Peer review approved** by @${reviewer}. Advancing to **technical edit**.\n\n@usace-rmc/docs-admin please run the \`/technical-edit\` Claude Code skill against this PR (or assign a human technical editor).`;
}
}
if (nextStage) {
await removeLabel(existingStage);
await addLabels([nextStage]);
if (nextStage === 'stage:ready-to-merge') {
await setReviewStatus('success', STAGE_STATUS_DESC['stage:ready-to-merge']);
} else if (STAGE_STATUS_DESC[nextStage]) {
await setReviewStatus('pending', STAGE_STATUS_DESC[nextStage]);
}
await postComment(comment);
}
}