From f22cacc63b7c503cdde6915ba6c21b98774e8cfe Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:49:21 +0800 Subject: [PATCH 1/3] fix: advance resolving issue status from PRs --- ...-04-forward-only-pr-issue-status.i18n.yaml | 6 +++ ...2026-08-04-forward-only-pr-issue-status.md | 39 ++++++++++++++++ ...6-08-04-forward-only-pr-issue-status.zh.md | 39 ++++++++++++++++ .github/issue-management/policy.mjs | 32 ++++++++++---- .github/issue-management/policy.test.mjs | 44 +++++++++++++++++++ package.json | 1 + scripts/run-gates.ts | 3 ++ 7 files changed, 155 insertions(+), 9 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md create mode 100644 .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml new file mode 100644 index 0000000000..1b704da8f1 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md +2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76 +2026-08-04-forward-only-pr-issue-status.zh.md: f19cceafbde074d298a7c7f27829c8ab919f00b6 diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md new file mode 100644 index 0000000000..dd567707bc --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md @@ -0,0 +1,39 @@ +# Agent Note: Forward-only PR-to-Issue status projection + +Status: implemented + +English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md) + +## Problem + +The Issue Project status represents the phase of the work, while an exact same-repository resolving keyword establishes the authoritative PR-to-Issue relationship. Restricting lifecycle advancement to Issues already in `Ready` leaves an Issue in `Inbox` or `Backlog` after implementation has demonstrably started. Requiring otherwise valid PR metadata before projecting the phase also conflates policy compliance with the work's observable state. + +## Decision + +PR and PR-review events project the current PR phase to every exact same-repository resolving Issue. A draft PR, or a non-draft PR without a review request or submitted review, targets `In progress`. A non-draft PR with either form of review activity targets `In review`. + +The active statuses have the order `Inbox`, `Backlog`, `Ready`, `In progress`, and `In review`. Projection writes only when the target is later in that order. It does not move an Issue backward, alter `Done` or `No action`, or add an Issue that has no Project status. The lifecycle path is independent of PR metadata validation; the separate required PR policy check continues to enforce labels, references, and priority consistency. + +This projection is intentionally one-way. It does not query from an Issue to related PRs, and it does not add a scheduled reconciler. PR events are the source of lifecycle advancement. The pure transition decision is exercised by the Issue-management test and that test runs in the `check-all`, `ci-primary`, and `ci-static` gates. + +## Verification + +`.github/issue-management/policy.test.mjs` covers advancement from every earlier active status, the draft and review distinctions, metadata-policy independence, and protection against backward or terminal transitions. `scripts/run-gates.ts` owns execution of that focused policy test in top-level local and CI gate modes. + +## Alternatives considered + +**Require `Ready` as the only source status.** This preserves a manual prerequisite but leaves stale `Inbox` and `Backlog` items even though the resolving PR proves implementation has begun. + +**Add bidirectional or scheduled reconciliation.** Looking up PRs from Issue events or sweeping the Project could repair more histories, but it adds another authority direction and recurring API work beyond the required PR-driven lifecycle. + +**Gate projection on complete PR metadata.** Labels, references, and priority still require enforcement, but a metadata defect does not make the implementation or review phase untrue. + +**Move statuses backward when a PR becomes a draft or loses reviewers.** That would make transient PR state overwrite a later observed work phase and complicate status ownership. Projection therefore remains monotonic. + +## Consequences + +- A PR event self-corrects a resolving Issue left in `Inbox`, `Backlog`, or `Ready`. +- An Issue created after the last relevant PR event waits for a later PR event or a manual status update because there is no reverse lookup or scheduled sweep. +- A draft PR remains `In progress` even if it has historical review activity; only a non-draft PR targets `In review`. +- Terminal statuses and later active statuses remain protected from regression. +- PR metadata failures remain visible through the required policy check without suppressing lifecycle projection. diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md new file mode 100644 index 0000000000..f19cceafbd --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -0,0 +1,39 @@ +# Agent Note: PR 到 Issue 的状态仅向前投射 + +Status: implemented + +[English](2026-08-04-forward-only-pr-issue-status.md) | 中文 + +## 问题 + +Issue Project 状态表示工作所处阶段,同仓库内精确匹配的解决型关键字引用则建立权威的 PR(Pull Request)到 Issue 关系。若仅允许已处于 `Ready` 的 Issue 推进生命周期,即使实现已经明确开始,处于 `Inbox` 或 `Backlog` 的 Issue 仍会停留在原状态。只有 PR 元数据在其他方面均有效时才投射工作阶段,也会把政策合规性与可观察到的工作状态混为一谈。 + +## 决策 + +PR 事件和 PR 评审事件会把当前 PR 阶段投射到同仓库内被精确引用的每个解决型 Issue。草稿 PR,或既没有评审请求也没有已提交评审的非草稿 PR,目标状态为 `In progress`。具备上述任一类评审活动的非草稿 PR,目标状态为 `In review`。 + +活跃状态依次为 `Inbox`、`Backlog`、`Ready`、`In progress` 和 `In review`。只有目标状态在该顺序中位于当前状态之后时,投射才会写入。投射不会把 Issue 状态向后移动,不会改动 `Done` 或 `No action`,也不会把没有 Project 状态的 Issue 加入 Project。生命周期路径独立于 PR 元数据校验;另行执行的必需 PR 政策检查继续强制落实标签、引用和优先级一致性。 + +这项投射刻意保持单向。它不会从 Issue 反查关联 PR,也不会添加定时对账任务。PR 事件是推进生命周期的来源。Issue 管理测试会验证纯函数实现的状态转换决策,并且该测试会在 `check-all`、`ci-primary` 和 `ci-static` 门禁中运行。 + +## 验证 + +`.github/issue-management/policy.test.mjs` 覆盖从所有更早活跃状态推进、区分草稿与评审状态、独立于元数据政策,以及防止状态倒退或改动终态。`scripts/run-gates.ts` 负责在顶层本地门禁模式和 CI 门禁模式中执行这项专项政策测试。 + +## 考虑过的替代方案 + +**仅允许从 `Ready` 状态推进。** 这种方案保留了人工前置条件,但解决型 PR 已经证明实现开始后,仍会让处于 `Inbox` 和 `Backlog` 的条目保持陈旧状态。 + +**增加双向或定时对账。** 由 Issue 事件反查 PR,或定期扫描 Project,可以修复更多历史遗留状态;但这会新增一条反向的权威状态更新路径,并增加周期性 API 工作量,超出所需的 PR 驱动生命周期范围。 + +**以完整的 PR 元数据作为投射前提。** 标签、引用和优先级仍须强制落实,但元数据缺陷并不能否定工作实际处于实现或评审阶段。 + +**PR 转为草稿或失去评审人时将状态向后移动。** 这会让临时的 PR 状态覆盖已经观察到的更靠后工作阶段,也会使状态所有权更复杂。因此,投射保持单调。 + +## 后果 + +- PR 事件会自动纠正停留在 `Inbox`、`Backlog` 或 `Ready` 的解决型 Issue。 +- 若 Issue 创建于最后一个相关 PR 事件之后,则必须等待后续 PR 事件或人工更新状态,因为系统不会反向查找或定时扫描。 +- 即使存在历史评审活动,草稿 PR 仍保持 `In progress`;只有非草稿 PR 才会以 `In review` 为目标状态。 +- 终态以及顺序中更靠后的活跃状态不会倒退。 +- 必需的政策检查仍会暴露 PR 元数据错误,而不会因此阻止生命周期投射。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 4c9242bab5..73703bd199 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,6 +12,7 @@ const AUDIT_MARKER = '' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] +const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] /** * Return Markdown outside balanced details elements. @@ -129,6 +130,22 @@ export function requiresPullRequestPolicy({ return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0) } +/** + * Derive a forward-only Issue status from the current PR phase. + * @param {string|null} currentStatus Current Project status. + * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase. + * @returns {string|null} Status to write, or null when no forward transition exists. + */ +export function nextResolvingIssueStatus(currentStatus, pull) { + const target = + !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0) + ? 'In review' + : 'In progress' + const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus) + const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target) + return currentIndex >= 0 && currentIndex < targetIndex ? target : null +} + function stripIgnoredMarkdown(body) { const lines = body.replace(//g, '').split(/\r?\n/) const kept = [] @@ -491,11 +508,13 @@ async function pullRequestSnapshot(number) { } } -async function moveResolvingIssues(pull, from, to) { +async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { const current = await issueSnapshot(number) - if (!current || current.status !== from) continue - await setStatus(number, to) + if (!current) continue + const target = nextResolvingIssueStatus(current.status, pull) + if (!target) continue + await setStatus(number, target) await auditIssue(number) } } @@ -530,12 +549,7 @@ async function runLifecycle(eventName, event) { if (eventName === 'pull_request' || eventName === 'pull_request_review') { const pull = await pullRequestSnapshot(event.pull_request.number) - const errors = validatePullRequest(pull) - if (errors.length > 0) return - await moveResolvingIssues(pull, 'Ready', 'In progress') - if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) { - await moveResolvingIssues(pull, 'In progress', 'In review') - } + await advanceResolvingIssues(pull) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8e0c253796..86750127a7 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test' import { countVisibleUnits, + nextResolvingIssueStatus, parseReferences, retainIssueReferences, requiresPullRequestPolicy, @@ -191,6 +192,49 @@ test('requires policy only after a human PR enters review', () => { ) }) +test('advances resolving Issues to the live PR phase', () => { + const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 } + const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 } + const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + for (const status of ['Inbox', 'Backlog', 'Ready']) { + assert.equal(nextResolvingIssueStatus(status, draft), 'In progress') + assert.equal(nextResolvingIssueStatus(status, open), 'In progress') + assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review') + } + assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review') + assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review') +}) + +test('never regresses or reopens a resolving Issue', () => { + const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } + const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } + + assert.equal(nextResolvingIssueStatus('In progress', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', implementation), null) + assert.equal(nextResolvingIssueStatus('In review', review), null) + assert.equal(nextResolvingIssueStatus('Done', review), null) + assert.equal(nextResolvingIssueStatus('No action', review), null) + assert.equal(nextResolvingIssueStatus(null, review), null) +}) + +test('keeps lifecycle projection independent of PR metadata enforcement', () => { + const pull = { + isDraft: false, + authorType: 'User', + reviewRequestCount: 1, + reviewCount: 0, + labels: [], + references: { all: [2], resolving: [2], related: [] }, + issues: new Map([[2, { priority: null }]]), + } + + assert.ok(validatePullRequest(pull).length > 0) + assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review') +}) + test('exempts Draft, Bot, and App PRs', () => { const invalid = { isDraft: false, diff --git a/package.json b/package.json index fef0a1eb53..fd7f5447ff 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:issue-management": "node --test .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 74d90a547d..956617c024 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -224,6 +224,7 @@ export function gatesForMode(selected: Mode): Gate[] { pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }), pnpmScript('test', 'test'), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('duplication', 'duplication'), snapshotGate(), pnpmScript('build', 'build'), @@ -246,6 +247,7 @@ function ciPrimaryGates(): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -343,6 +345,7 @@ function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), + pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From 7aa0ae34b372bb5b91571830c416c661da1ae33f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 4 Aug 2026 18:12:55 +0800 Subject: [PATCH 2/3] fix: harden issue status projection --- .github/issue-management/policy.mjs | 23 ++++++++++++++++------- scripts/run-gates.ts | 14 ++++++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 73703bd199..608291c4f8 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -12,7 +12,12 @@ const AUDIT_MARKER = '' const OWNER_LINE = /^Owner: @([A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?)$/ const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task']) const PRIORITIES = ['p0', 'p1', 'p2', 'p3'] -const ACTIVE_STATUS_ORDER = ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review'] +const TERMINAL_STATUSES = new Set(['Done', 'No action']) +const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) + +for (const status of ['In progress', 'In review']) { + if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`) +} /** * Return Markdown outside balanced details elements. @@ -418,8 +423,7 @@ async function ensureProjectItem(number) { } } -async function setStatus(number, status) { - const context = await ensureProjectItem(number) +async function updateStatus(context, status) { const option = context.statusField.options.find((candidate) => candidate.name === status) if (!option) throw new Error(`Status 不存在:${status}`) if (context.item.fieldValueByName?.name === status) return @@ -441,6 +445,10 @@ async function setStatus(number, status) { ) } +async function setStatus(number, status) { + await updateStatus(await ensureProjectItem(number), status) +} + async function upsertAudit(number, errors) { const comments = await api( `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, @@ -510,11 +518,12 @@ async function pullRequestSnapshot(number) { async function advanceResolvingIssues(pull) { for (const number of pull.references.resolving) { - const current = await issueSnapshot(number) - if (!current) continue - const target = nextResolvingIssueStatus(current.status, pull) + const context = await projectContext(number) + const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) if (!target) continue - await setStatus(number, target) + // TODO: Replace this latest-state guard with per-Issue serialization or a + // conditional ProjectV2 update; GraphQL currently has no compare-and-swap. + await updateStatus(context, target) await auditIssue(number) } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 956617c024..7503c77072 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -241,13 +241,19 @@ export function gatesForMode(selected: Mode): Gate[] { } } -function ciPrimaryGates(): Gate[] { +function ciSharedStaticGates(): Gate[] { return [ pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ] +} + +function ciPrimaryGates(): Gate[] { + return [ + ...ciSharedStaticGates(), pnpmScript('typecheck', 'typecheck'), lintGate(), pnpmScript('duplication', 'duplication'), @@ -341,11 +347,7 @@ function runningNodeMajor(): number { function ciStaticGates(options: { ownsBuild: boolean }): Gate[] { return [ - pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), - pnpmScript('constraints', 'constraints'), - pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }), - pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }), - pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), + ...ciSharedStaticGates(), ...options.ownsBuild ? [pnpmScript('build', 'build')] : [], ...docSyncLeafGates({ includeDocTypecheck: options.ownsBuild, From 38c373af65923abcd8fff7c200f5a64ca93b617b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Fri, 7 Aug 2026 22:41:09 +0800 Subject: [PATCH 3/3] fix(ci): keep issue policy test discovery focused --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9698d0d2fa..81cb40acad 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", - "test:issue-management": "node --test .github/issue-management/policy.test.mjs", + "test:issue-management": "node .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts",