diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 756ef854b..66634f1a3 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: write jobs: build: @@ -19,3 +20,28 @@ jobs: cache: 'npm' - run: npm ci - run: npm run build + - name: Signal admin-may-merge for non-docs PRs + if: success() + uses: actions/github-script@v7 + with: + script: | + const prNumber = context.payload.pull_request.number; + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + per_page: 100, + }); + const touchesDocs = files.some(f => f.filename.startsWith('docs/')); + if (touchesDocs) return; // docs PRs are handled by stage-progression.yml + + const marker = ''; + const sha = context.payload.pull_request.head.sha.substring(0, 7); + const body = `${marker}\n\n✅ **CI build passed** for commit \`${sha}\`\n\nThis PR does not touch documentation content under \`docs/\`, so no multi-stage review is required.\n\n@usace-rmc/docs-admin may merge.`; + const comments = await github.rest.issues.listComments({ owner: context.repo.owner, repo: context.repo.repo, issue_number: prNumber }); + const existing = comments.data.find(c => c.user.type === 'Bot' && c.body.includes(marker)); + 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 }); + } diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 63f869c3f..cae3baf8c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,15 @@ name: Deploy to GitHub Pages on: push: branches: [main] + paths: + - 'docs/**' + - 'src/**' + - 'static/**' + - 'docusaurus.config.js' + - 'tailwind.config.js' + - 'package.json' + - 'package-lock.json' + - 'scripts/**' workflow_dispatch: inputs: ref: diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index bc9d893eb..f0e744b61 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -17,6 +17,10 @@ permissions: contents: read pull-requests: write +concurrency: + group: pr-preview-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: build-and-deploy: name: Build and deploy preview diff --git a/.github/workflows/stage-progression.yml b/.github/workflows/stage-progression.yml index 34b43730d..62e91c41d 100644 --- a/.github/workflows/stage-progression.yml +++ b/.github/workflows/stage-progression.yml @@ -2,7 +2,7 @@ name: Stage Progression on: pull_request: - types: [opened, reopened, labeled, edited] + types: [opened, reopened, synchronize, labeled, edited, review_requested, review_request_removed] pull_request_review: types: [submitted] @@ -10,6 +10,11 @@ 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: @@ -24,12 +29,50 @@ jobs: if (!pr) return; const prNumber = pr.number; - const branch = pr.head.ref; - const labels = pr.labels.map(l => l.name); + 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']; 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'; @@ -38,82 +81,331 @@ jobs: 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 }); } + 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, + }); + } + + async function prTouchesDocs() { + const files = await github.paginate(github.rest.pulls.listFiles, { + owner: context.repo.owner, repo: context.repo.repo, + pull_number: prNumber, per_page: 100, + }); + return files.some(f => f.filename.startsWith('docs/')); + } - const existingLane = labels.find(l => LANE_LABELS.includes(l)); - const existingStage = labels.find(l => STAGE_LABELS.includes(l)); + // ── 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, + }); + } - // ── PR opened/reopened ── - if (context.eventName === 'pull_request' && ['opened', 'reopened'].includes(context.payload.action)) { - // Only auto-process branches under docs/. Non-doc PRs (infrastructure, - // tooling, etc.) are silently ignored on open. An admin can still opt - // any PR into the review process by manually applying a lane:* label, - // which is handled by the labeled-event branch below. - if (!branch.startsWith('docs/')) return; - const lane = existingLane || detectLane(branch); - if (!lane) { - await addLabels(['stage:needs-lane']); - await postComment(`⚠️ **Could not determine review lane**\n\nBranch \`${branch}\` starts with \`docs/\` but does not match an expected sub-prefix (\`docs/new/\`, \`docs/major/\`, \`docs/minor/\`, \`docs/fix/\`).\n\n@usace-rmc/docs-admin please apply the correct \`lane:*\` label.`); - return; + // ── 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 = ''; + const STATE_DATA_RE = //; + + function emptyState() { + return { + 'stage:peer-review': [], + 'stage:lead-civil-review': [], + 'stage:ai-editor-review': [], + 'stage:director-review': [], + }; + } + + function renderStateComment(state) { + const dataLine = ``; + 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') { toAdd.push('stage:ready-to-merge'); + await setReviewStatus('success', 'Editorial fix — admin may merge'); comment = `📋 **Lane: Editorial Fix**\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' : 'Peer review only'; - comment = `📋 **Lane: ${laneName}**\n\nReview scope: ${scope}.\n\nCurrently in **peer review**. If no peer reviewer has been assigned, @usace-rmc/docs-admin please assign one via the Reviewers sidebar.`; + const scope = lane === 'lane:new-doc' ? 'Peer → Lead Civil → Technical Edit → Director' + : lane === 'lane:major-revision' ? 'Peer → Lead Civil' + : 'Peer review only'; + 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)) { + if (!(await prTouchesDocs())) return; + const lane = existingLane || 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}\` starts with \`docs/\` but does not match an expected sub-prefix (\`docs/new/\`, \`docs/major/\`, \`docs/minor/\`, \`docs/fix/\`).` + : `Branch \`${branch}\` does not follow the \`docs/{new,major,minor,fix}/\` naming convention, but this PR modifies files under \`docs/\` and therefore requires a review lane.`; + await postComment(`⚠️ **Could not determine review lane**\n\n${reason}\n\n@usace-rmc/docs-admin please apply the correct \`lane:*\` label.`); + 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; + } + if (!(await prTouchesDocs())) return; + const lane = 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}\` starts with \`docs/\` but does not match an expected sub-prefix.` + : `Branch \`${branch}\` does not follow \`docs/{new,major,minor,fix}/\` naming. This PR now modifies files under \`docs/\` and requires a review lane.`; + await postComment(`⚠️ **Could not determine review lane**\n\n${reason}\n\n@usace-rmc/docs-admin please apply the correct \`lane:*\` label.`); + 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. + // Just re-set the commit status on the new head SHA (since + // statuses are per-SHA) and ping the assigned reviewer(s) to + // backcheck. + if (existingStage === 'stage:ready-to-merge') { + await setReviewStatus('success', STAGE_STATUS_DESC['stage:ready-to-merge']); + await postComment(`⚠️ **New commits pushed after PR reached \`stage:ready-to-merge\`**\n\n@usace-rmc/docs-admin please eyeball the new changes before merging — the bot has re-set the \`review-workflow\` status to success, but this bypasses any incremental review of the latest push.`); + return; + } + if (existingStage && STAGE_STATUS_DESC[existingStage]) { + await setReviewStatus('pending', STAGE_STATUS_DESC[existingStage]); + } + const syncState = await readState(); + if (syncState && existingStage && syncState[existingStage] && syncState[existingStage].length > 0) { + const mentions = syncState[existingStage].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 an individual as a reviewer via the sidebar. + // Record them in the state comment under the current stage. + // Ignored if the request is for a team (payload.requested_team) + // instead of an individual — admins should request individuals. + if (eventName === 'pull_request' && action === 'review_requested') { + if (!existingLane || !existingStage) return; + if (!ACTIVE_REVIEW_STAGES.includes(existingStage)) return; + const requested = context.payload.requested_reviewer && context.payload.requested_reviewer.login; + if (!requested) return; + const state = await ensureState(); + if (!state[existingStage]) state[existingStage] = []; + if (!state[existingStage].includes(requested)) { + state[existingStage].push(requested); + await writeState(state); + } + return; + } + + // ── pull_request review_request_removed ────────────────── + if (eventName === 'pull_request' && action === 'review_request_removed') { + if (!existingLane || !existingStage) return; + if (!ACTIVE_REVIEW_STAGES.includes(existingStage)) return; + const removed = context.payload.requested_reviewer && context.payload.requested_reviewer.login; + if (!removed) return; + const state = await readState(); + if (!state || !state[existingStage]) return; + const idx = state[existingStage].indexOf(removed); + if (idx !== -1) { + state[existingStage].splice(idx, 1); + await writeState(state); + } return; } - // ── Label manually applied ── - if (context.eventName === 'pull_request' && context.payload.action === 'labeled') { + // ── pull_request labeled ───────────────────────────────── + if (eventName === 'pull_request' && action === 'labeled') { const added = context.payload.label.name; + + // Admin override: advance a lane:new-doc PR from technical + // edit to Director review 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. + if (added === 'admin:advance-to-director') { + if (existingLane !== 'lane:new-doc' || existingStage !== 'stage:ai-editor-review') { + await removeLabel('admin:advance-to-director'); + await postComment(`⚠️ **admin:advance-to-director** can only be applied to a \`lane:new-doc\` PR currently at \`stage:ai-editor-review\`. Label removed, no action taken.`); + return; + } + await removeLabel('admin:advance-to-director'); + await removeLabel('stage:ai-editor-review'); + await addLabels(['stage:director-review']); + await setReviewStatus('pending', STAGE_STATUS_DESC['stage:director-review']); + await postComment(`✅ **Technical edit marked complete by site admin override.**\n\nAdvancing to **Director review**.\n\n@usace-rmc/docs-admin next steps:\n1. Trigger a checkpoint deploy of branch \`${branch}\` via Actions → Deploy to GitHub Pages → Run workflow (this is the first deploy of this PR to the live site, with the DRAFT watermark)\n2. Approve the deploy at the production environment gate\n3. Post the live URL in a comment on this PR\n4. Assign a member of @usace-rmc/docs-director via the Reviewers sidebar\n\nThe Director will review at the live URL. If the Director requests changes and the author pushes fixes, re-trigger the checkpoint deploy to refresh the live URL.`); + 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') { await addLabels(['stage:ready-to-merge']); + await setReviewStatus('success', 'Editorial fix — admin may merge'); await postComment(`📋 Lane set to **editorial fix**.\n\n@usace-rmc/docs-admin please review and merge.`); } else { await addLabels(['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.`); + 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; } - // ── PR description edited (check for technical edit checkbox) ── - if (context.eventName === 'pull_request' && context.payload.action === 'edited') { + // ── pull_request edited (author checks technical-edit box) ── + if (eventName === 'pull_request' && action === 'edited') { if (existingStage === 'stage:ai-editor-review' && existingLane === 'lane:new-doc') { - const body = pr.body || ''; - const checkboxChecked = body.includes('[x] Technical edit comments addressed'); + const checkboxChecked = prBody.includes('[x] Technical edit comments addressed'); if (checkboxChecked) { await removeLabel('stage:ai-editor-review'); await addLabels(['stage:director-review']); + await setReviewStatus('pending', STAGE_STATUS_DESC['stage:director-review']); await postComment(`✅ **Technical edit marked complete** by the author.\n\nAdvancing to **Director review**.\n\n@usace-rmc/docs-admin next steps:\n1. Trigger a checkpoint deploy of branch \`${branch}\` via Actions → Deploy to GitHub Pages → Run workflow (this is the first deploy of this PR to the live site, with the DRAFT watermark)\n2. Approve the deploy at the production environment gate\n3. Post the live URL in a comment on this PR\n4. Assign a member of @usace-rmc/docs-director via the Reviewers sidebar\n\nThe Director will review at the live URL. If the Director requests changes and the author pushes fixes, re-trigger the checkpoint deploy to refresh the live URL.`); } } return; } - // ── Review approved ── - if (context.eventName === 'pull_request_review' && context.payload.review.state === 'approved') { + // ── pull_request_review submitted (approved) ───────────── + // Per-individual gating: an approval only advances the stage + // if the approver is in the assigned list for the current + // stage. Drive-by approvals from non-assigned reviewers are + // acknowledged with an info comment but do not advance. + if (eventName === 'pull_request_review' && context.payload.review.state === 'approved') { if (!existingLane || !existingStage) return; + if (!ACTIVE_REVIEW_STAGES.includes(existingStage)) return; const reviewer = context.payload.review.user.login; + + 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}.\n\nAdvancing to **RMC Lead Civil review**.\n\n@usace-rmc/docs-admin please assign the appropriate Lead Civil via the Reviewers sidebar. The Lead Civil reviews on the preview URL.`; + comment = `✅ **Peer review approved** by @${reviewer}.\n\nAdvancing 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}.\n\nAdvancing 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). The technical edit reviews the document source MDX directly and posts inline comments on the PR — **no live deploy is needed at this stage**.\n\nAfter the author addresses the technical edit comments and checks the completion checkbox in the PR description, the document will advance to Director review and the site admin will deploy it to the live site (watermarked) at that point.`; @@ -124,7 +416,7 @@ jobs: } else if (existingLane === 'lane:major-revision') { if (existingStage === 'stage:peer-review') { nextStage = 'stage:lead-civil-review'; - comment = `✅ **Peer review approved** by @${reviewer}.\n\nAdvancing to **RMC Lead Civil review**.\n\n@usace-rmc/docs-admin please assign the appropriate Lead Civil via the Reviewers sidebar.`; + comment = `✅ **Peer review approved** by @${reviewer}.\n\nAdvancing 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:ready-to-merge'; comment = `✅ **Lead Civil review approved** by @${reviewer}.\n\nThis PR is **ready for final merge**.\n\n@usace-rmc/docs-admin next steps:\n1. Check out this branch\n2. Flip the document's \`draft\` flag to \`false\`\n3. Update \`00-version-history.mdx\`\n4. Commit and push\n5. Merge to \`main\`\n6. Approve the production deploy`; @@ -139,6 +431,11 @@ jobs: 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); } } diff --git a/planning/02-documentation-guide-implementation.md b/planning/02-documentation-guide-implementation.md index 0b2955c93..f3b9d11bd 100644 --- a/planning/02-documentation-guide-implementation.md +++ b/planning/02-documentation-guide-implementation.md @@ -297,12 +297,14 @@ Click the **Files changed** tab on the PR. Hover over a line to see the blue "+" Click **Finish your review** (upper right of Files changed). Choose: -- **Approve** — ready to advance -- **Request changes** — author needs to fix issues first -- **Comment** — notes only, no approval or rejection +- **Comment** — you've left notes for the author to address. Use this for every review round where you have feedback. The stage does not advance. +- **Approve** — you're satisfied with the document, typically on a backcheck round after the author has addressed your prior comments. **This is what advances the stage.** +- **Request changes** — _not used in this workflow._ Use **Comment** for routine revision cycles. Click **Submit review.** +The typical cycle: you leave notes via **Comment**, the author addresses them and pushes revisions, the stage progression bot pings you to backcheck, and you return to submit **Approve** if satisfied (or another **Comment** review if more feedback is needed). + ## After you approve The stage progression workflow advances the PR automatically. You don't need to do anything further unless someone tags you with a follow-up question. @@ -423,9 +425,9 @@ Done. The site administrator handles everything from here. ## If something needs fixing -Select **Request changes** instead of Approve and describe the issue. The author will fix it, and you'll be asked to re-review. +Select **Comment** (not Approve or Request changes) and describe the issue. The author will fix it, and the site admin will ask you to re-review. -For minor suggestions you don't want to block on, select Approve and include a note — the site admin will coordinate the follow-up. +For minor suggestions you don't want to block on, select **Approve** and include a note — the site admin will coordinate the follow-up. ``` ---