Merge branch 'master' into worktree/archive-agent-notes-aggressive-20260807

This commit is contained in:
Tianyi Cui
2026-08-07 22:57:51 +08:00
committed by GitHub
7 changed files with 174 additions and 17 deletions

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
# Agent Note: PR 到 Issue 的状态仅向前投射
Status: implemented
[English](2026-08-04-forward-only-pr-issue-status.md) | 中文
## 问题
Issue Project 状态表示工作所处阶段,同仓库内精确匹配的解决型关键字引用则建立权威的 PRPull 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 元数据错误,而不会因此阻止生命周期投射。

View File

@@ -12,6 +12,12 @@ const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
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 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.
@@ -129,6 +135,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(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
const kept = []
@@ -401,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
@@ -424,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`,
@@ -491,11 +516,14 @@ 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)
const context = await projectContext(number)
const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull)
if (!target) continue
// 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)
}
}
@@ -530,12 +558,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)
}
}

View File

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

View File

@@ -29,6 +29,7 @@
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:e2e": "vitest run --config vitest.e2e.config.ts",
"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",

View File

@@ -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'),
@@ -240,12 +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'),
@@ -339,10 +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' }),
...ciSharedStaticGates(),
...options.ownsBuild ? [pnpmScript('build', 'build')] : [],
...docSyncLeafGates({
includeDocTypecheck: options.ownsBuild,