Pārlūkot izejas kodu

fix(issue-management): use Project-local Priority

Turtle 1 mēnesi atpakaļ
vecāks
revīzija
39b151bda3

+ 3 - 0
.agents/notes/archived/manifest.json

@@ -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
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml → .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml

@@ -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 - 0
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md → .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.md

@@ -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 - 0
.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md → .agents/notes/archived/process/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 - 2
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml

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

+ 1 - 1
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md

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

+ 1 - 1
.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md

@@ -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
.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.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-09-02-project-local-issue-planning-fields.md
+2026-09-02-project-local-issue-planning-fields.md: 97689de4e64d60e40ba479b89f20dff1f557ec21
+2026-09-02-project-local-issue-planning-fields.zh.md: 4d815af1789324f82394d4d26b638c50f2031ec4

+ 41 - 0
.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md

@@ -0,0 +1,41 @@
+# 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 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, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. 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.

+ 41 - 0
.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md

@@ -0,0 +1,41 @@
+# 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 ...` 前缀的迁移源保留,仓库工作流不会读取它们。
+
+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,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。删除组织字段前必须逐项比较所有旧字段值与 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 可能胜出。

+ 41 - 15
.github/issue-management/policy.mjs

@@ -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 未设置')
 }
@@ -445,13 +448,16 @@ async function graphql(query, variables) {
   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 +466,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 +482,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
       $project: Int!
       $includeStatusActor: Boolean!
       $includeStartDate: Boolean!
+      $priorityField: String!
       $startDateField: String!
     ) {
       organization(login: $organization) {
@@ -484,8 +491,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 +528,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 +547,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa
       project: config.projectNumber,
       includeStatusActor,
       includeStartDate,
+      priorityField: config.priorityField,
       startDateField: config.startDateField,
     },
   )
@@ -535,6 +557,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 +585,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 +604,7 @@ async function ensureProjectItem(number, includeStartDate = false) {
     item: {
       id: data.addProjectV2ItemById.item.id,
       fieldValueByName: null,
+      priorityValue: null,
       startDateValue: null,
     },
   }

+ 75 - 0
.github/issue-management/policy.test.mjs

@@ -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',
@@ -30,6 +35,17 @@ const projectGraphqlData = ({
       fields: {
         nodes: [
           { id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] },
+          ...(priorityField
+            ? [
+                {
+                  id: 'priority-project-field-id',
+                  name: 'Priority',
+                  dataType: priorityType,
+                  isIssueField: priorityIsIssueField,
+                  options: [],
+                },
+              ]
+            : []),
           ...(startDateField
             ? [
                 {
@@ -54,6 +70,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 +284,43 @@ 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 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
+  })
+  const urls = []
+  t.mock.method(globalThis, 'fetch', async (url, options) => {
+    urls.push(url)
+    if (url.endsWith('/issues/42')) {
+      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 test-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 +332,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 +389,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],