Initial webpage release of 2020 LifeSim Technical Reference Manual. Updated applicable items to 2026. #108
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } | |
| } |