mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 23:35:20 +00:00
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:
parent
b7f4e16c0f
commit
7665b1062c
6 changed files with 75 additions and 28 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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([])
|
||||
|
|
|
|||
Loading…
Reference in a new issue