fix(ops): OPS18 simplify — Related regex, already-ready, job gate

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 <fsolla@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-08-03 00:22:55 +00:00
parent b7f4e16c0f
commit 7665b1062c
No known key found for this signature in database
6 changed files with 75 additions and 28 deletions

View file

@ -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

View file

@ -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

View file

@ -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)
})`,
)
}
}

View file

@ -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
}

View file

@ -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 = /(?<![\w-])related\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.
* 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[]}
@ -56,6 +61,9 @@ export const parseRelatedIssueNumbers = (body) => {
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' }

View file

@ -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([])