mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Merge pull request #345 from fsolla/cursor/pool-i296-f91a8b70-0be4
OPS18 — Promote plan-issue dual (agente + Action no merge)
This commit is contained in:
commit
7753470a3b
12 changed files with 318 additions and 20 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -157,7 +157,7 @@ Sem `--plan`: nasce `ready` (use `--blocked` só se quiser não-claimável sem p
|
|||
pnpm agent:ready -- --issue <N[,N…]>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
40
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
40
.github/workflows/plan-issue-ready-on-main-merge.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# 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.
|
||||
# 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
|
||||
pull-requests: read
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
plan-issue-ready:
|
||||
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
|
||||
with:
|
||||
node-version: 24
|
||||
- name: Promote Related #N awaiting plan → ready
|
||||
run: node scripts/agent-promote-related-on-merge.mjs --pr "$PR_NUMBER"
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -59,13 +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` | — | `in-progress` → `done` + `in-prod` |
|
||||
| `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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<slug>/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.
|
||||
|
|
|
|||
89
docs/plans/plan-issue-promote-dual-impl.md
Normal file
89
docs/plans/plan-issue-promote-dual-impl.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# 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 `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
|
||||
|
||||
- **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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
82
scripts/agent-promote-related-on-merge.mjs
Normal file
82
scripts/agent-promote-related-on-merge.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* 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-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); optional PR_BODY skips `gh pr view` (Actions event).
|
||||
*/
|
||||
|
||||
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')
|
||||
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 <N>')
|
||||
}
|
||||
|
||||
const prNumber = Number(flags.pr)
|
||||
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`)
|
||||
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 === 'already-ready') {
|
||||
console.log(`[agent:promote-related] #${number}: already ready — skip`)
|
||||
continue
|
||||
}
|
||||
console.log(`[agent:promote-related] #${number}: skip (${verdict.reason})`)
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,34 @@ export const resolveRegisterStateLabel = ({ hasPlan, explicitBlocked = false })
|
|||
|
||||
const TERMINAL_OR_ACTIVE = ['in-progress', 'done', 'in-prod']
|
||||
|
||||
/**
|
||||
* 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 = /(?<![\w-])related\s+#(\d+)\b/gi
|
||||
|
||||
/**
|
||||
* Issue numbers cited as `Related #N` in a PR body (plans-only contract).
|
||||
* Deduped, stable first-seen order. Only matches the Related keyword —
|
||||
* closing keywords are owned by issue-done and never appear here.
|
||||
*
|
||||
* @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).
|
||||
|
|
@ -31,6 +61,9 @@ const TERMINAL_OR_ACTIVE = ['in-progress', 'done', 'in-prod']
|
|||
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' }
|
||||
|
|
|
|||
|
|
@ -168,6 +168,8 @@ export const RelationChipCell = ({
|
|||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const chipRowRef = useRef<HTMLDivElement>(null)
|
||||
/** Blur debounce — cleared on unmount so setState cannot fire after jsdom teardown (CI flake). */
|
||||
const blurCloseTimeoutRef = useRef<number | null>(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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'
|
|||
|
||||
import {
|
||||
canPromotePlanIssue,
|
||||
parseRelatedIssueNumbers,
|
||||
resolveRegisterStateLabel,
|
||||
} from '../../scripts/lib/agent-plan-lifecycle.mjs'
|
||||
|
||||
|
|
@ -55,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) => {
|
||||
|
|
@ -87,3 +95,31 @@ 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('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([])
|
||||
expect(parseRelatedIssueNumbers('no refs here')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue