From b7f4e16c0f2a856cc8873996deed252ebf56e855 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 00:20:48 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(ops):=20OPS18=20=E2=80=94=20Action=20p?= =?UTF-8?q?romote=20Related=20#N=20no=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safety net dual com agent:ready: no merge em main, workflow lê Related #N e promove Issues ainda aguardando plano (canPromotePlanIssue), soft-skip idempotente. Helper parseRelatedIssueNumbers + pins unitários. Co-authored-by: Francisco Solla --- .cursor/skills/agent-pool/SKILL.md | 2 +- .cursor/skills/plan-issue/SKILL.md | 2 +- .../plan-issue-ready-on-main-merge.yml | 31 +++++++ docs/AGENT-OPS.md | 5 +- docs/CHANGELOG-AGENTS.md | 2 + docs/plans/plan-issue-promote-dual-impl.md | 87 +++++++++++++++++++ docs/plans/plan-issue-promote-dual.md | 6 +- scripts/agent-promote-related-on-merge.mjs | 75 ++++++++++++++++ scripts/lib/agent-plan-lifecycle.mjs | 31 ++++++- tests/unit/agentPlanLifecycle.unit.spec.ts | 21 +++++ 10 files changed, 252 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/plan-issue-ready-on-main-merge.yml create mode 100644 docs/plans/plan-issue-promote-dual-impl.md create mode 100644 scripts/agent-promote-related-on-merge.mjs diff --git a/.cursor/skills/agent-pool/SKILL.md b/.cursor/skills/agent-pool/SKILL.md index 8018f84e..3dffd77a 100644 --- a/.cursor/skills/agent-pool/SKILL.md +++ b/.cursor/skills/agent-pool/SKILL.md @@ -93,7 +93,7 @@ Os wrappers disparam `gh workflow run agent-pool.yml -f action=…`; acompanhe c `ready` → (tick: claim coordenado, flip + marcador) → `in-progress` → worker spawnado com o `model:` da Issue (fallback `composer-2.5` + **`fast=false`** — omitir `fast` na API vira `composer-2.5-fast` no usage) → PR `--base main` com `Closes #N` → CI green → auto-merge → `done`+`in-prod` (flip determinístico do CI) → tick arquiva o agente e repõe o slot. -Issues nascidas de `plan-issue` com `--plan` **não** entram como `ready`: ficam `blocked` até o plano de intenção estar em `main` e `pnpm agent:ready` (OPS17). O tick não promove — só consome `ready`. +Issues nascidas de `plan-issue` com `--plan` **não** entram como `ready`: ficam `blocked` até o plano de intenção estar em `main` e promote dual (OPS17 `pnpm agent:ready` + OPS18 Action no merge com `Related #N`). O tick não promove — só consome `ready`. - **Falha terminal** (run ERROR/CANCELLED/EXPIRED ou fim sem PR): tick comenta, move a Issue para `blocked` e arquiva o agente. **Triage humana**: ler o run em cursor.com/agents, decidir — re-`ready` manual se transitório (o circuit breaker recusa a 3ª tentativa automática), corrigir a spec se sistêmico. - **Worker travado**: archive em cursor.com/agents → o próximo tick reconcilia como falha documentada. diff --git a/.cursor/skills/plan-issue/SKILL.md b/.cursor/skills/plan-issue/SKILL.md index 6a0d1c1a..5bd89714 100644 --- a/.cursor/skills/plan-issue/SKILL.md +++ b/.cursor/skills/plan-issue/SKILL.md @@ -157,7 +157,7 @@ Sem `--plan`: nasce `ready` (use `--blocked` só se quiser não-claimável sem p pnpm agent:ready -- --issue ``` -A Action de merge (OPS18) é safety net se este passo falhar — não pule o promote do agente no caminho feliz. +A Action de merge (`plan-issue-ready-on-main-merge.yml`, OPS18) é safety net se este passo falhar — não pule o promote do agente no caminho feliz. **NÃO faz:** editar `docs/roadmap.md`; implementar código; claim; escrever `*-impl.md`; editar plano de Issue `in-progress`/`done`/`in-prod`; marcar `ready` antes do plano em `main`; registrar/abrir PR antes do gate. diff --git a/.github/workflows/plan-issue-ready-on-main-merge.yml b/.github/workflows/plan-issue-ready-on-main-merge.yml new file mode 100644 index 00000000..64728905 --- /dev/null +++ b/.github/workflows/plan-issue-ready-on-main-merge.yml @@ -0,0 +1,31 @@ +# Safety net OPS18: when a PR merges to main with `Related #N`, promote Issues +# that are still awaiting their intention plan (`blocked` + docs/plans/ link) +# 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. +name: Promote plan-issue Related on main merge + +on: + pull_request: + types: [closed] + +permissions: + issues: write + pull-requests: read + contents: read + +jobs: + plan-issue-ready: + if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.merged == true + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 + with: + node-version: 24 + - name: Promote Related #N awaiting plan → ready + run: node scripts/agent-promote-related-on-merge.mjs --pr "$PR_NUMBER" diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index b157e5c6..0861ddce 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -49,7 +49,7 @@ main → ci.yml full suite → vercel deploy --prod (se verde) → requeue se HE Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`. -**plan-issue (OPS17):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), `pnpm agent:ready` promove a `ready`. Chores sem plano continuam nascendo `ready`. +**plan-issue (OPS17 + OPS18):** gate pós-overview fecha o lote antes de qualquer Issue/PR; register com `--plan` nasce `blocked` (não entra na fila do claim/pool); após o PR de planos mergear em `main` (`Related #N`), promote dual idempotente: (A) `pnpm agent:ready` na sessão; (B) Action `plan-issue-ready-on-main-merge.yml` lê `Related #N` e promove Issues ainda “aguardando plano” (`blocked` + link `docs/plans/`). Chores sem plano continuam nascendo `ready`. ## Contrato de PR @@ -63,7 +63,8 @@ Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`. | ------------------------------ | -------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- | | `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | | `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho → `deploy` (`vercel deploy --prod`) → `requeue` se HEAD andou | -| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `in-progress` → `done` + `in-prod` | +| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` | +| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) | | `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --merge` (safety net; audit incluso) | | `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) | diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 3a8179e8..020e4852 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**Recently resolved (2026-08-03):** **OPS18** (Issue #296) — promote dual plan-issue: Action `plan-issue-ready-on-main-merge.yml` no merge lê `Related #N` e promove Issues ainda aguardando plano (`canPromotePlanIssue`); idempotente com `pnpm agent:ready`. Planos: [`plan-issue-promote-dual.md`](docs/plans/plan-issue-promote-dual.md), impl [`plan-issue-promote-dual-impl.md`](docs/plans/plan-issue-promote-dual-impl.md). + **Recently resolved (2026-08-02):** **OPS17** (Issue #292) — ciclo plan-issue: gate pós-overview antes de Issue/PR; `agent:register --plan` nasce `blocked`; após plano em `main`, `pnpm agent:ready` promove a `ready`. Planos: [`plan-issue-lifecycle-ready.md`](docs/plans/plan-issue-lifecycle-ready.md), impl [`plan-issue-lifecycle-ready-impl.md`](docs/plans/plan-issue-lifecycle-ready-impl.md). Action dual no merge = OPS18 (#296). **Recently resolved (2026-08-02):** **B147** (Issue #330) — rota paralela `/campanha/municipio//v2` com faixa de status (nível · tendência · sinal · classe · frescor) e modal de motivo opcional; soft-dep B134 no write de nível (motivo opcional, sem “voltar atrás”). Intenção [`municipio-v2-shell-status.md`](docs/plans/municipio-v2-shell-status.md); impl [`municipio-v2-shell-status-impl.md`](docs/plans/municipio-v2-shell-status-impl.md). Filhos B148–B152. diff --git a/docs/plans/plan-issue-promote-dual-impl.md b/docs/plans/plan-issue-promote-dual-impl.md new file mode 100644 index 00000000..f5334346 --- /dev/null +++ b/docs/plans/plan-issue-promote-dual-impl.md @@ -0,0 +1,87 @@ +# Impl: Promote plan-issue — agente + Action no merge (dual) + +Status: aprovado +Atualizado em: 2026-08-03 +Issue: #296 +Intenção: docs/plans/plan-issue-promote-dual.md +Appetite restante: ~0,5d (workflow + helper puro + script + pins; sem UI) + +## Leitura da intenção + +- **Outcome:** depois do plano em `main`, a Issue `blocked` aguardando plano vira `ready` mesmo se o agente do plan-issue não rodou `pnpm agent:ready`. +- **O que NÃO negociar:** promote só no **merge** (nunca no CI do PR aberto); não promover `blocked` de produto/deps sem heurística conservadora (`blocked` + link `docs/plans/` + sem gates humanos / estados ativos-terminais). +- **O que reavaliar:** “estender `issue-done-on-main-merge.yml` vs job irmão”; “bash inline vs node reusando `canPromotePlanIssue`”; restrição extra a diff `docs/plans/**`. + +## Abordagem recomendada + +```mermaid +flowchart LR + Merge["PR merge → main"] --> Parse["parseRelatedIssueNumbers(body)"] + Parse --> Fetch["gh issue view #N"] + Fetch --> Gate["canPromotePlanIssue"] + Gate -->|ok| Flip["blocked→ready + comment"] + Gate -->|no-op| Skip["log skip"] + Agent["pnpm agent:ready"] --> Gate +``` + +**Opções consideradas:** A) segundo job no mesmo workflow de `Closes`→`done` · B) workflow irmão dedicado + script node que reusa `canPromotePlanIssue` · C) bash inline reimplementando a heurística +**Recomendação:** B — porque o predicado de promote já vive em `scripts/lib/agent-plan-lifecycle.mjs` (OPS17); duplicar em bash é débito; misturar com o job de `Closes` acopla dois contratos e força checkout/`node` no job que hoje é só `gh`. +**Rejeitadas:** A — checkout + install no workflow de done só para um job; C — diverge do pin unitário e do `agent:ready`. + +### Decisões de engenharia + +1. **Onde vive o parse de `Related #N`?** + Opções: A) `plansOnlyClosesGuard.mjs` · B) `agent-plan-lifecycle.mjs` · C) só no script da Action. + **Recomendação:** B — é o lifecycle de promote; o guard de closes só rejeita keywords de fechamento. + **Rejeitadas:** A (domínio errado); C (sem pin unitário). + +2. **Restringir a PRs que tocam `docs/plans/`?** + Opções: A) sim · B) não — só `Related` + `canPromotePlanIssue`. + **Recomendação:** B — `Related #N` já é o contrato dos PRs de planos (`plans-only-closes`); o predicado conservador evita flip de deps de produto. Restrição de path é rabbit hole (API de files, false negatives em PRs mistos). + **Rejeitada:** A até haver falso positivo real. + +3. **Script da Action vs estender `agent:ready`?** + Opções: A) `agent:ready -- --from-pr N` soft · B) script dedicado `agent-promote-related-on-merge.mjs` soft-skip. + **Recomendação:** B — `agent:ready` continua fail-closed para o humano/agente; a Action precisa soft-skip em Issues Related que não estão “aguardando plano”. + **Rejeitada:** A — mistura UX CLI com safety net. + +4. **`pnpm install` no workflow?** + Opções: A) install frozen · B) só `actions/setup-node` + `node scripts/…` (deps só stdlib). + **Recomendação:** B — a cadeia `agent-plan-lifecycle` → `agent-github` / `agent-pool-eligibility` não importa packages npm. + **Rejeitada:** A — custo sem ganho. + +### Componentes / mudanças + +- **`parseRelatedIssueNumbers(body)`** (`scripts/lib/agent-plan-lifecycle.mjs`): puro; case-insensitive; dedupe; ignora `Closes`/`Fixes`. +- **`scripts/agent-promote-related-on-merge.mjs`:** lê body do PR via `gh`; para cada `#N`, `canPromotePlanIssue`; flip + comentário “Action OPS18”; soft-skip (incl. already-ready). +- **`.github/workflows/plan-issue-ready-on-main-merge.yml`:** `pull_request` closed + merged → `main`; checkout + node 24; sem pnpm. +- **Docs:** `docs/AGENT-OPS.md` (tabela CI + nota OPS17/18); changelog curto; skill `plan-issue` já cita OPS18 — só amarrar o nome do workflow se faltar. +- **Migration / Access / Consent / UI:** N/A. + +## Fases verificáveis + +1. **Tracer** — helper + pins unit + script + workflow. +2. **Docs** — AGENT-OPS + changelog; skill se necessário. +3. **Gates** — `pnpm gate:fast`; entrega com `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- 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. + +## Riscos e mitigação + +- **Flip frouxo de `Related` de dep de produto:** mitiga `canPromotePlanIssue` (exige link `docs/plans/` + `blocked` + sem needs:\*/estados). +- **Segunda execução (agente + Action):** idempotente — already-ready = skip. +- **Workflow só na default branch:** igual aos outros merge triggers; merge em `main` é o evento. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards (sem schema/access) +- [x] Testes de domínio: unit em `parseRelatedIssueNumbers` + predicado existente + +Self-score decision-quality: 5/5 diff --git a/docs/plans/plan-issue-promote-dual.md b/docs/plans/plan-issue-promote-dual.md index db86c1c5..75b9a6f2 100644 --- a/docs/plans/plan-issue-promote-dual.md +++ b/docs/plans/plan-issue-promote-dual.md @@ -1,13 +1,13 @@ # Promote plan-issue — agente + Action no merge (dual path) -Status: registrado (blocked até plano em main) -Atualizado em: 2026-08-02 +Status: em execução +Atualizado em: 2026-08-03 Issue: #296 Priority: P1 Model: cursor-grok-4.5-medium Impeccable: A — N/A (workflow + skill; sem UI) Appetite: ~0,5 dia eng; um outcome verificável -Responsável: — +Responsável: pool ## Intenção diff --git a/scripts/agent-promote-related-on-merge.mjs b/scripts/agent-promote-related-on-merge.mjs new file mode 100644 index 00000000..63c8e950 --- /dev/null +++ b/scripts/agent-promote-related-on-merge.mjs @@ -0,0 +1,75 @@ +/** + * 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`. + * + * node scripts/agent-promote-related-on-merge.mjs --pr 123 + * + * Env: GH_TOKEN (Actions), GITHUB_REPOSITORY (optional — gh uses default remote). + */ + +import { dieAgent, gh, ghJson, labelNames, parseArgs, setLabels } from './lib/agent-github.mjs' +import { canPromotePlanIssue, parseRelatedIssueNumbers } from './lib/agent-plan-lifecycle.mjs' + +const die = dieAgent('promote-related-on-merge') +const { flags } = parseArgs(process.argv.slice(2), new Set(['pr'])) + +if (!flags.pr || !/^\d+$/.test(String(flags.pr))) { + die('Usage: node scripts/agent-promote-related-on-merge.mjs --pr ') +} + +const prNumber = Number(flags.pr) +const pr = ghJson(['pr', 'view', String(prNumber), '--json', 'body,number,url']) +const numbers = parseRelatedIssueNumbers(pr.body) + +if (numbers.length === 0) { + console.log(`[agent:promote-related] PR #${prNumber}: no Related #N — nothing to do`) + process.exit(0) +} + +console.log( + `[agent:promote-related] PR #${prNumber}: Related → ${numbers.map((n) => `#${n}`).join(', ')}`, +) + +for (const number of numbers) { + let issue + try { + issue = ghJson([ + 'issue', + 'view', + String(number), + '--json', + 'number,title,body,state,labels', + ]) + } catch (error) { + console.log( + `[agent:promote-related] #${number}: skip (issue view failed: ${ + error instanceof Error ? error.message : String(error) + })`, + ) + continue + } + + const verdict = canPromotePlanIssue(issue) + if (!verdict.ok) { + if (verdict.reason === 'not-blocked' && labelNames(issue).includes('ready')) { + console.log(`[agent:promote-related] #${number}: already ready — skip`) + continue + } + console.log(`[agent:promote-related] #${number}: skip (${verdict.reason})`) + 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`) +} diff --git a/scripts/lib/agent-plan-lifecycle.mjs b/scripts/lib/agent-plan-lifecycle.mjs index 0510b513..69e2d056 100644 --- a/scripts/lib/agent-plan-lifecycle.mjs +++ b/scripts/lib/agent-plan-lifecycle.mjs @@ -1,8 +1,10 @@ /** - * Plan-issue lifecycle helpers (OPS17): register with `--plan` starts non-claimable; - * promote to `ready` only after the intention plan is on `main`. + * Plan-issue lifecycle helpers (OPS17 + OPS18): register with `--plan` starts + * non-claimable; promote to `ready` only after the intention plan is on `main` + * (agent `pnpm agent:ready` and/or merge Action reading `Related #N`). * - * Pure — no IO. Used by `agent-register` / `agent-ready` and pinned by unit tests. + * Pure — no IO. Used by `agent-register` / `agent-ready` / the OPS18 Action + * script and pinned by unit tests. */ import { labelNames } from './agent-github.mjs' @@ -21,6 +23,29 @@ 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 + +/** + * Issue numbers cited as `Related #N` in a PR body (plans-only contract). + * Deduped, stable first-seen order. Ignores Closes/Fixes/Resolves. + * + * @param {string | null | undefined} body + * @returns {number[]} + */ +export const parseRelatedIssueNumbers = (body) => { + if (!body) return [] + const seen = new Set() + const numbers = [] + for (const match of body.matchAll(RELATED_ISSUE_RE)) { + const number = Number(match[1]) + if (seen.has(number)) continue + seen.add(number) + numbers.push(number) + } + return numbers +} + /** * Whether an issue is waiting for its intention plan on `main` and may be * flipped `blocked` → `ready` by `agent:ready` (or the OPS18 Action). diff --git a/tests/unit/agentPlanLifecycle.unit.spec.ts b/tests/unit/agentPlanLifecycle.unit.spec.ts index 158a76e2..a674f6b9 100644 --- a/tests/unit/agentPlanLifecycle.unit.spec.ts +++ b/tests/unit/agentPlanLifecycle.unit.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { canPromotePlanIssue, + parseRelatedIssueNumbers, resolveRegisterStateLabel, } from '../../scripts/lib/agent-plan-lifecycle.mjs' @@ -87,3 +88,23 @@ describe('canPromotePlanIssue', () => { }) }) }) + +describe('parseRelatedIssueNumbers', () => { + it('extracts Related #N case-insensitively and dedupes', () => { + expect(parseRelatedIssueNumbers('Related #296\nrelated #296 and RELATED #301')).toEqual([ + 296, 301, + ]) + }) + + it('ignores closing keywords (Closes/Fixes/Resolves)', () => { + expect(parseRelatedIssueNumbers('Closes #10\nFixes #11\nResolves #12\nRelated #296')).toEqual([ + 296, + ]) + }) + + it('returns empty for missing/blank bodies', () => { + expect(parseRelatedIssueNumbers(undefined)).toEqual([]) + expect(parseRelatedIssueNumbers('')).toEqual([]) + expect(parseRelatedIssueNumbers('no refs here')).toEqual([]) + }) +}) From 7665b1062cd1841c5451bae2bae5284e8b6398ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 00:22:55 +0000 Subject: [PATCH 2/4] =?UTF-8?q?fix(ops):=20OPS18=20simplify=20=E2=80=94=20?= =?UTF-8?q?Related=20regex,=20already-ready,=20job=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aperta parseRelatedIssueNumbers (anti hyphen), canPromotePlanIssue already-ready, workflow só com Related # no body + PR_BODY, soft-fail por Issue no Action script. Co-authored-by: Francisco Solla --- .../plan-issue-ready-on-main-merge.yml | 11 ++++- docs/plans/plan-issue-promote-dual-impl.md | 6 ++- scripts/agent-promote-related-on-merge.mjs | 49 ++++++++++++------- scripts/agent-ready.mjs | 4 +- scripts/lib/agent-plan-lifecycle.mjs | 14 ++++-- tests/unit/agentPlanLifecycle.unit.spec.ts | 19 ++++++- 6 files changed, 75 insertions(+), 28 deletions(-) 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([]) From 07c884ee2e6d52adaf9882f7bc17f5d36c552c73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 00:23:26 +0000 Subject: [PATCH 3/4] style: prettier AGENT-OPS and promote-related script --- docs/AGENT-OPS.md | 16 ++++++++-------- scripts/agent-promote-related-on-merge.mjs | 8 +------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/docs/AGENT-OPS.md b/docs/AGENT-OPS.md index 0861ddce..e03de271 100644 --- a/docs/AGENT-OPS.md +++ b/docs/AGENT-OPS.md @@ -59,14 +59,14 @@ Labels: `ready|in-progress|blocked|done|in-prod`, `prio:*`, `kind:*`, `needs:*`. ## CI por alvo -| Workflow | Trigger | Banco | Passos | -| ------------------------------ | -------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- | -| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | -| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho → `deploy` (`vercel deploy --prod`) → `requeue` se HEAD andou | -| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` | -| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) | -| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --merge` (safety net; audit incluso) | -| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) | +| Workflow | Trigger | Banco | Passos | +| ------------------------------------ | -------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------- | +| `ci-pr.yml` | PR → `main` | service Postgres 17 | Fase 1 barata → fase 2 cara; skips via `ci-scope.mjs`; fail-fast cancela o run no 1º job vermelho; rollup `checks` | +| `ci.yml` | push / dispatch `main` | service Postgres | Full sempre; fail-fast cancela o run no 1º job vermelho → `deploy` (`vercel deploy --prod`) → `requeue` se HEAD andou | +| `issue-done-on-main-merge.yml` | PR merged → `main` | — | `Closes`/`Fixes` → `done` + `in-prod` | +| `plan-issue-ready-on-main-merge.yml` | PR merged → `main` | — | `Related #N` aguardando plano → `ready` (OPS18; soft-skip) | +| `agent-pr-ready-automerge.yml` | PR `cursor/*` → `main` (open/sync/…) | — | Draft→Ready + `gh pr merge --auto --merge` (safety net; audit incluso) | +| `agent-pool.yml` | schedule / PR closed `main` / dispatch | — | Supervisor do pool (`POOL_GITHUB_TOKEN`) | Action runtimes: `actions/checkout@v5`, `actions/setup-node@v5`, `pnpm/action-setup@v6` e `styfle/cancel-workflow-action@0.13.1` usam Node 24 nativo — sem `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24`. diff --git a/scripts/agent-promote-related-on-merge.mjs b/scripts/agent-promote-related-on-merge.mjs index 65c98913..029e827b 100644 --- a/scripts/agent-promote-related-on-merge.mjs +++ b/scripts/agent-promote-related-on-merge.mjs @@ -42,13 +42,7 @@ console.log( for (const number of numbers) { let issue try { - issue = ghJson([ - 'issue', - 'view', - String(number), - '--json', - 'number,title,body,state,labels', - ]) + issue = ghJson(['issue', 'view', String(number), '--json', 'number,title,body,state,labels']) } catch (error) { console.log( `[agent:promote-related] #${number}: skip (issue view failed: ${ From e36634786efc3dcb172a33dad9f5b3ec840c53fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 00:28:41 +0000 Subject: [PATCH 4/4] fix(campaign): clear RelationChipCell blur timeout on unmount Prevents setState after jsdom teardown (window is not defined) that failed CI unit on leadershipStateDeputyRelationCell. --- .../campaign/shared/RelationChipCell.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/campaign/shared/RelationChipCell.tsx b/src/components/campaign/shared/RelationChipCell.tsx index cb8aa3f2..59b426ea 100644 --- a/src/components/campaign/shared/RelationChipCell.tsx +++ b/src/components/campaign/shared/RelationChipCell.tsx @@ -168,6 +168,8 @@ export const RelationChipCell = ({ const rootRef = useRef(null) const inputRef = useRef(null) const chipRowRef = useRef(null) + /** Blur debounce — cleared on unmount so setState cannot fire after jsdom teardown (CI flake). */ + const blurCloseTimeoutRef = useRef(null) const baseId = useId() const listboxId = (surface: SuggestionSurface) => `${baseId}-${surface}-listbox` const optionId = useCallback( @@ -175,6 +177,15 @@ export const RelationChipCell = ({ [baseId], ) + useEffect(() => { + return () => { + if (blurCloseTimeoutRef.current != null) { + window.clearTimeout(blurCloseTimeoutRef.current) + blurCloseTimeoutRef.current = null + } + } + }, []) + useEffect(() => { setOptimistic((current) => { if (!current) return null @@ -685,7 +696,11 @@ export const RelationChipCell = ({ }} onFocus={() => setOpen(true)} onBlur={() => { - window.setTimeout(() => { + if (blurCloseTimeoutRef.current != null) { + window.clearTimeout(blurCloseTimeoutRef.current) + } + blurCloseTimeoutRef.current = window.setTimeout(() => { + blurCloseTimeoutRef.current = null if (!rootRef.current?.contains(document.activeElement)) { closeSuggestions() }