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:
commit
0e6a397464
14 files changed
+288
-23
No files matched your search
@@ -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
-2
@@ -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
@@ -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
@@ -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
-2
@@ -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 进入评审时,该工作流负责执行必需检查门禁。
|
||||
|
||||
|
||||
+6
@@ -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.
|
||||
+43
@@ -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 可能胜出。
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user