Merge pull request #3444 from deepseek-harness/turtle/migrate-issue-fields-to-project

fix(issue-management): use Project-local Priority
This commit is contained in:
Turtle authored and GitHub committed 2026-09-03 00:58:00 +08:00
commit 0e6a397464
14 files changed
+288 -23

No files matched your search

+3
View File
@@ -391,6 +391,9 @@
"process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml": "sha256:dde0041399b253e3758045f0858488db8178ffc563ce889c8b396c87af6c3730",
"process/2026-08-12-documentation-site-navigation-and-chrome.md": "sha256:56cb836ed862378afd33eb5c1a9dc159958b35a0aed3bf4336fcf26ab0b84b8b",
"process/2026-08-12-documentation-site-navigation-and-chrome.zh.md": "sha256:f2dd4adde38a09fe312866a1e6dad0f465684d809287862f40f1a488acd4fe18",
"process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml": "sha256:c522daca5e126bf64227d6259f447648a589831125113dcf49814817d4b28f17",
"process/2026-08-31-pr-opened-issue-start-dates.md": "sha256:749f343576006b0d4950b9c67f434cea0bb9ae21a5fd8d74fefe77b59289ffbf",
"process/2026-08-31-pr-opened-issue-start-dates.zh.md": "sha256:a227dc6cba6e62d08a15b1c6b69b5941297c2d10035d257d6f3bc95d6ea54591",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd",
"simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04",
@@ -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-31-pr-opened-issue-start-dates.md
2026-08-31-pr-opened-issue-start-dates.md: 49756d9960a7616993e4513c20c990e5cfba167e
2026-08-31-pr-opened-issue-start-dates.zh.md: 752533251be559cb4fe82f619553872bbcd748d1
2026-08-31-pr-opened-issue-start-dates.md: f17e1bf0dcfdc541952312d1b504bdaa2e2817cc
2026-08-31-pr-opened-issue-start-dates.zh.md: 3aab8f5528ad125210fbb3839fd334d99ad49bb0
@@ -1,6 +1,7 @@
# Agent Note: PR-opened Issue start dates
Status: implemented
Archived: 2026-09-02
English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md)
@@ -1,6 +1,7 @@
# Agent Note: 在 PR 创建时设置 Issue 开始日期
Status: implemented
Archived: 2026-09-02
[English](2026-08-31-pr-opened-issue-start-dates.md) | 中文
@@ -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: 47f6f1731b037ae55a994c3373c0f99917da98dd
2026-08-10-event-directed-pr-review-status.zh.md: 8062ab5b1f2efdfcba92f0675af700e59a358d25
2026-08-10-event-directed-pr-review-status.md: de4dc0700f2083772321fdf5f26c05fdf39928be
2026-08-10-event-directed-pr-review-status.zh.md: b8a8fbaa25673a376542965700e864f82ab0d739
@@ -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 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.
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. [Project-local Issue planning fields](2026-09-02-project-local-issue-planning-fields.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.
@@ -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,或运行定时协调器。独立的日期初始化由[在 PR 创建时设置 Issue 开始日期](2026-08-31-pr-opened-issue-start-dates.zh.md)负责,并处理每个同仓库 Issue 引用。
状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[Project 局部 Issue 规划字段](2026-09-02-project-local-issue-planning-fields.zh.md)负责,并处理每个同仓库 Issue 引用。
[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。
@@ -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-09-02-project-local-issue-planning-fields.md
2026-09-02-project-local-issue-planning-fields.md: e44fe0f002c40309beaeb4af3ccdd975714777e6
2026-09-02-project-local-issue-planning-fields.zh.md: 28308d48994dbb144f7e7934a1058cef35942c20
@@ -0,0 +1,43 @@
# Agent Note: Project-local Issue planning fields
Status: implemented
English | [中文](2026-09-02-project-local-issue-planning-fields.zh.md)
## Problem
The Issue lifecycle workflow needs structured planning metadata, but organization Issue fields require a separate GitHub App permission from organization Projects. A workflow token with Project write access can read and update Project custom fields while GitHub rejects Issue-field reads, so using both storage systems makes one policy depend on two independently administered permission sets.
Priority, impact, cost, and dates are used to plan work in `DSH Issue Management`. Keeping those values on the Issue also exposes them outside that Project, but the repository has no workflow that needs cross-Project values.
## Decision
The `DSH Issue Management` Project owns `Priority`, `Severity`, `Cost`, `Start Date`, and `Target Date` as Project custom fields. `Severity` uses the option meanings from the organization `影响面` field, and `Cost` uses the option meanings from `解决代价`.
Repository policy resolves `Priority` and `Start Date` from the configured Project. It rejects an Issue-backed field or the wrong data type, reads Priority from the Project item, and writes Start Date through `updateProjectV2ItemFieldValue`. Organization Issue fields are retained only as `Legacy ...` migration sources and are not read by repository workflows.
The pull-request policy workflow uses the repository `GITHUB_TOKEN` for REST Issue and pull-request reads, and a GitHub App token restricted to repository Issues and organization Projects read access for ProjectV2 queries. Lifecycle mutations continue to use the write-capable App token.
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 date only when the current Project value is empty.
The [organization-field implementation](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md) records the superseded cross-Project ownership decision and its event-timing rationale. Event-directed Status transitions remain owned by [the lifecycle decision](2026-08-10-event-directed-pr-review-status.md).
## Verification
[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, prove repository and Project reads use separate credentials, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Workflow tests pin the Project token's read-only permission. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items.
## Alternatives considered
**Keep organization Issue fields.** They make one value visible across Projects, but the workflow does not need that scope and the GitHub App would require separate organization Issue Fields access.
**Dual-write Issue and Project fields.** Mirrored fields retain cross-Project visibility, but every writer and manual edit can create drift and requires a reconciliation policy.
**Process every subscribed pull-request event or overwrite Start Date.** Later events could repair missing dates, but they would assign dates after work starts or replace a manual plan. The initializer therefore retains opened-only, empty-only behavior.
## Consequences
Planning metadata is scoped to one Project membership. The same Issue can have different values in another Project, and an Issue outside `DSH Issue Management` has no Project-local planning values.
The GitHub App needs Project access rather than organization Issue Fields access for policy metadata. Field renames or type changes fail the workflow instead of falling back to legacy fields.
The empty-value read makes ordinary retries idempotent. Project field updates have no compare-and-set precondition, so simultaneous pull requests can both observe an empty Start Date and the last mutation can win.
@@ -0,0 +1,43 @@
# Agent Note: Project 局部 Issue 规划字段
Status: implemented
[English](2026-09-02-project-local-issue-planning-fields.md) | 中文
## 问题
Issue 生命周期工作流需要结构化规划元数据,但组织 Issue 字段使用的 GitHub App 权限独立于组织 Project 权限。具有 Project 写权限的工作流 token 可以读取和更新 Project custom field,而 GitHub 会拒绝读取 Issue 字段,因此同时使用两套存储会让同一策略依赖两组独立管理的权限。
Priority、影响面、解决代价和日期用于在 `DSH Issue Management` 中规划工作。把这些值保存在 Issue 上还会让它们在该 Project 之外可见,但仓库没有需要跨 Project 值的工作流。
## 决策
`DSH Issue Management` Project 使用 Project custom field 存储 `Priority`、`Severity`、`Cost`、`Start Date` 和 `Target Date`。`Severity` 沿用组织字段 `影响面` 的选项含义,`Cost` 沿用 `解决代价` 的选项含义。
仓库策略从配置的 Project 解析 `Priority` 和 `Start Date`。策略拒绝 Issue 字段投影或错误的数据类型,从 Project item 读取 Priority,并通过 `updateProjectV2ItemFieldValue` 写入 Start Date。组织 Issue 字段仅作为带有 `Legacy ...` 前缀的迁移源保留,仓库工作流不会读取它们。
PR 策略工作流使用仓库 `GITHUB_TOKEN` 执行 REST Issue 和 PR 读取,并使用仅有仓库 Issues 与组织 Projects 读取权限的 GitHub App token 执行 ProjectV2 查询。生命周期 mutation 继续使用有写权限的 App token。
Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start Date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前 Project 值为空时写入日期。
[组织字段实现](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md)记录了已被取代的跨 Project 所有权决策及其事件时机依据。由事件直接指定的 Status 转换仍由[生命周期决策](2026-08-10-event-directed-pr-review-status.zh.md)负责。
## 验证
[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,证明仓库读取与 Project 读取使用不同凭据,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。工作流测试固定 Project token 的只读权限。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。
## 考虑过的替代方案
**保留组织 Issue 字段。** 它们可以让一个值在多个 Project 中可见,但工作流不需要该范围,并且 GitHub App 还需要单独的组织 Issue Fields 权限。
**同时写入 Issue 和 Project 字段。** 镜像字段保留跨 Project 可见性,但每个写入方和人工编辑都可能产生偏差,并且还需要协调策略。
**处理每个已订阅 PR 事件或覆盖 Start Date。** 后续事件可以修复缺失日期,但会在工作开始后才赋值或替换人工计划。因此初始化器保留仅 opened、仅空值的行为。
## 后果
规划元数据限定在一个 Project 归属中。同一个 Issue 可以在另一个 Project 中使用不同的值,`DSH Issue Management` 之外的 Issue 没有 Project 局部规划值。
GitHub App 通过 Project 权限而不是组织 Issue Fields 权限访问策略元数据。字段改名或类型变化会让工作流失败,而不会回退到旧字段。
空值读取使通常的重试保持幂等。Project 字段更新没有比较并设置前提,因此同时引用同一个 Issue 的 PR 可能都会观察到空的 Start Date,最后一次 mutation 可能胜出。
+49 -16
View File
@@ -52,6 +52,9 @@ for (const status of ['In progress', 'In review']) {
if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
throw new Error('config.lifecycleActor 未设置')
}
if (typeof config.priorityField !== 'string' || !config.priorityField) {
throw new Error('config.priorityField 未设置')
}
if (typeof config.startDateField !== 'string' || !config.startDateField) {
throw new Error('config.startDateField 未设置')
}
@@ -415,6 +418,10 @@ function token() {
return value
}
function projectToken() {
return process.env.PROJECT_TOKEN || token()
}
async function api(path, options = {}) {
const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
...options,
@@ -439,19 +446,25 @@ async function graphql(query, variables) {
const result = await api('/graphql', {
method: 'POST',
body: JSON.stringify({ query, variables }),
headers: { 'Content-Type': 'application/json' },
headers: {
Authorization: `Bearer ${projectToken()}`,
'Content-Type': 'application/json',
},
})
if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
return result.data
}
async function issueSnapshot(number, status = undefined) {
/**
* Read one Issue together with its Project planning values.
* @param {number} number Same-repository Issue number.
* @param {string|null|undefined} status Optional known Project status.
* @returns {Promise<object|null>} Issue snapshot, or null when the number identifies a pull request.
*/
export async function issueSnapshot(number, status = undefined) {
const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
if (issue.pull_request) return null
const values = await api(
`/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`,
)
const field = (name) => values.find((value) => value.issue_field_name === name)
const context = await projectContext(number)
return {
number,
nodeId: issue.node_id,
@@ -460,8 +473,8 @@ async function issueSnapshot(number, status = undefined) {
assignees: issue.assignees.map((assignee) => assignee.login),
labels: issue.labels.map((label) => label.name),
type: issue.type?.name ?? null,
priority: field(config.priorityField)?.single_select_option?.name ?? null,
status: status === undefined ? await projectStatus(number) : status,
priority: context.item?.priorityValue?.name ?? null,
status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status,
state: issue.state,
stateReason: issue.state_reason ?? null,
}
@@ -476,6 +489,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
$project: Int!
$includeStatusActor: Boolean!
$includeStartDate: Boolean!
$priorityField: String!
$startDateField: String!
) {
organization(login: $organization) {
@@ -484,8 +498,19 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
title
fields(first: 50) {
nodes {
... on ProjectV2Field { id name dataType isIssueField }
... on ProjectV2SingleSelectField { id name dataType options { id name } }
... on ProjectV2Field {
id
name
dataType
isIssueField
}
... on ProjectV2SingleSelectField {
id
name
dataType
isIssueField
options { id name }
}
}
}
}
@@ -510,6 +535,9 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { name optionId }
}
priorityValue: fieldValueByName(name: $priorityField) {
... on ProjectV2ItemFieldSingleSelectValue { name optionId }
}
startDateValue: fieldValueByName(name: $startDateField)
@include(if: $includeStartDate) {
... on ProjectV2ItemFieldDateValue { date }
@@ -526,6 +554,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
project: config.projectNumber,
includeStatusActor,
includeStartDate,
priorityField: config.priorityField,
startDateField: config.startDateField,
},
)
@@ -535,6 +564,14 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
if (!issue) throw new Error(`#${number} 不存在`)
const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
if (!statusField) throw new Error('Project 缺少 Status 字段')
const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField)
if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`)
if (priorityField.dataType !== 'SINGLE_SELECT') {
throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`)
}
if (priorityField.isIssueField) {
throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`)
}
const startDateField = includeStartDate
? project.fields.nodes.find((field) => field?.name === config.startDateField)
: null
@@ -555,12 +592,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
? (latestStatusEvent.actor?.login ?? null)
: null
return { project, issue, statusField, startDateField, item, statusActor }
}
async function projectStatus(number) {
const context = await projectContext(number)
return context.item?.fieldValueByName?.name ?? null
return { project, issue, statusField, priorityField, startDateField, item, statusActor }
}
async function ensureProjectItem(number, includeStartDate = false) {
@@ -579,6 +611,7 @@ async function ensureProjectItem(number, includeStartDate = false) {
item: {
id: data.addProjectV2ItemById.item.id,
fieldValueByName: null,
priorityValue: null,
startDateValue: null,
},
}
+91 -1
View File
@@ -5,6 +5,7 @@ import {
countVisibleUnits,
initializeIssueStartDate,
initializePullRequestStartDates,
issueSnapshot,
nextResolvingIssueStatus,
parseReferences,
projectDate,
@@ -18,6 +19,10 @@ import {
const projectGraphqlData = ({
projectItem = true,
priority = null,
priorityField = true,
priorityType = 'SINGLE_SELECT',
priorityIsIssueField = false,
startDate = null,
startDateField = true,
startDateType = 'DATE',
@@ -29,7 +34,24 @@ const projectGraphqlData = ({
title: 'DSH Issue Management',
fields: {
nodes: [
{ id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] },
{
id: 'status-field-id',
name: 'Status',
dataType: 'SINGLE_SELECT',
isIssueField: false,
options: [],
},
...(priorityField
? [
{
id: 'priority-project-field-id',
name: 'Priority',
dataType: priorityType,
isIssueField: priorityIsIssueField,
options: [],
},
]
: []),
...(startDateField
? [
{
@@ -54,6 +76,8 @@ const projectGraphqlData = ({
id: 'item-id',
project: { id: 'project-id' },
fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
priorityValue:
priority === null ? null : { name: priority, optionId: `${priority}-option-id` },
startDateValue: startDate === null ? null : { date: startDate },
},
]
@@ -266,6 +290,52 @@ test('initializes every referenced Issue only for a PR opened event', async () =
assert.equal(writes.length, 3)
})
test('reads Priority and Status from Project custom fields', async (t) => {
const previousGhToken = process.env.GH_TOKEN
const previousGithubToken = process.env.GITHUB_TOKEN
const previousProjectToken = process.env.PROJECT_TOKEN
delete process.env.GH_TOKEN
process.env.GITHUB_TOKEN = 'repository-token'
process.env.PROJECT_TOKEN = 'project-token'
t.after(() => {
if (previousGhToken === undefined) delete process.env.GH_TOKEN
else process.env.GH_TOKEN = previousGhToken
if (previousGithubToken === undefined) delete process.env.GITHUB_TOKEN
else process.env.GITHUB_TOKEN = previousGithubToken
if (previousProjectToken === undefined) delete process.env.PROJECT_TOKEN
else process.env.PROJECT_TOKEN = previousProjectToken
})
const urls = []
t.mock.method(globalThis, 'fetch', async (url, options) => {
urls.push(url)
if (url.endsWith('/issues/42')) {
assert.equal(options.headers.Authorization, 'Bearer repository-token')
return Response.json({
node_id: 'issue-id',
title: 'Project metadata',
body: null,
assignees: [],
labels: [],
type: { name: 'Task' },
state: 'open',
state_reason: null,
})
}
assert.equal(url, 'https://api.github.com/graphql')
assert.equal(options.headers.Authorization, 'Bearer project-token')
return Response.json({ data: projectGraphqlData({ priority: 'P1' }) })
})
const issue = await issueSnapshot(42)
assert.equal(issue.priority, 'P1')
assert.equal(issue.status, 'Inbox')
assert.deepEqual(urls, [
'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42',
'https://api.github.com/graphql',
])
})
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()
@@ -277,6 +347,8 @@ test('writes an empty Project Start Date with the configured field', async (t) =
assert.equal(requests.length, 2)
assert.match(requests[0].query, /isIssueField/)
assert.doesNotMatch(requests[0].query, /issueField\s*\{/)
assert.match(requests[0].query, /priorityValue: fieldValueByName/)
assert.equal(requests[0].variables.priorityField, 'Priority')
assert.match(requests[0].query, /ProjectV2ItemFieldDateValue/)
assert.match(requests[1].query, /updateProjectV2ItemFieldValue/)
assert.match(requests[1].query, /value: \{date: \$date\}/)
@@ -332,6 +404,24 @@ test('rejects a missing, non-Date, or Issue-level Start Date field', async (t) =
assert.equal(requests.length, 3)
})
test('rejects a missing, non-select, or Issue-level Priority field', async (t) => {
let response = projectGraphqlData({ priorityField: false })
const requests = mockGraphql(t, () => response)
await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Priority 字段/)
response = projectGraphqlData({ priorityType: 'TEXT' })
await assert.rejects(
initializeIssueStartDate(42, '2026-08-28'),
/Priority 字段必须为 Single Select/,
)
response = projectGraphqlData({ priorityIsIssueField: true })
await assert.rejects(
initializeIssueStartDate(42, '2026-08-28'),
/Priority 字段必须为 Project custom field/,
)
assert.equal(requests.length, 3)
})
test('does not treat pull request references as Issue associations', () => {
const references = {
all: [123, 1180, 1181],
+13
View File
@@ -21,7 +21,20 @@ jobs:
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Create Project read token
id: app-token
if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }}
private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}
owner: deepseek-harness
repositories: deepseek-harness
permission-issues: read
permission-organization-projects: read
- name: Validate pull request
if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}
env:
GITHUB_TOKEN: ${{ github.token }}
PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}
run: node .github/issue-management/policy.mjs pr
+32
View File
@@ -683,6 +683,38 @@ describe('Issue lifecycle workflow', () => {
const policyPullRequest = workflowEvent(policy, 'pull_request')
expect(policyPullRequest.types).toContain('ready_for_review')
})
it('uses a read-only Project token only for human pull request policy metadata', () => {
const policy = loadWorkflow('.github/workflows/issue-policy.yml')
const policyJob = workflowJob(policy, 'policy')
if (!Array.isArray(policyJob.steps)) throw new TypeError('Issue policy job must define steps')
const steps = policyJob.steps.filter(isRecord)
const tokenStep = steps.find(step => step.name === 'Create Project read token')
const validateStep = steps.find(step => step.name === 'Validate pull request')
const humanPullRequest =
"${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}"
expect(tokenStep).toMatchObject({
id: 'app-token',
if: humanPullRequest,
uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1',
with: {
'client-id': '${{ vars.DSH_ISSUE_APP_CLIENT_ID }}',
'private-key': '${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}',
owner: 'deepseek-harness',
repositories: 'deepseek-harness',
'permission-issues': 'read',
'permission-organization-projects': 'read',
},
})
expect(validateStep).toMatchObject({
if: humanPullRequest,
env: {
GITHUB_TOKEN: '${{ github.token }}',
PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}',
},
})
})
})
describe('npm release workflows', () => {