Browse Source

fix(issue-management): grant policy Project read access

Turtle 1 month ago
parent
commit
9ea2329084

+ 2 - 2
.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.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-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
+2026-09-02-project-local-issue-planning-fields.md: efd0383c9701f9f4caf797d9bf1544b225e5f71c
+2026-09-02-project-local-issue-planning-fields.zh.md: 3c22a506743621ac3204342575fac90d81ee8a37

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

@@ -16,13 +16,15 @@ The `DSH Issue Management` Project owns `Priority`, `Severity`, `Cost`, `Start D
 
 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 repository Issue and pull-request reads, and a GitHub App token restricted to 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, 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.
+[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
 

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

@@ -16,13 +16,15 @@ Priority、影响面、解决代价和日期用于在 `DSH Issue Management` 中
 
 仓库策略从配置的 Project 解析 `Priority` 和 `Start Date`。策略拒绝 Issue 字段投影或错误的数据类型,从 Project item 读取 Priority,并通过 `updateProjectV2ItemFieldValue` 写入 Start Date。组织 Issue 字段仅作为带有 `Legacy ...` 前缀的迁移源保留,仓库工作流不会读取它们。
 
+PR 策略工作流使用仓库 `GITHUB_TOKEN` 读取仓库 Issue 和 PR,并使用仅有组织 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,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。
+[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,证明仓库读取与 Project 读取使用不同凭据,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。工作流测试固定 Project token 的只读权限。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。
 
 ## 考虑过的替代方案
 

+ 8 - 1
.github/issue-management/policy.mjs

@@ -418,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,
@@ -442,7 +446,10 @@ 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

+ 21 - 6
.github/issue-management/policy.test.mjs

@@ -34,7 +34,13 @@ 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
             ? [
                 {
@@ -285,16 +291,25 @@ test('initializes every referenced Issue only for a PR opened event', async () =
 })
 
 test('reads Priority and Status from Project custom fields', async (t) => {
-  const previousToken = process.env.GH_TOKEN
-  process.env.GH_TOKEN = 'test-token'
+  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 (previousToken === undefined) delete process.env.GH_TOKEN
-    else process.env.GH_TOKEN = previousToken
+    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',
@@ -307,7 +322,7 @@ test('reads Priority and Status from Project custom fields', async (t) => {
       })
     }
     assert.equal(url, 'https://api.github.com/graphql')
-    assert.equal(options.headers.Authorization, 'Bearer test-token')
+    assert.equal(options.headers.Authorization, 'Bearer project-token')
     return Response.json({ data: projectGraphqlData({ priority: 'P1' }) })
   })
 

+ 10 - 0
.github/workflows/issue-policy.yml

@@ -21,7 +21,17 @@ jobs:
         with:
           ref: ${{ github.event.repository.default_branch }}
           persist-credentials: false
+      - name: Create Project read token
+        id: app-token
+        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-organization-projects: read
       - name: Validate pull request
         env:
           GITHUB_TOKEN: ${{ github.token }}
+          PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}
         run: node .github/issue-management/policy.mjs pr

+ 27 - 0
scripts/ci-workflow.spec.ts

@@ -683,6 +683,33 @@ describe('Issue lifecycle workflow', () => {
     const policyPullRequest = workflowEvent(policy, 'pull_request')
     expect(policyPullRequest.types).toContain('ready_for_review')
   })
+
+  it('uses a read-only Project token for 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')
+
+    expect(tokenStep).toMatchObject({
+      id: 'app-token',
+      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-organization-projects': 'read',
+      },
+    })
+    expect(validateStep).toMatchObject({
+      env: {
+        GITHUB_TOKEN: '${{ github.token }}',
+        PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}',
+      },
+    })
+  })
 })
 
 describe('npm release workflows', () => {