diff --git a/.github/workflows/plan-issue-ready-on-main-merge.yml b/.github/workflows/plan-issue-ready-on-main-merge.yml index 64728905..d7d8d8a7 100644 --- a/.github/workflows/plan-issue-ready-on-main-merge.yml +++ b/.github/workflows/plan-issue-ready-on-main-merge.yml @@ -3,11 +3,13 @@ # to `ready`. Idempotent with `pnpm agent:ready` (agent happy path). # # Does NOT run on open PRs — only after merge — so the claim race stays closed. +# Job is gated on Related # in the body so non-plan merges skip checkout/Node. name: Promote plan-issue Related on main merge on: pull_request: types: [closed] + branches: [main] permissions: issues: write @@ -16,12 +18,19 @@ permissions: jobs: plan-issue-ready: - if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true + if: >- + github.event.pull_request.merged == true && + ( + contains(github.event.pull_request.body, 'Related #') || + contains(github.event.pull_request.body, 'related #') || + contains(github.event.pull_request.body, 'RELATED #') + ) runs-on: ubuntu-latest timeout-minutes: 5 env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BODY: ${{ github.event.pull_request.body }} steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 diff --git a/docs/plans/plan-issue-promote-dual-impl.md b/docs/plans/plan-issue-promote-dual-impl.md index f5334346..6520e547 100644 --- a/docs/plans/plan-issue-promote-dual-impl.md +++ b/docs/plans/plan-issue-promote-dual-impl.md @@ -69,8 +69,10 @@ flowchart LR - Promover no `ci-pr` / checks do PR aberto. - Label nova `needs:plan`. - Matar o promote do agente (`agent:ready`). -- Hoist de `HUMAN_GATE_LABELS` / `issueHasPlanLink` para módulo neutro (ainda 2–3 call sites; defer). -- Batch soft-fail em `agent:ready` CLI. +- Hoist `HUMAN_GATE_LABELS` / `issueHasPlanLink` / lista ativa-ou-terminal para módulo neutro — defer até 3º consumidor fora do lifecycle. +- Extrair executor compartilhado promote-one-issue (`agent:ready` × Action) — defer até 3º call site ou política de falha convergente. +- Unificar workflows de merge (`issue-done` + plan-ready) num único runner — defer; acoplamento rejeitado na decisão B. +- Comentário duplicado se agente e Action promovem na mesma janela — benigno; labels idempotentes. ## Riscos e mitigação diff --git a/scripts/agent-promote-related-on-merge.mjs b/scripts/agent-promote-related-on-merge.mjs index 63c8e950..65c98913 100644 --- a/scripts/agent-promote-related-on-merge.mjs +++ b/scripts/agent-promote-related-on-merge.mjs @@ -2,19 +2,20 @@ * OPS18 safety net — on PR merge to `main`, promote plan-issue Issues cited as * `Related #N` from `blocked` → `ready` when they are still awaiting a plan. * - * Soft-skip (never fail the workflow) when an issue is already ready, product- - * blocked without a plan link, gated by needs:*, or otherwise ineligible. - * Idempotent with `pnpm agent:ready`. + * Soft-skips ineligible Issues (already ready, product-blocked without a plan + * link, needs:*, etc.). Flip/comment failures for one Issue are logged and do + * not abort siblings. Idempotent with `pnpm agent:ready`. * * node scripts/agent-promote-related-on-merge.mjs --pr 123 + * PR_BODY='Related #296' node scripts/agent-promote-related-on-merge.mjs --pr 123 * - * Env: GH_TOKEN (Actions), GITHUB_REPOSITORY (optional — gh uses default remote). + * Env: GH_TOKEN (Actions); optional PR_BODY skips `gh pr view` (Actions event). */ -import { dieAgent, gh, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs' +import { dieAgent, gh, ghJson, parseArgs, setLabels } from './lib/agent-github.mjs' import { canPromotePlanIssue, parseRelatedIssueNumbers } from './lib/agent-plan-lifecycle.mjs' -const die = dieAgent('promote-related-on-merge') +const die = dieAgent('promote-related') const { flags } = parseArgs(process.argv.slice(2), new Set(['pr'])) if (!flags.pr || !/^\d+$/.test(String(flags.pr))) { @@ -22,8 +23,12 @@ if (!flags.pr || !/^\d+$/.test(String(flags.pr))) { } const prNumber = Number(flags.pr) -const pr = ghJson(['pr', 'view', String(prNumber), '--json', 'body,number,url']) -const numbers = parseRelatedIssueNumbers(pr.body) +const bodyFromEnv = process.env.PR_BODY +const body = + typeof bodyFromEnv === 'string' + ? bodyFromEnv + : ghJson(['pr', 'view', String(prNumber), '--json', 'body']).body +const numbers = parseRelatedIssueNumbers(body) if (numbers.length === 0) { console.log(`[agent:promote-related] PR #${prNumber}: no Related #N — nothing to do`) @@ -55,7 +60,7 @@ for (const number of numbers) { const verdict = canPromotePlanIssue(issue) if (!verdict.ok) { - if (verdict.reason === 'not-blocked' && labelNames(issue).includes('ready')) { + if (verdict.reason === 'already-ready') { console.log(`[agent:promote-related] #${number}: already ready — skip`) continue } @@ -63,13 +68,21 @@ for (const number of numbers) { continue } - setLabels(number, { add: ['ready'], remove: ['blocked'] }) - gh([ - 'issue', - 'comment', - String(number), - '--body', - `Promovido a \`ready\` via Action OPS18 — plano de intenção em \`main\` (PR #${prNumber} com \`Related #${number}\`).`, - ]) - console.log(`[agent:promote-related] #${number}: blocked → ready`) + try { + setLabels(number, { add: ['ready'], remove: ['blocked'] }) + gh([ + 'issue', + 'comment', + String(number), + '--body', + `Promovido a \`ready\` via Action OPS18 — plano de intenção em \`main\` (PR #${prNumber} com \`Related #${number}\`).`, + ]) + console.log(`[agent:promote-related] #${number}: blocked → ready`) + } catch (error) { + console.log( + `[agent:promote-related] #${number}: promote failed (${ + error instanceof Error ? error.message : String(error) + })`, + ) + } } diff --git a/scripts/agent-ready.mjs b/scripts/agent-ready.mjs index 7640b23f..210b5156 100644 --- a/scripts/agent-ready.mjs +++ b/scripts/agent-ready.mjs @@ -11,7 +11,7 @@ * link stay for human triage. */ -import { dieAgent, gh, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs' +import { dieAgent, gh, ghJson, parseArgs, setLabels } from './lib/agent-github.mjs' import { canPromotePlanIssue } from './lib/agent-plan-lifecycle.mjs' const die = dieAgent('ready') @@ -42,7 +42,7 @@ for (const number of numbers) { ]) const verdict = canPromotePlanIssue(issue) if (!verdict.ok) { - if (verdict.reason === 'not-blocked' && labelNames(issue).includes('ready')) { + if (verdict.reason === 'already-ready') { console.log(`[agent:ready] #${number} already ready — skip`) continue } diff --git a/scripts/lib/agent-plan-lifecycle.mjs b/scripts/lib/agent-plan-lifecycle.mjs index 69e2d056..9ae91f4f 100644 --- a/scripts/lib/agent-plan-lifecycle.mjs +++ b/scripts/lib/agent-plan-lifecycle.mjs @@ -23,12 +23,17 @@ export const resolveRegisterStateLabel = ({ hasPlan, explicitBlocked = false }) const TERMINAL_OR_ACTIVE = ['in-progress', 'done', 'in-prod'] -/** `Related #N` only — never closing keywords (those are owned by issue-done). */ -const RELATED_ISSUE_RE = /\brelated\s+#(\d+)\b/gi +/** + * Whole-word `Related #N` only (case-insensitive). Does not match + * closing keywords (`Closes`/`Fixes`/`Resolves`) and rejects hyphenated + * compounds like `non-related #5`. + */ +const RELATED_ISSUE_RE = /(? { export const canPromotePlanIssue = (issue) => { if ((issue?.state ?? 'OPEN') !== 'OPEN') return { ok: false, reason: 'not-open' } const labels = labelNames(issue ?? { labels: [] }) + if (labels.includes('ready') && !labels.includes('blocked')) { + return { ok: false, reason: 'already-ready' } + } if (!labels.includes('blocked')) return { ok: false, reason: 'not-blocked' } if (TERMINAL_OR_ACTIVE.some((label) => labels.includes(label))) { return { ok: false, reason: 'state-label' } diff --git a/tests/unit/agentPlanLifecycle.unit.spec.ts b/tests/unit/agentPlanLifecycle.unit.spec.ts index a674f6b9..cf6b9fe1 100644 --- a/tests/unit/agentPlanLifecycle.unit.spec.ts +++ b/tests/unit/agentPlanLifecycle.unit.spec.ts @@ -56,12 +56,19 @@ describe('canPromotePlanIssue', () => { }) it('rejects issues that are not blocked', () => { - expect(canPromotePlanIssue(issue({ labels: [{ name: 'ready' }] }))).toEqual({ + expect(canPromotePlanIssue(issue({ labels: [{ name: 'prio:P2' }] }))).toEqual({ ok: false, reason: 'not-blocked', }) }) + it('reports already-ready when ready without blocked (idempotent skip)', () => { + expect(canPromotePlanIssue(issue({ labels: [{ name: 'ready' }] }))).toEqual({ + ok: false, + reason: 'already-ready', + }) + }) + it.each(['in-progress', 'done', 'in-prod'])( 'rejects when %s coexists with blocked', (stateLabel) => { @@ -96,12 +103,20 @@ describe('parseRelatedIssueNumbers', () => { ]) }) - it('ignores closing keywords (Closes/Fixes/Resolves)', () => { + it('does not match closing keywords (Closes/Fixes/Resolves)', () => { expect(parseRelatedIssueNumbers('Closes #10\nFixes #11\nResolves #12\nRelated #296')).toEqual([ 296, ]) }) + it('rejects hyphenated compounds and Related without whitespace', () => { + expect(parseRelatedIssueNumbers('non-related #5 and Related#296')).toEqual([]) + }) + + it('accepts markdown list form', () => { + expect(parseRelatedIssueNumbers('- Related #296\n- Related #301')).toEqual([296, 301]) + }) + it('returns empty for missing/blank bodies', () => { expect(parseRelatedIssueNumbers(undefined)).toEqual([]) expect(parseRelatedIssueNumbers('')).toEqual([])