policy.test.mjs 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077
  1. import assert from 'node:assert/strict'
  2. import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
  3. import { spawnSync } from 'node:child_process'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import test from 'node:test'
  7. import { api, graphql, initializeIssueStartDate, issueSnapshot } from './github.mjs'
  8. import { auditIssue, initializePullRequestStartDates, repairIssueLabels, runLifecycle } from './lifecycle.mjs'
  9. import {
  10. lifecyclePullRequestSnapshot,
  11. pullRequestSnapshot,
  12. runPullRequestCheck,
  13. runPullRequestPreflight,
  14. } from './pull-request.mjs'
  15. import {
  16. nextResolvingIssueStatus,
  17. parseReferences,
  18. projectDate,
  19. retainIssueReferences,
  20. resolvingIssueStatusCommand,
  21. requiresPullRequestPolicy,
  22. validateIssue,
  23. validatePullRequest,
  24. } from './rules.mjs'
  25. const projectGraphqlData = ({
  26. projectItem = true,
  27. priority = null,
  28. priorityField = true,
  29. priorityType = 'SINGLE_SELECT',
  30. priorityIsIssueField = false,
  31. startDate = null,
  32. startDateField = true,
  33. startDateType = 'DATE',
  34. startDateIsIssueField = false,
  35. } = {}) => ({
  36. organization: {
  37. projectV2: {
  38. id: 'project-id',
  39. title: 'DSH Issue Management',
  40. fields: {
  41. nodes: [
  42. {
  43. id: 'status-field-id',
  44. name: 'Status',
  45. dataType: 'SINGLE_SELECT',
  46. isIssueField: false,
  47. options: [],
  48. },
  49. ...(priorityField
  50. ? [
  51. {
  52. id: 'priority-project-field-id',
  53. name: 'Priority',
  54. dataType: priorityType,
  55. isIssueField: priorityIsIssueField,
  56. options: [],
  57. },
  58. ]
  59. : []),
  60. ...(startDateField
  61. ? [
  62. {
  63. id: 'start-date-field-id',
  64. name: 'Start Date',
  65. dataType: startDateType,
  66. isIssueField: startDateIsIssueField,
  67. },
  68. ]
  69. : []),
  70. ],
  71. },
  72. },
  73. },
  74. repository: {
  75. issue: {
  76. id: 'issue-id',
  77. projectItems: {
  78. nodes: projectItem
  79. ? [
  80. {
  81. id: 'item-id',
  82. project: { id: 'project-id' },
  83. fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
  84. priorityValue:
  85. priority === null ? null : { name: priority, optionId: `${priority}-option-id` },
  86. startDateValue: startDate === null ? null : { date: startDate },
  87. },
  88. ]
  89. : [],
  90. },
  91. },
  92. },
  93. })
  94. const mockGraphql = (t, resolve) => {
  95. const requests = []
  96. const previousToken = process.env.GH_TOKEN
  97. process.env.GH_TOKEN = 'test-token'
  98. t.after(() => {
  99. if (previousToken === undefined) delete process.env.GH_TOKEN
  100. else process.env.GH_TOKEN = previousToken
  101. })
  102. t.mock.method(globalThis, 'fetch', async (url, options) => {
  103. assert.equal(url, 'https://api.github.com/graphql')
  104. assert.equal(options.headers.Authorization, 'Bearer test-token')
  105. const request = JSON.parse(options.body)
  106. requests.push(request)
  107. return Response.json({ data: resolve(request, requests.length - 1) })
  108. })
  109. return requests
  110. }
  111. const legalIssue = {
  112. labels: [],
  113. type: 'Idea',
  114. priority: null,
  115. status: 'In review',
  116. state: 'open',
  117. stateReason: null,
  118. }
  119. const canonicalKinds = [
  120. 'kind/feature',
  121. 'kind/bug-fix',
  122. 'kind/doc',
  123. 'kind/testing',
  124. 'kind/cleanup',
  125. 'kind/dependency',
  126. ]
  127. // Keep an independent oracle rather than importing the implementation's reserved set.
  128. const legacyLabels = [
  129. 'kind/bug',
  130. 'kind/documentation',
  131. 'feature',
  132. 'bug-fix',
  133. 'doc',
  134. 'cleanup',
  135. 'testing',
  136. 'dependencies',
  137. 'ci',
  138. 'cli',
  139. 'llm',
  140. 'web-search',
  141. ]
  142. const reviewedPull = (labels) => ({
  143. isDraft: false,
  144. authorType: 'User',
  145. reviewRequestCount: 1,
  146. reviewCount: 0,
  147. labels,
  148. references: { all: [2], resolving: [], related: [2] },
  149. issues: new Map([[2, { priority: null }]]),
  150. })
  151. test('keeps only Bug, Feature, and Task Issue templates with used frontmatter', () => {
  152. const directory = new URL('../ISSUE_TEMPLATE/', import.meta.url)
  153. assert.deepEqual(readdirSync(directory).sort(), ['bug.md', 'config.yml', 'feature.md', 'task.md'])
  154. for (const file of ['bug.md', 'feature.md', 'task.md']) {
  155. const source = readFileSync(new URL(file, directory), 'utf8')
  156. const frontmatter = source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
  157. const keys = frontmatter
  158. .split('\n')
  159. .filter(Boolean)
  160. .map((line) => line.slice(0, line.indexOf(':')))
  161. .sort()
  162. assert.deepEqual(keys, ['about', 'name', 'type'], file)
  163. assert.match(source, /^## /m, file)
  164. assert.match(source, /<!-- [^\n]+ -->/, file)
  165. assert.doesNotMatch(source, /<details\b/i, file)
  166. }
  167. assert.equal(
  168. readFileSync(new URL('config.yml', directory), 'utf8'),
  169. 'blank_issues_enabled: false\n',
  170. )
  171. })
  172. test('keeps Feature Issues limited to motivation and behavior', () => {
  173. const source = readFileSync(
  174. new URL('../ISSUE_TEMPLATE/feature.md', import.meta.url),
  175. 'utf8',
  176. )
  177. assert.deepEqual(
  178. [...source.matchAll(/^## .+$/gm)].map(([heading]) => heading),
  179. ['## Motivation', '## Behavior'],
  180. )
  181. })
  182. test('keeps Bug Issues limited to the problem report', () => {
  183. const source = readFileSync(new URL('../ISSUE_TEMPLATE/bug.md', import.meta.url), 'utf8')
  184. assert.deepEqual(
  185. [...source.matchAll(/^## .+$/gm)].map(([heading]) => heading),
  186. ['## Summary', '## Reproduction', '## Current behavior', '## Expected behavior', '## Environment'],
  187. )
  188. })
  189. test('keeps Task Issues limited to summary and deliverables', () => {
  190. const source = readFileSync(new URL('../ISSUE_TEMPLATE/task.md', import.meta.url), 'utf8')
  191. assert.deepEqual(
  192. [...source.matchAll(/^## .+$/gm)].map(([heading]) => heading),
  193. ['## Summary', '## Deliverables'],
  194. )
  195. })
  196. test('structures the pull request template around motivation, changes, and testing', () => {
  197. const source = readFileSync(new URL('../pull_request_template.md', import.meta.url), 'utf8')
  198. for (const heading of ['## Motivation', '## Changes', '## Testing']) {
  199. assert.match(source, new RegExp(`^${heading.replaceAll('#', '\\#')}$`, 'm'))
  200. }
  201. assert.doesNotMatch(source, /^### /m)
  202. assert.match(
  203. source,
  204. /<!-- 高层次说明命令[^\n]+ -->\n<!-- 高层次说明用户[^\n]+ -->/,
  205. )
  206. assert.match(source, /- <!-- [^\n]+ -->\n\n <details>\n <summary>Proof<\/summary>/)
  207. assert.equal(source.match(/<details>/g)?.length, 1)
  208. assert.equal(source.match(/<\/details>/g)?.length, 1)
  209. })
  210. test('ignores Issue title, body presentation, and assignee ownership', () => {
  211. assert.deepEqual(
  212. validateIssue({
  213. ...legalIssue,
  214. title: '[Bug] English title',
  215. body: `Owner: @octocat\n\n${'visible '.repeat(60)}<details open>unclosed`,
  216. assignees: ['octocat', 'hubot'],
  217. }),
  218. [],
  219. )
  220. })
  221. test('allows optional metadata in every open Status', () => {
  222. assert.deepEqual(validateIssue(legalIssue), [])
  223. for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) {
  224. assert.deepEqual(validateIssue({ ...legalIssue, status }), [])
  225. }
  226. })
  227. test('reserves PR kind and legacy labels for pull requests', () => {
  228. for (const label of [
  229. ...canonicalKinds,
  230. 'kind/experimental',
  231. ...legacyLabels,
  232. ]) {
  233. assert.ok(
  234. validateIssue({ ...legalIssue, labels: [label] }).some((error) =>
  235. error.startsWith('Issue 不得使用 PR kind 或旧版标签:'),
  236. ),
  237. label,
  238. )
  239. }
  240. assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), [])
  241. })
  242. test('removes reserved labels from Issues before validation', async (t) => {
  243. const previousToken = process.env.GH_TOKEN
  244. process.env.GH_TOKEN = 'test-token'
  245. t.after(() => {
  246. if (previousToken === undefined) delete process.env.GH_TOKEN
  247. else process.env.GH_TOKEN = previousToken
  248. })
  249. const requests = []
  250. t.mock.method(globalThis, 'fetch', async (url, options) => {
  251. requests.push({ url, method: options.method })
  252. assert.equal(options.headers.Authorization, 'Bearer test-token')
  253. if (url.endsWith('/labels/bug-fix')) {
  254. return Response.json({ message: 'Label does not exist' }, { status: 404 })
  255. }
  256. return Response.json([])
  257. })
  258. const issue = {
  259. ...legalIssue,
  260. number: 42,
  261. labels: ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'],
  262. }
  263. const repaired = await repairIssueLabels(issue)
  264. assert.deepEqual(repaired.labels, ['area/web', 'source/member'])
  265. assert.deepEqual(issue.labels, ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'])
  266. assert.deepEqual(validateIssue(repaired), [])
  267. assert.deepEqual(requests, [
  268. {
  269. url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix',
  270. method: 'DELETE',
  271. },
  272. {
  273. url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/bug-fix',
  274. method: 'DELETE',
  275. },
  276. ])
  277. })
  278. test('deletes a stale audit comment after repairing its only violation', async (t) => {
  279. const previousToken = process.env.GH_TOKEN
  280. process.env.GH_TOKEN = 'test-token'
  281. t.after(() => {
  282. if (previousToken === undefined) delete process.env.GH_TOKEN
  283. else process.env.GH_TOKEN = previousToken
  284. })
  285. const requests = []
  286. t.mock.method(globalThis, 'fetch', async (url, options) => {
  287. requests.push({ url, method: options.method ?? 'GET' })
  288. if (url.endsWith('/issues/42')) {
  289. return Response.json({
  290. node_id: 'issue-id',
  291. labels: [{ name: 'area/web' }, { name: 'kind/bug-fix' }],
  292. type: { name: 'Bug' },
  293. state: 'open',
  294. state_reason: null,
  295. })
  296. }
  297. if (url.endsWith('/graphql')) return Response.json({ data: projectGraphqlData() })
  298. if (url.endsWith('/labels/kind%2Fbug-fix')) return Response.json([{ name: 'area/web' }])
  299. if (url.endsWith('/issues/42/comments?per_page=100')) {
  300. return Response.json([
  301. {
  302. id: 99,
  303. user: { type: 'Bot' },
  304. body: '<!-- dsh-issue-policy -->\nold audit',
  305. },
  306. ])
  307. }
  308. if (url.endsWith('/issues/comments/99')) return new Response(null, { status: 204 })
  309. return Response.json({ message: 'unexpected request' }, { status: 500 })
  310. })
  311. assert.deepEqual(await auditIssue(42), [])
  312. assert.deepEqual(
  313. requests.map(({ url, method }) => ({ path: new URL(url).pathname + new URL(url).search, method })),
  314. [
  315. { path: '/repos/deepseek-harness/deepseek-harness/issues/42', method: 'GET' },
  316. { path: '/graphql', method: 'POST' },
  317. {
  318. path: '/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix',
  319. method: 'DELETE',
  320. },
  321. {
  322. path: '/repos/deepseek-harness/deepseek-harness/issues/42/comments?per_page=100',
  323. method: 'GET',
  324. },
  325. {
  326. path: '/repos/deepseek-harness/deepseek-harness/issues/comments/99',
  327. method: 'DELETE',
  328. },
  329. ],
  330. )
  331. })
  332. test('keeps terminal Status aligned with the native close reason', () => {
  333. assert.deepEqual(
  334. validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }),
  335. [],
  336. )
  337. assert.deepEqual(
  338. validateIssue({
  339. ...legalIssue,
  340. status: 'No action',
  341. state: 'closed',
  342. stateReason: 'not_planned',
  343. }),
  344. [],
  345. )
  346. assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因'))
  347. })
  348. test('separates resolving and informational references', () => {
  349. assert.deepEqual(
  350. parseReferences({
  351. body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7',
  352. repository: 'deepseekharness/dsh-test',
  353. }),
  354. { all: [4, 7, 12], resolving: [12], related: [4, 7] },
  355. )
  356. })
  357. test('converts PR creation timestamps to Shanghai Project dates', () => {
  358. assert.equal(projectDate('2026-08-27T15:59:59Z', 'Asia/Shanghai'), '2026-08-27')
  359. assert.equal(projectDate('2026-08-27T16:00:00Z', 'Asia/Shanghai'), '2026-08-28')
  360. assert.throws(() => projectDate('invalid', 'Asia/Shanghai'), /无效的 PR 创建时间/)
  361. })
  362. test('initializes every referenced Issue only for a PR opened event', async () => {
  363. const writes = []
  364. const pull = {
  365. createdAt: '2026-08-27T16:00:00Z',
  366. references: { all: [4, 7, 12] },
  367. }
  368. const initialize = async (number, date) => writes.push({ number, date })
  369. await initializePullRequestStartDates(pull, 'opened', initialize)
  370. assert.deepEqual(writes, [
  371. { number: 4, date: '2026-08-28' },
  372. { number: 7, date: '2026-08-28' },
  373. { number: 12, date: '2026-08-28' },
  374. ])
  375. for (const action of ['edited', 'synchronize', 'reopened']) {
  376. await initializePullRequestStartDates(pull, action, initialize)
  377. }
  378. assert.equal(writes.length, 3)
  379. })
  380. test('reads Priority and Status from Project custom fields', async (t) => {
  381. const previousGhToken = process.env.GH_TOKEN
  382. const previousGithubToken = process.env.GITHUB_TOKEN
  383. const previousProjectToken = process.env.PROJECT_TOKEN
  384. delete process.env.GH_TOKEN
  385. process.env.GITHUB_TOKEN = 'repository-token'
  386. process.env.PROJECT_TOKEN = 'project-token'
  387. t.after(() => {
  388. if (previousGhToken === undefined) delete process.env.GH_TOKEN
  389. else process.env.GH_TOKEN = previousGhToken
  390. if (previousGithubToken === undefined) delete process.env.GITHUB_TOKEN
  391. else process.env.GITHUB_TOKEN = previousGithubToken
  392. if (previousProjectToken === undefined) delete process.env.PROJECT_TOKEN
  393. else process.env.PROJECT_TOKEN = previousProjectToken
  394. })
  395. const urls = []
  396. t.mock.method(globalThis, 'fetch', async (url, options) => {
  397. urls.push(url)
  398. if (url.endsWith('/issues/42')) {
  399. assert.equal(options.headers.Authorization, 'Bearer repository-token')
  400. return Response.json({
  401. node_id: 'issue-id',
  402. title: 'Project metadata',
  403. body: null,
  404. assignees: [],
  405. labels: [],
  406. type: { name: 'Task' },
  407. state: 'open',
  408. state_reason: null,
  409. })
  410. }
  411. assert.equal(url, 'https://api.github.com/graphql')
  412. assert.equal(options.headers.Authorization, 'Bearer project-token')
  413. return Response.json({ data: projectGraphqlData({ priority: 'P1' }) })
  414. })
  415. const issue = await issueSnapshot(42)
  416. assert.equal(issue.priority, 'P1')
  417. assert.equal(issue.status, 'Inbox')
  418. assert.deepEqual(urls, [
  419. 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42',
  420. 'https://api.github.com/graphql',
  421. ])
  422. })
  423. test('writes an empty Project Start Date with the configured field', async (t) => {
  424. const requests = mockGraphql(t, (request) => {
  425. if (request.query.includes('query(')) return projectGraphqlData()
  426. return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'item-id' } } }
  427. })
  428. await initializeIssueStartDate(42, '2026-08-28')
  429. assert.equal(requests.length, 2)
  430. assert.match(requests[0].query, /isIssueField/)
  431. assert.doesNotMatch(requests[0].query, /issueField\s*\{/)
  432. assert.match(requests[0].query, /priorityValue: fieldValueByName/)
  433. assert.equal(requests[0].variables.priorityField, 'Priority')
  434. assert.match(requests[0].query, /ProjectV2ItemFieldDateValue/)
  435. assert.match(requests[1].query, /updateProjectV2ItemFieldValue/)
  436. assert.match(requests[1].query, /value: \{date: \$date\}/)
  437. assert.deepEqual(requests[1].variables, {
  438. projectId: 'project-id',
  439. itemId: 'item-id',
  440. fieldId: 'start-date-field-id',
  441. date: '2026-08-28',
  442. })
  443. })
  444. test('preserves an existing Project Start Date', async (t) => {
  445. const requests = mockGraphql(t, () => projectGraphqlData({ startDate: '2026-08-01' }))
  446. await initializeIssueStartDate(42, '2026-08-28')
  447. assert.equal(requests.length, 1)
  448. })
  449. test('adds a referenced Issue to the Project before setting Start Date', async (t) => {
  450. const requests = mockGraphql(t, (request) => {
  451. if (request.query.includes('query(')) return projectGraphqlData({ projectItem: false })
  452. if (request.query.includes('addProjectV2ItemById')) {
  453. return { addProjectV2ItemById: { item: { id: 'new-item-id' } } }
  454. }
  455. return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'new-item-id' } } }
  456. })
  457. await initializeIssueStartDate(42, '2026-08-28')
  458. assert.equal(requests.length, 3)
  459. assert.deepEqual(requests[1].variables, { projectId: 'project-id', contentId: 'issue-id' })
  460. assert.deepEqual(requests[2].variables, {
  461. projectId: 'project-id',
  462. itemId: 'new-item-id',
  463. fieldId: 'start-date-field-id',
  464. date: '2026-08-28',
  465. })
  466. })
  467. test('rejects a missing, non-Date, or Issue-level Start Date field', async (t) => {
  468. let response = projectGraphqlData({ startDateField: false })
  469. const requests = mockGraphql(t, () => response)
  470. await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start Date 字段/)
  471. response = projectGraphqlData({ startDateType: 'TEXT' })
  472. await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start Date 字段必须为 Date/)
  473. response = projectGraphqlData({ startDateIsIssueField: true })
  474. await assert.rejects(
  475. initializeIssueStartDate(42, '2026-08-28'),
  476. /Start Date 字段必须为 Project Date 字段/,
  477. )
  478. assert.equal(requests.length, 3)
  479. })
  480. test('rejects a missing, non-select, or Issue-level Priority field', async (t) => {
  481. let response = projectGraphqlData({ priorityField: false })
  482. const requests = mockGraphql(t, () => response)
  483. await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Priority 字段/)
  484. response = projectGraphqlData({ priorityType: 'TEXT' })
  485. await assert.rejects(
  486. initializeIssueStartDate(42, '2026-08-28'),
  487. /Priority 字段必须为 Single Select/,
  488. )
  489. response = projectGraphqlData({ priorityIsIssueField: true })
  490. await assert.rejects(
  491. initializeIssueStartDate(42, '2026-08-28'),
  492. /Priority 字段必须为 Project custom field/,
  493. )
  494. assert.equal(requests.length, 3)
  495. })
  496. test('does not treat pull request references as Issue associations', () => {
  497. const references = {
  498. all: [123, 1180, 1181],
  499. resolving: [123, 1180],
  500. related: [1181],
  501. }
  502. const issues = new Map([
  503. [1180, {}],
  504. [1181, {}],
  505. ])
  506. assert.deepEqual(retainIssueReferences(references, issues), {
  507. all: [1180, 1181],
  508. resolving: [1180],
  509. related: [1181],
  510. })
  511. })
  512. test('allows informational references without cross-object constraints', () => {
  513. const errors = validatePullRequest({
  514. isDraft: false,
  515. authorType: 'User',
  516. reviewRequestCount: 1,
  517. reviewCount: 0,
  518. labels: ['kind/cleanup', 'area/infra'],
  519. references: { all: [4], resolving: [], related: [4] },
  520. issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]),
  521. })
  522. assert.deepEqual(errors, [])
  523. })
  524. test('enforces highest resolving Priority without Type or area synchronization', () => {
  525. const pull = {
  526. isDraft: false,
  527. authorType: 'User',
  528. reviewRequestCount: 0,
  529. reviewCount: 1,
  530. labels: ['kind/cleanup', 'p0', 'area/web'],
  531. references: { all: [2, 3], resolving: [2, 3], related: [] },
  532. issues: new Map([
  533. [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }],
  534. [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }],
  535. ]),
  536. }
  537. assert.deepEqual(validatePullRequest(pull), [])
  538. assert.ok(
  539. validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes(
  540. 'PR Priority 应为 p0',
  541. ),
  542. )
  543. })
  544. test('requires policy only after a human PR enters review', () => {
  545. assert.equal(
  546. requiresPullRequestPolicy({
  547. isDraft: false,
  548. authorType: 'User',
  549. reviewRequestCount: 1,
  550. reviewCount: 0,
  551. }),
  552. true,
  553. )
  554. assert.equal(
  555. requiresPullRequestPolicy({
  556. isDraft: false,
  557. authorType: 'User',
  558. reviewRequestCount: 0,
  559. reviewCount: 0,
  560. }),
  561. false,
  562. )
  563. })
  564. test('maps only explicit review handoffs to review status commands', () => {
  565. assert.equal(
  566. resolvingIssueStatusCommand('pull_request', {
  567. action: 'review_requested',
  568. }),
  569. 'review-requested',
  570. )
  571. assert.equal(
  572. resolvingIssueStatusCommand('pull_request_review', {
  573. action: 'submitted',
  574. review: { state: 'changes_requested' },
  575. }),
  576. 'changes-requested',
  577. )
  578. for (const state of ['approved', 'commented']) {
  579. assert.equal(
  580. resolvingIssueStatusCommand('pull_request_review', {
  581. action: 'submitted',
  582. review: { state },
  583. }),
  584. null,
  585. )
  586. }
  587. assert.equal(
  588. resolvingIssueStatusCommand('pull_request_review', {
  589. action: 'dismissed',
  590. review: { state: 'changes_requested' },
  591. }),
  592. null,
  593. )
  594. })
  595. test('keeps PR opening, reopening, and body edits as implementation signals', () => {
  596. for (const action of ['opened', 'reopened']) {
  597. assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
  598. }
  599. assert.equal(
  600. resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { body: { from: '' } } }),
  601. 'implementation',
  602. )
  603. for (const action of ['synchronize', 'labeled', 'unlabeled', 'edited']) {
  604. assert.equal(resolvingIssueStatusCommand('pull_request', { action }), null)
  605. }
  606. assert.equal(
  607. resolvingIssueStatusCommand('pull_request', { action: 'edited', changes: { title: { from: '' } } }),
  608. null,
  609. )
  610. assert.equal(
  611. resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
  612. null,
  613. )
  614. })
  615. test('toggles automation-owned work on request changes and repeated review request', () => {
  616. for (const status of ['Inbox', 'Backlog', 'Ready']) {
  617. assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress')
  618. assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review')
  619. assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress')
  620. }
  621. let status = nextResolvingIssueStatus(
  622. 'In review',
  623. 'changes-requested',
  624. 'dsh-issue-management',
  625. )
  626. assert.equal(status, 'In progress')
  627. status = nextResolvingIssueStatus(status, 'review-requested')
  628. assert.equal(status, 'In review')
  629. })
  630. test('preserves human review status and terminal Issues', () => {
  631. assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null)
  632. assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null)
  633. assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null)
  634. assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null)
  635. assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null)
  636. assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null)
  637. assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null)
  638. assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null)
  639. })
  640. test('keeps lifecycle projection independent of PR metadata enforcement', () => {
  641. const pull = {
  642. isDraft: false,
  643. authorType: 'User',
  644. reviewRequestCount: 1,
  645. reviewCount: 0,
  646. labels: [],
  647. references: { all: [2], resolving: [2], related: [] },
  648. issues: new Map([[2, { priority: null }]]),
  649. }
  650. assert.ok(validatePullRequest(pull).length > 0)
  651. assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review')
  652. })
  653. test('exempts Draft, Bot, and App PRs', () => {
  654. const invalid = {
  655. isDraft: false,
  656. labels: [],
  657. references: { all: [], resolving: [], related: [] },
  658. issues: new Map(),
  659. reviewRequestCount: 1,
  660. reviewCount: 0,
  661. }
  662. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), [])
  663. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), [])
  664. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), [])
  665. assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0)
  666. })
  667. test('requires repository PR labels in the enforcement scope', () => {
  668. const errors = validatePullRequest({
  669. isDraft: false,
  670. authorType: 'User',
  671. reviewRequestCount: 1,
  672. reviewCount: 0,
  673. labels: [],
  674. references: { all: [2], resolving: [], related: [2] },
  675. issues: new Map([[2, { priority: null }]]),
  676. })
  677. assert.ok(errors.includes('PR 必须恰好有一个允许的 kind/*,当前为 0'))
  678. assert.ok(errors.includes('PR 必须至少有一个 area/*'))
  679. })
  680. test('accepts exactly the canonical kinds with extensible areas', () => {
  681. for (const kind of canonicalKinds) {
  682. assert.deepEqual(validatePullRequest(reviewedPull([kind, 'area/future-domain'])), [], kind)
  683. }
  684. })
  685. test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => {
  686. assert.ok(
  687. validatePullRequest(
  688. reviewedPull(['kind/feature', 'kind/doc', 'area/web']),
  689. ).includes('PR 必须恰好有一个允许的 kind/*,当前为 2'),
  690. )
  691. assert.ok(
  692. validatePullRequest(reviewedPull(['kind/experimental', 'area/web'])).includes(
  693. 'PR 含不支持的 kind/*:kind/experimental',
  694. ),
  695. )
  696. for (const label of legacyLabels) {
  697. assert.ok(
  698. validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) =>
  699. error.startsWith('PR 含旧版标签:'),
  700. ),
  701. label,
  702. )
  703. }
  704. assert.ok(
  705. validatePullRequest(
  706. reviewedPull(['kind/feature', 'area/web', 'source/internal-pr']),
  707. ).includes('source/* 仅用于 Issue:source/internal-pr'),
  708. )
  709. })
  710. const mockPolicyApi = (t, { pull = {}, requested = true, reviews = [], issues = {}, priority = 'P1', projectError = false } = {}) => {
  711. const environment = ['GH_TOKEN', 'GITHUB_TOKEN', 'PROJECT_TOKEN', 'GITHUB_API_URL', 'GITHUB_OUTPUT']
  712. const previous = new Map(environment.map((key) => [key, process.env[key]]))
  713. const directory = mkdtempSync(join(tmpdir(), 'dsh-policy-'))
  714. t.after(() => {
  715. for (const [key, value] of previous) {
  716. if (value === undefined) delete process.env[key]
  717. else process.env[key] = value
  718. }
  719. rmSync(directory, { recursive: true, force: true })
  720. })
  721. for (const key of environment) delete process.env[key]
  722. process.env.GITHUB_TOKEN = 'repository-token'
  723. process.env.GITHUB_OUTPUT = join(directory, 'output')
  724. const requests = []
  725. const output = []
  726. t.mock.method(process.stdout, 'write', (text) => { output.push(text); return true })
  727. t.mock.method(globalThis, 'fetch', async (url, options) => {
  728. const path = new URL(url).pathname + new URL(url).search
  729. requests.push(path)
  730. if (path.endsWith('/pulls/10')) return Response.json({
  731. draft: false, user: { type: 'User' }, body: 'Refs #2',
  732. labels: [{ name: 'kind/cleanup' }, { name: 'area/infra' }], ...pull,
  733. })
  734. if (path.endsWith('/requested_reviewers')) {
  735. return Response.json({ users: requested ? [{}] : [], teams: [] })
  736. }
  737. if (path.endsWith('/reviews?per_page=100')) return Response.json(reviews)
  738. if (path === '/graphql') {
  739. assert.equal(options.headers.Authorization, 'Bearer repository-token')
  740. if (projectError) return Response.json({ errors: [{ message: 'Project access denied' }] })
  741. return Response.json({ data: projectGraphqlData({ priority }) })
  742. }
  743. const number = Number(path.match(/\/issues\/(\d+)$/)?.[1])
  744. assert.ok(Object.hasOwn(issues, number), 'Unexpected request: ' + path)
  745. const issue = issues[number]
  746. return Response.json(issue ?? { message: 'Not Found' }, { status: issue === null ? 404 : 200 })
  747. })
  748. return { requests, output, workflowOutput: () => readFileSync(process.env.GITHUB_OUTPUT, 'utf8') }
  749. }
  750. for (const [name, pull, requested, count] of [
  751. ['draft', { draft: true }, true, 1],
  752. ['Bot', { user: { type: 'Bot' } }, true, 1],
  753. ['App', { user: { type: 'App' } }, true, 1],
  754. ['not reviewed', {}, false, 3],
  755. ]) {
  756. test('reads no Issue or Project for a currently exempt ' + name + ' PR', async (t) => {
  757. const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #999', ...pull }, requested })
  758. const event = { pull_request: { number: 10, draft: false, user: { type: 'User' } } }
  759. assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
  760. assert.equal(fixture.requests.length, count)
  761. assert.equal(fixture.workflowOutput(), 'eligible=false\nexempt=true\nneeds-project=false\n')
  762. await runPullRequestCheck(event)
  763. assert.equal(fixture.requests.length, count * 2)
  764. assert.ok(fixture.output.every((text) => text.includes('Issue policy exempt')))
  765. })
  766. }
  767. test('validates informational Issues and ignores PR numbers without Project reads', async (t) => {
  768. const fixture = mockPolicyApi(t, { pull: { body: 'Refs #2; Fixes #3' }, issues: { 2: {}, 3: { pull_request: {} } } })
  769. const event = { pull_request: { number: 10, draft: true, body: 'Fixes #999' } }
  770. assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: false })
  771. assert.equal(fixture.requests.length, 5)
  772. assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=false\n')
  773. await runPullRequestCheck(event)
  774. assert.equal(fixture.requests.length, 10)
  775. assert.ok(!fixture.requests.includes('/graphql'))
  776. })
  777. test('requires a real Issue and explains why stacked PR references do not qualify', async (t) => {
  778. const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #3' }, issues: { 3: { pull_request: {} } } })
  779. await assert.rejects(runPullRequestCheck({ pull_request: { number: 10 } }), /Issue policy 未通过/)
  780. assert.equal(fixture.requests.length, 4)
  781. assert.match(fixture.output.join(''), /PR 编号(包括堆叠依赖 PR)不算 Issue 引用/)
  782. })
  783. test('fetches Project Priority only for resolving Issues and enforces mismatch', async (t) => {
  784. const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2; Refs #4; Fixes #3' }, issues: { 2: {}, 4: {}, 3: { pull_request: {} } } })
  785. const event = { pull_request: { number: 10 } }
  786. assert.deepEqual(await runPullRequestPreflight(event), { eligible: true, needsProject: true })
  787. assert.equal(fixture.requests.length, 6)
  788. assert.equal(fixture.workflowOutput(), 'eligible=true\nexempt=false\nneeds-project=true\n')
  789. assert.ok(!fixture.requests.includes('/graphql'))
  790. await assert.rejects(runPullRequestCheck(event), /Issue policy 未通过/)
  791. assert.equal(fixture.requests.length, 13)
  792. assert.equal(fixture.requests.filter((path) => path === '/graphql').length, 1)
  793. assert.match(fixture.output.join(''), /PR Priority 应为 p1/)
  794. })
  795. test('enforces current metadata on title edits and prior reviews without requested reviewers', async (t) => {
  796. const fixture = mockPolicyApi(t, { requested: false, reviews: [{}], pull: { labels: [] }, issues: { 2: {} } })
  797. await assert.rejects(runPullRequestCheck({ action: 'edited', changes: { title: { from: 'old' } }, pull_request: { number: 10 } }), /Issue policy 未通过/)
  798. assert.equal(fixture.requests.length, 4)
  799. assert.match(fixture.output.join(''), /PR 必须至少有一个 area/)
  800. })
  801. test('fails closed on missing referenced numbers and unavailable Project access', async (t) => {
  802. const fixture = mockPolicyApi(t, { pull: { body: 'Fixes #2' }, issues: { 2: null } })
  803. await assert.rejects(runPullRequestPreflight({ pull_request: { number: 10 } }), /404/)
  804. assert.equal(fixture.requests.length, 4)
  805. })
  806. test('fails closed when current resolving Issues need a Project token preflight did not mint', async (t) => {
  807. const pull = { draft: true, body: 'Fixes #2' }
  808. const fixture = mockPolicyApi(t, { pull, issues: { 2: {} }, projectError: true })
  809. const event = { pull_request: { number: 10 } }
  810. assert.deepEqual(await runPullRequestPreflight(event), { eligible: false, needsProject: false })
  811. pull.draft = false
  812. await assert.rejects(runPullRequestCheck(event), /Project access denied/)
  813. assert.equal(fixture.requests.length, 6)
  814. })
  815. test('performs no lifecycle requests for removed signals or title-only edits', async (t) => {
  816. const fixture = mockPolicyApi(t)
  817. for (const action of ['synchronize', 'labeled', 'unlabeled']) {
  818. await runLifecycle('pull_request', { action, pull_request: { number: 10 } })
  819. }
  820. await runLifecycle('pull_request', { action: 'edited', changes: { title: { from: '' } }, pull_request: { number: 10 } })
  821. for (const state of ['approved', 'commented']) {
  822. await runLifecycle('pull_request_review', { action: 'submitted', review: { state }, pull_request: { number: 10 } })
  823. }
  824. assert.deepEqual(fixture.requests, [])
  825. })
  826. test('keeps trusted preflight before token minting and required policy unconditional', () => {
  827. const source = readFileSync(new URL('../workflows/issue-policy.yml', import.meta.url), 'utf8')
  828. const job = source.slice(source.indexOf(' policy:'))
  829. assert.ok(job.includes(' name: Issue policy'))
  830. assert.ok(!job.slice(0, job.indexOf(' steps:')).includes(' if:'))
  831. assert.ok(source.includes('types: [opened, edited, synchronize, reopened, labeled, unlabeled, ready_for_review, review_requested]'))
  832. const steps = job.split(' - name: ').slice(1)
  833. assert.equal(steps.length, 4)
  834. assert.ok(steps[0].includes('ref: ${{ github.event.repository.default_branch }}'))
  835. assert.ok(steps[0].includes('persist-credentials: false'))
  836. assert.doesNotMatch(source, /pull_request\.head|pull_request_target/)
  837. assert.ok(steps[1].includes('id: preflight'))
  838. assert.ok(steps[1].includes('GITHUB_TOKEN: ${{ github.token }}'))
  839. assert.ok(steps[1].includes('node .github/issue-management/policy.mjs pr-preflight'))
  840. assert.ok(steps[1].includes('if [ -f .github/issue-management/selective-preflight.json ]; then'))
  841. assert.doesNotMatch(steps[1], /secrets\.|PROJECT_TOKEN|if:/)
  842. assert.ok(steps[2].includes("if: ${{ steps.preflight.outputs.needs-project == 'true' }}"))
  843. assert.ok(steps[2].includes('permission-organization-projects: read'))
  844. assert.ok(steps[3].includes('PROJECT_TOKEN: ${{ steps.app-token.outputs.token }}'))
  845. assert.ok(steps[3].includes('run: node .github/issue-management/policy.mjs pr'))
  846. assert.ok(steps[3].includes("if: ${{ steps.preflight.outputs.legacy-automated != 'true' }}"))
  847. })
  848. test('runs trusted rollout selection with absent and present capability markers', { skip: process.platform === 'win32' ? 'The policy workflow executes under hosted Ubuntu bash' : false }, (t) => {
  849. const directory = mkdtempSync(join(tmpdir(), 'dsh-policy-rollout-'))
  850. t.after(() => rmSync(directory, { recursive: true, force: true }))
  851. const source = readFileSync(new URL('../workflows/issue-policy.yml', import.meta.url), 'utf8')
  852. const script = source.split(' run: |\n')[1].split(' - name: Create Project read token')[0]
  853. .split('\n').map((line) => line.slice(10)).join('\n')
  854. assert.deepEqual(JSON.parse(readFileSync(new URL('./selective-preflight.json', import.meta.url), 'utf8')), { version: 1 })
  855. const cases = [
  856. { name: 'legacy human draft', type: 'User', draft: true, marker: false, expected: 'legacy-automated=false\nneeds-project=true\n' },
  857. { name: 'legacy human ready', type: 'User', draft: false, marker: false, expected: 'legacy-automated=false\nneeds-project=true\n' },
  858. { name: 'legacy bot', type: 'Bot', marker: false, expected: 'legacy-automated=true\nneeds-project=false\n' },
  859. { name: 'legacy app', type: 'App', marker: false, expected: 'legacy-automated=true\nneeds-project=false\n' },
  860. { name: 'modern exempt', type: 'Bot', marker: true, expected: 'exempt=true\nneeds-project=false\n' },
  861. { name: 'modern failure', type: 'User', marker: true, failure: true, expected: '' },
  862. ]
  863. for (const [index, fixture] of cases.entries()) {
  864. const cwd = join(directory, String(index))
  865. const policyDirectory = join(cwd, '.github', 'issue-management')
  866. mkdirSync(policyDirectory, { recursive: true })
  867. const eventPath = join(cwd, 'event.json')
  868. const outputPath = join(cwd, 'output')
  869. writeFileSync(eventPath, JSON.stringify({ pull_request: { user: { type: fixture.type }, draft: fixture.draft } }))
  870. writeFileSync(outputPath, '')
  871. if (fixture.marker) writeFileSync(join(policyDirectory, 'selective-preflight.json'), '{"version":1}\n')
  872. writeFileSync(join(policyDirectory, 'policy.mjs'), fixture.marker && !fixture.failure
  873. ? "import fs from 'node:fs'; if (process.argv[2] !== 'pr-preflight') throw Error('wrong command'); fs.appendFileSync(process.env.GITHUB_OUTPUT, 'exempt=true\\nneeds-project=false\\n')\n"
  874. : "throw new Error('preflight unavailable or failed')\n")
  875. const result = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
  876. cwd,
  877. env: { PATH: process.env.PATH, GITHUB_EVENT_PATH: eventPath, GITHUB_OUTPUT: outputPath },
  878. encoding: 'utf8',
  879. timeout: 30_000,
  880. })
  881. assert.equal(result.error, undefined, fixture.name)
  882. assert.equal(result.signal, null, fixture.name)
  883. assert.equal(result.status, fixture.failure ? 1 : 0, fixture.name + ': ' + result.stderr)
  884. assert.equal(readFileSync(outputPath, 'utf8'), fixture.expected, fixture.name)
  885. if (fixture.marker) assert.doesNotMatch(result.stdout, /preserving legacy/)
  886. else assert.match(result.stdout, /preserving legacy policy enforcement/)
  887. }
  888. })
  889. test('allocates lifecycle runners only for relevant reviews and PR body edits', () => {
  890. const source = readFileSync(new URL('../workflows/issue-lifecycle.yml', import.meta.url), 'utf8')
  891. const issues = source.split(' issues:')[1].split(' pull_request:')[0]
  892. const pulls = source.split(' pull_request:')[1].split(' pull_request_review:')[0]
  893. const actions = (block) => [...block.matchAll(/^ - (\w+)$/gm)].map((match) => match[1])
  894. assert.deepEqual(actions(issues), ['opened', 'edited', 'labeled', 'unlabeled', 'closed', 'reopened', 'typed', 'untyped', 'field_added', 'field_removed'])
  895. assert.deepEqual(actions(pulls), ['opened', 'edited', 'reopened', 'review_requested'])
  896. const job = source.slice(source.indexOf(' lifecycle:'))
  897. const beforeSteps = job.slice(0, job.indexOf(' steps:'))
  898. assert.ok(beforeSteps.includes(' if: >-'))
  899. assert.ok(beforeSteps.includes("(github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested') &&"))
  900. assert.ok(beforeSteps.includes("(github.event_name != 'pull_request' || github.event.action != 'edited' || github.event.changes.body != null)"))
  901. assert.ok(source.includes('ref: ${{ github.event.repository.default_branch }}'))
  902. assert.ok(source.includes('persist-credentials: false'))
  903. })
  904. test('keeps REST headers, null responses, and transport errors unchanged', async (t) => {
  905. mockPolicyApi(t)
  906. process.env.GH_TOKEN = 'preferred-token'
  907. process.env.PROJECT_TOKEN = 'project-token'
  908. process.env.GITHUB_API_URL = 'https://github.example/api/v3'
  909. const requests = []
  910. const responses = [
  911. Response.json({ ok: true }),
  912. new Response(null, { status: 204 }),
  913. new Response('missing', { status: 404 }),
  914. new Response('denied', { status: 403 }),
  915. Response.json({ errors: [{ message: 'first' }, { message: 'second' }] }),
  916. ]
  917. t.mock.method(globalThis, 'fetch', async (url, options) => {
  918. requests.push({ url, options })
  919. return responses.shift()
  920. })
  921. assert.deepEqual(await api('/example'), { ok: true })
  922. assert.deepEqual(requests[0], {
  923. url: 'https://github.example/api/v3/example',
  924. options: { headers: {
  925. Accept: 'application/vnd.github+json',
  926. Authorization: 'Bearer preferred-token',
  927. 'X-GitHub-Api-Version': '2026-03-10',
  928. 'User-Agent': 'dsh-issue-policy',
  929. } },
  930. })
  931. assert.equal(await api('/empty'), null)
  932. assert.equal(await api('/missing', { allow404: true }), null)
  933. await assert.rejects(api('/denied', { method: 'PATCH' }), { message: 'PATCH /denied: 403 denied' })
  934. await assert.rejects(graphql('query { viewer { login } }', {}), { message: 'first; second' })
  935. assert.equal(requests[4].options.headers.Authorization, 'Bearer project-token')
  936. assert.equal(requests[4].options.method, 'POST')
  937. assert.equal(requests[4].options.body, JSON.stringify({ query: 'query { viewer { login } }', variables: {} }))
  938. assert.equal(requests.length, 5)
  939. })
  940. test('reads policy snapshots in reference order and only resolving Project priorities', async (t) => {
  941. const fixture = mockPolicyApi(t, {
  942. pull: { body: 'Refs #4; Fixes #3; Fixes #2' },
  943. issues: { 2: {}, 3: { pull_request: {} }, 4: {} },
  944. })
  945. assert.deepEqual(await pullRequestSnapshot(10), {
  946. number: 10,
  947. isDraft: false,
  948. authorType: 'User',
  949. reviewRequestCount: 1,
  950. reviewCount: 0,
  951. labels: ['kind/cleanup', 'area/infra'],
  952. references: { all: [2, 4], resolving: [2], related: [4] },
  953. issues: new Map([[2, { priority: 'P1' }], [4, { priority: null }]]),
  954. })
  955. const repo = '/repos/deepseek-harness/deepseek-harness'
  956. assert.deepEqual(fixture.requests, [
  957. repo + '/pulls/10',
  958. repo + '/pulls/10/requested_reviewers',
  959. repo + '/pulls/10/reviews?per_page=100',
  960. repo + '/issues/2',
  961. repo + '/issues/3',
  962. repo + '/issues/4',
  963. '/graphql',
  964. ])
  965. assert.deepEqual(fixture.output, [])
  966. })
  967. test('reads lifecycle references for draft Bot PRs without review or Project requests', async (t) => {
  968. const fixture = mockPolicyApi(t, {
  969. pull: { draft: true, user: { type: 'Bot' }, body: 'Fixes #2; Refs #4', created_at: '2026-08-27T16:00:00Z' },
  970. issues: { 2: {}, 4: {} },
  971. })
  972. assert.deepEqual(await lifecyclePullRequestSnapshot(10), {
  973. number: 10,
  974. references: { all: [2, 4], resolving: [2], related: [4] },
  975. issues: new Map([[2, { priority: null }], [4, { priority: null }]]),
  976. createdAt: '2026-08-27T16:00:00Z',
  977. })
  978. const repo = '/repos/deepseek-harness/deepseek-harness'
  979. assert.deepEqual(fixture.requests, [repo + '/pulls/10', repo + '/issues/2', repo + '/issues/4'])
  980. assert.deepEqual(fixture.output, [])
  981. })
  982. test('allows missing Priority only when resolving Issues are also unprioritized', () => {
  983. const pull = {
  984. isDraft: false,
  985. authorType: 'User',
  986. reviewRequestCount: 1,
  987. reviewCount: 0,
  988. labels: ['kind/feature', 'area/web'],
  989. references: { all: [2], resolving: [2], related: [] },
  990. issues: new Map([[2, { priority: null }]]),
  991. }
  992. assert.deepEqual(validatePullRequest(pull), [])
  993. assert.ok(
  994. validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes(
  995. 'PR Priority 应为 p2',
  996. ),
  997. )
  998. assert.ok(
  999. validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes(
  1000. '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority',
  1001. ),
  1002. )
  1003. })