feat(issue-management): initialize Issue start dates on PR open

This commit is contained in:
Turtle 2026-08-31 14:20:02 +08:00
parent cf2d0986cf
commit 4df85c85ff
10 changed files with 340 additions and 12 deletions

View file

@ -2,5 +2,5 @@
# 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-10-event-directed-pr-review-status.md
2026-08-10-event-directed-pr-review-status.md: 3ed6038929d3c2c1e9cd82182978262ee363f5ab
2026-08-10-event-directed-pr-review-status.zh.md: 1fa8650057e53ab894c597b712720a3ee7a5c46a
2026-08-10-event-directed-pr-review-status.md: 47f6f1731b037ae55a994c3373c0f99917da98dd
2026-08-10-event-directed-pr-review-status.zh.md: 8062ab5b1f2efdfcba92f0675af700e59a358d25

View file

@ -16,7 +16,7 @@ The Issue lifecycle workflow treats review webhooks as commands. `pull_request.r
Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status.
The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler.
The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [PR-opened Issue start dates](2026-08-31-pr-opened-issue-start-dates.md) own the separate date initialization for every same-repository Issue reference.
[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review.

View file

@ -16,7 +16,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review
工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。
处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。
状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[在 PR 创建时设置 Issue 开始日期](2026-08-31-pr-opened-issue-start-dates.zh.md)负责,并处理每个同仓库 Issue 引用。
[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。

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-31-pr-opened-issue-start-dates.md
2026-08-31-pr-opened-issue-start-dates.md: f8976b8b0499aa5c68c9637e8571805b78ce6d48
2026-08-31-pr-opened-issue-start-dates.zh.md: 3142bad5007cbbdd27e1f564bc3ccec9101d3d79

View file

@ -0,0 +1,39 @@
# Agent Note: PR-opened Issue start dates
Status: implemented
English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md)
## Problem
The Issue Project records planned work in a `Start date` field, but adding or linking an Issue does not provide a date value. A pull request can identify both Issues it resolves and Issues that supply related implementation context, and either relationship marks the start of repository work.
Updating the field on every pull-request event would assign dates to existing work after edits, pushes, or reopenings. Replacing an existing date would also discard a manually planned date or a date recorded by an earlier pull request.
## Decision
The Issue lifecycle workflow initializes `Start date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the configured Date field only when the current value is empty.
The configuration names the Project field and time zone. Missing configuration fails when the policy module loads; a missing field, a non-Date field, an invalid timestamp, or a failed API request fails the workflow at the first relevant pull request.
[Event-directed PR review status commands](2026-08-10-event-directed-pr-review-status.md) continue to own Status transitions. Date initialization includes resolving and informational Issue references, runs for Draft and automated pull requests, and does not depend on PR policy enforcement.
## Verification
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) cover the Shanghai date boundary, opened-only dispatch, all retained Issue references, empty-value writes, existing-value preservation, missing Project items, invalid field configuration, and the GraphQL mutation variables. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) require the `pull_request.opened` subscription.
## Alternatives considered
**Use a built-in Project workflow.** The built-in workflows own fixed Project item and Status transitions; the repository workflow already owns authenticated GraphQL mutations and can supply the PR creation date.
**Process every subscribed PR event or run a reconciler.** Later events would fill dates for existing pull requests and references added after creation, but they would make the field a repair projection instead of a record created with the pull request and would add repeated Project reads.
**Update only resolving Issue references.** Informational references also identify Issues whose implementation work begins with the pull request, so the date initializer uses the existing all-reference set while Status transitions retain resolving-only semantics.
**Overwrite an existing date.** A later pull request must not replace a manual plan or the date written for earlier work, so the mutation follows an empty-value read.
## Consequences
Only pull requests opened after the workflow ships initialize dates. References added after creation and existing open pull requests remain unchanged, and the workflow does not scan existing Project items or pull requests.
The empty-value read makes retries idempotent in ordinary operation. ProjectV2 has no conditional field update, so simultaneous pull requests that reference the same empty Issue can both write; per-PR concurrency does not serialize that Issue, and the last mutation can win.

View file

@ -0,0 +1,39 @@
# Agent Note: 在 PR 创建时设置 Issue 开始日期
Status: implemented
[English](2026-08-31-pr-opened-issue-start-dates.md) | 中文
## 问题
Issue Project 使用 `Start date` 字段记录已规划工作的开始日期,但加入或关联 Issue 不会提供日期值。PR 可以同时标识它所解决的 Issue 和提供相关实现上下文的 Issue;两种关系都表示仓库工作已经开始。
如果每个 PR 事件都更新该字段,编辑、推送或重新打开 PR 会为已有工作补上日期。覆盖已有日期还会丢弃人工规划的日期或较早 PR 记录的日期。
## 决策
Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前值为空时写入配置的 Date 字段。
配置指定 Project 字段和时区。配置缺失会在策略模块加载时失败;字段缺失、字段不是 Date 类型、时间戳无效或 API 请求失败会让首个相关 PR 的工作流失败。
[由事件直接指定的 PR 评审状态命令](2026-08-10-event-directed-pr-review-status.zh.md)继续负责 Status 转换。日期初始化同时包含解决型和信息型 Issue 引用,对 Draft PR 和自动化 PR 同样运行,也不依赖 PR 策略检查是否生效。
## 验证
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)覆盖上海时区日期边界、仅 opened 分派、全部保留的 Issue 引用、空值写入、已有值保留、Project item 缺失、字段配置无效和 GraphQL mutation 变量。[工作流测试](../../../../scripts/ci-workflow.spec.ts)要求保留 `pull_request.opened` 订阅。
## 考虑过的替代方案
**使用 Project 内置工作流。** 内置工作流负责固定的 Project item 和 Status 转换;仓库工作流已经负责经过身份验证的 GraphQL mutation,并且能够提供 PR 创建日期。
**处理每个已订阅 PR 事件或运行协调器。** 后续事件可以为已有 PR 和创建后新增的引用补上日期,但这会让该字段成为修复型投影,而不是随 PR 创建的记录,并且会增加重复 Project 读取。
**仅更新解决型 Issue 引用。** 信息型引用同样标识随该 PR 开始实现工作的 Issue,因此日期初始化使用现有的全部引用集合,Status 转换仍只处理解决型引用。
**覆盖已有日期。** 后续 PR 不得替换人工计划或为较早工作写入的日期,因此 mutation 先读取并只处理空值。
## 后果
只有工作流发布后新建的 PR 会初始化日期。创建后新增的引用和现有开放 PR 保持不变,工作流不会扫描已有 Project item 或 PR。
空值读取使重试在通常情况下保持幂等。ProjectV2 没有条件字段更新,因此同时引用同一个空日期 Issue 的 PR 可能都会写入;按 PR 设置的并发控制不会串行化该 Issue,最后一次 mutation 可能胜出。

View file

@ -5,6 +5,8 @@
"projectTitle": "DSH Issue Management",
"lifecycleActor": "dsh-issue-management",
"priorityField": "Priority",
"startDateField": "Start date",
"projectTimeZone": "Asia/Shanghai",
"allowUnassignedOwner": true,
"statuses": [
"Inbox",

View file

@ -52,6 +52,13 @@ for (const status of ['In progress', 'In review']) {
if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
throw new Error('config.lifecycleActor 未设置')
}
if (typeof config.startDateField !== 'string' || !config.startDateField) {
throw new Error('config.startDateField 未设置')
}
if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) {
throw new Error('config.projectTimeZone 未设置')
}
Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone })
/**
* Return Markdown outside balanced details elements.
@ -215,6 +222,29 @@ export function nextResolvingIssueStatus(currentStatus, command, currentStatusAc
return currentIndex >= 0 && currentIndex < targetIndex ? target : null
}
/**
* Convert a GitHub timestamp to a Project date in one configured time zone.
* @param {string} timestamp ISO timestamp.
* @param {string} timeZone IANA time-zone name.
* @returns {string} Calendar date in YYYY-MM-DD form.
*/
export function projectDate(timestamp, timeZone = config.projectTimeZone) {
const instant = new Date(timestamp)
if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`)
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.formatToParts(instant)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value]),
)
return `${parts.year}-${parts.month}-${parts.day}`
}
function stripIgnoredMarkdown(body) {
const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
const kept = []
@ -437,7 +467,7 @@ async function issueSnapshot(number, status = undefined) {
}
}
async function projectContext(number, includeStatusActor = false) {
async function projectContext(number, includeStatusActor = false, includeStartDate = false) {
const data = await graphql(
`query(
$organization: String!
@ -445,6 +475,8 @@ async function projectContext(number, includeStatusActor = false) {
$number: Int!
$project: Int!
$includeStatusActor: Boolean!
$includeStartDate: Boolean!
$startDateField: String!
) {
organization(login: $organization) {
projectV2(number: $project) {
@ -452,7 +484,8 @@ async function projectContext(number, includeStatusActor = false) {
title
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField { id name options { id name } }
... on ProjectV2Field { id name dataType }
... on ProjectV2SingleSelectField { id name dataType options { id name } }
}
}
}
@ -477,6 +510,10 @@ async function projectContext(number, includeStatusActor = false) {
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { name optionId }
}
startDateValue: fieldValueByName(name: $startDateField)
@include(if: $includeStartDate) {
... on ProjectV2ItemFieldDateValue { date }
}
}
}
}
@ -488,6 +525,8 @@ async function projectContext(number, includeStatusActor = false) {
number,
project: config.projectNumber,
includeStatusActor,
includeStartDate,
startDateField: config.startDateField,
},
)
const project = data.organization?.projectV2
@ -496,15 +535,24 @@ async function projectContext(number, includeStatusActor = false) {
if (!issue) throw new Error(`#${number} 不存在`)
const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
if (!statusField) throw new Error('Project 缺少 Status 字段')
const startDateField = includeStartDate
? project.fields.nodes.find((field) => field?.name === config.startDateField)
: null
if (includeStartDate && !startDateField) {
throw new Error(`Project 缺少 ${config.startDateField} 字段`)
}
if (startDateField && startDateField.dataType !== 'DATE') {
throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
}
const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
const latestStatusEvent = issue.timelineItems?.nodes
?.filter((event) => event?.project?.id === project.id)
.at(-1)
const statusActor =
latestStatusEvent?.status === item?.fieldValueByName?.name
latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
? (latestStatusEvent.actor?.login ?? null)
: null
return { project, issue, statusField, item, statusActor }
return { project, issue, statusField, startDateField, item, statusActor }
}
async function projectStatus(number) {
@ -512,8 +560,8 @@ async function projectStatus(number) {
return context.item?.fieldValueByName?.name ?? null
}
async function ensureProjectItem(number) {
const context = await projectContext(number)
async function ensureProjectItem(number, includeStartDate = false) {
const context = await projectContext(number, false, includeStartDate)
if (context.item) return context
const data = await graphql(
`mutation($projectId: ID!, $contentId: ID!) {
@ -525,10 +573,58 @@ async function ensureProjectItem(number) {
)
return {
...context,
item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null },
item: {
id: data.addProjectV2ItemById.item.id,
fieldValueByName: null,
startDateValue: null,
},
}
}
/**
* Initialize one Issue's Project Start date when it is empty.
* @param {number} number Same-repository Issue number.
* @param {string} date Date in YYYY-MM-DD form.
* @returns {Promise<void>} Resolves after the conditional Project update.
*/
export async function initializeIssueStartDate(number, date) {
const context = await ensureProjectItem(number, true)
if (context.item.startDateValue?.date) return
await graphql(
`mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId,
itemId: $itemId,
fieldId: $fieldId,
value: {date: $date}
}) { projectV2Item { id } }
}`,
{
projectId: context.project.id,
itemId: context.item.id,
fieldId: context.startDateField.id,
date,
},
)
}
/**
* Initialize every referenced Issue from a newly opened PR.
* @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot.
* @param {string} action Pull-request event action.
* @param {(number: number, date: string) => Promise<void>} initialize Date writer.
* @returns {Promise<void>} Resolves after all eligible Issues are processed.
*/
export async function initializePullRequestStartDates(
pull,
action,
initialize = initializeIssueStartDate,
) {
if (action !== 'opened') return
const date = projectDate(pull.createdAt)
for (const number of pull.references.all) await initialize(number, date)
}
async function updateStatus(context, status) {
const option = context.statusField.options.find((candidate) => candidate.name === status)
if (!option) throw new Error(`Status 不存在:${status}`)
@ -631,7 +727,10 @@ async function pullRequestSnapshot(number) {
async function lifecyclePullRequestSnapshot(number) {
const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
return resolvingReferencesSnapshot(number, pull)
return {
...(await resolvingReferencesSnapshot(number, pull)),
createdAt: pull.created_at,
}
}
async function transitionResolvingIssues(pull, command) {
@ -683,6 +782,9 @@ async function runLifecycle(eventName, event) {
if (!command) return
const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
await transitionResolvingIssues(pull, command)
if (eventName === 'pull_request') {
await initializePullRequestStartDates(pull, event.action)
}
}
}

View file

@ -3,8 +3,11 @@ import test from 'node:test'
import {
countVisibleUnits,
initializeIssueStartDate,
initializePullRequestStartDates,
nextResolvingIssueStatus,
parseReferences,
projectDate,
retainIssueReferences,
resolvingIssueStatusCommand,
requiresPullRequestPolicy,
@ -13,6 +16,63 @@ import {
validatePullRequest,
} from './policy.mjs'
const projectGraphqlData = ({
projectItem = true,
startDate = null,
startDateField = true,
startDateType = 'DATE',
} = {}) => ({
organization: {
projectV2: {
id: 'project-id',
title: 'DSH Issue Management',
fields: {
nodes: [
{ id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] },
...(startDateField
? [{ id: 'start-date-field-id', name: 'Start date', dataType: startDateType }]
: []),
],
},
},
},
repository: {
issue: {
id: 'issue-id',
projectItems: {
nodes: projectItem
? [
{
id: 'item-id',
project: { id: 'project-id' },
fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
startDateValue: startDate === null ? null : { date: startDate },
},
]
: [],
},
},
},
})
const mockGraphql = (t, resolve) => {
const requests = []
const previousToken = process.env.GH_TOKEN
process.env.GH_TOKEN = 'test-token'
t.after(() => {
if (previousToken === undefined) delete process.env.GH_TOKEN
else process.env.GH_TOKEN = previousToken
})
t.mock.method(globalThis, 'fetch', async (url, options) => {
assert.equal(url, 'https://api.github.com/graphql')
assert.equal(options.headers.Authorization, 'Bearer test-token')
const request = JSON.parse(options.body)
requests.push(request)
return Response.json({ data: resolve(request, requests.length - 1) })
})
return requests
}
const withDetails = (summary) =>
`${summary}\n\n<details><summary>验收与细节</summary>待补充。</details>`
@ -171,6 +231,85 @@ test('separates resolving and informational references', () => {
)
})
test('converts PR creation timestamps to Shanghai Project dates', () => {
assert.equal(projectDate('2026-08-27T15:59:59Z', 'Asia/Shanghai'), '2026-08-27')
assert.equal(projectDate('2026-08-27T16:00:00Z', 'Asia/Shanghai'), '2026-08-28')
assert.throws(() => projectDate('invalid', 'Asia/Shanghai'), /无效的 PR 创建时间/)
})
test('initializes every referenced Issue only for a PR opened event', async () => {
const writes = []
const pull = {
createdAt: '2026-08-27T16:00:00Z',
references: { all: [4, 7, 12] },
}
const initialize = async (number, date) => writes.push({ number, date })
await initializePullRequestStartDates(pull, 'opened', initialize)
assert.deepEqual(writes, [
{ number: 4, date: '2026-08-28' },
{ number: 7, date: '2026-08-28' },
{ number: 12, date: '2026-08-28' },
])
for (const action of ['edited', 'synchronize', 'reopened']) {
await initializePullRequestStartDates(pull, action, initialize)
}
assert.equal(writes.length, 3)
})
test('writes an empty Project Start date with the configured field', async (t) => {
const requests = mockGraphql(t, (request) => {
if (request.query.includes('query(')) return projectGraphqlData()
return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'item-id' } } }
})
await initializeIssueStartDate(42, '2026-08-28')
assert.equal(requests.length, 2)
assert.match(requests[1].query, /value: \{date: \$date\}/)
assert.deepEqual(requests[1].variables, {
projectId: 'project-id',
itemId: 'item-id',
fieldId: 'start-date-field-id',
date: '2026-08-28',
})
})
test('preserves an existing Project Start date', async (t) => {
const requests = mockGraphql(t, () => projectGraphqlData({ startDate: '2026-08-01' }))
await initializeIssueStartDate(42, '2026-08-28')
assert.equal(requests.length, 1)
})
test('adds a referenced Issue to the Project before setting Start date', async (t) => {
const requests = mockGraphql(t, (request) => {
if (request.query.includes('query(')) return projectGraphqlData({ projectItem: false })
if (request.query.includes('addProjectV2ItemById')) {
return { addProjectV2ItemById: { item: { id: 'new-item-id' } } }
}
return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'new-item-id' } } }
})
await initializeIssueStartDate(42, '2026-08-28')
assert.equal(requests.length, 3)
assert.deepEqual(requests[1].variables, { projectId: 'project-id', contentId: 'issue-id' })
assert.equal(requests[2].variables.itemId, 'new-item-id')
})
test('rejects a missing or non-Date Start date field', async (t) => {
let response = projectGraphqlData({ startDateField: false })
const requests = mockGraphql(t, () => response)
await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start date 字段/)
response = projectGraphqlData({ startDateType: 'TEXT' })
await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start date 字段必须为 Date/)
assert.equal(requests.length, 2)
})
test('does not treat pull request references as Issue associations', () => {
const references = {
all: [123, 1180, 1181],

View file

@ -652,6 +652,7 @@ describe('Issue lifecycle workflow', () => {
// review events.
const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request')
const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review')
expect(lifecyclePullRequest.types).toContain('opened')
expect(lifecyclePullRequest.types).not.toContain('ready_for_review')
expect(lifecyclePullRequest.types).toContain('review_requested')
expect(lifecycleReview.types).toEqual(['submitted'])