policy.test.mjs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773
  1. import assert from 'node:assert/strict'
  2. import { readFileSync, readdirSync } from 'node:fs'
  3. import test from 'node:test'
  4. import {
  5. auditIssue,
  6. initializeIssueStartDate,
  7. initializePullRequestStartDates,
  8. issueSnapshot,
  9. nextResolvingIssueStatus,
  10. parseReferences,
  11. projectDate,
  12. repairIssueLabels,
  13. retainIssueReferences,
  14. resolvingIssueStatusCommand,
  15. requiresPullRequestPolicy,
  16. validateIssue,
  17. validatePullRequest,
  18. } from './policy.mjs'
  19. const projectGraphqlData = ({
  20. projectItem = true,
  21. priority = null,
  22. priorityField = true,
  23. priorityType = 'SINGLE_SELECT',
  24. priorityIsIssueField = false,
  25. startDate = null,
  26. startDateField = true,
  27. startDateType = 'DATE',
  28. startDateIsIssueField = false,
  29. } = {}) => ({
  30. organization: {
  31. projectV2: {
  32. id: 'project-id',
  33. title: 'DSH Issue Management',
  34. fields: {
  35. nodes: [
  36. {
  37. id: 'status-field-id',
  38. name: 'Status',
  39. dataType: 'SINGLE_SELECT',
  40. isIssueField: false,
  41. options: [],
  42. },
  43. ...(priorityField
  44. ? [
  45. {
  46. id: 'priority-project-field-id',
  47. name: 'Priority',
  48. dataType: priorityType,
  49. isIssueField: priorityIsIssueField,
  50. options: [],
  51. },
  52. ]
  53. : []),
  54. ...(startDateField
  55. ? [
  56. {
  57. id: 'start-date-field-id',
  58. name: 'Start Date',
  59. dataType: startDateType,
  60. isIssueField: startDateIsIssueField,
  61. },
  62. ]
  63. : []),
  64. ],
  65. },
  66. },
  67. },
  68. repository: {
  69. issue: {
  70. id: 'issue-id',
  71. projectItems: {
  72. nodes: projectItem
  73. ? [
  74. {
  75. id: 'item-id',
  76. project: { id: 'project-id' },
  77. fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' },
  78. priorityValue:
  79. priority === null ? null : { name: priority, optionId: `${priority}-option-id` },
  80. startDateValue: startDate === null ? null : { date: startDate },
  81. },
  82. ]
  83. : [],
  84. },
  85. },
  86. },
  87. })
  88. const mockGraphql = (t, resolve) => {
  89. const requests = []
  90. const previousToken = process.env.GH_TOKEN
  91. process.env.GH_TOKEN = 'test-token'
  92. t.after(() => {
  93. if (previousToken === undefined) delete process.env.GH_TOKEN
  94. else process.env.GH_TOKEN = previousToken
  95. })
  96. t.mock.method(globalThis, 'fetch', async (url, options) => {
  97. assert.equal(url, 'https://api.github.com/graphql')
  98. assert.equal(options.headers.Authorization, 'Bearer test-token')
  99. const request = JSON.parse(options.body)
  100. requests.push(request)
  101. return Response.json({ data: resolve(request, requests.length - 1) })
  102. })
  103. return requests
  104. }
  105. const legalIssue = {
  106. labels: [],
  107. type: 'Idea',
  108. priority: null,
  109. status: 'In review',
  110. state: 'open',
  111. stateReason: null,
  112. }
  113. const canonicalKinds = [
  114. 'kind/feature',
  115. 'kind/bug-fix',
  116. 'kind/doc',
  117. 'kind/testing',
  118. 'kind/cleanup',
  119. 'kind/dependency',
  120. ]
  121. // Keep an independent oracle rather than importing the implementation's reserved set.
  122. const legacyLabels = [
  123. 'kind/bug',
  124. 'kind/documentation',
  125. 'feature',
  126. 'bug-fix',
  127. 'doc',
  128. 'cleanup',
  129. 'testing',
  130. 'dependencies',
  131. 'ci',
  132. 'cli',
  133. 'llm',
  134. 'web-search',
  135. ]
  136. const reviewedPull = (labels) => ({
  137. isDraft: false,
  138. authorType: 'User',
  139. reviewRequestCount: 1,
  140. reviewCount: 0,
  141. labels,
  142. references: { all: [2], resolving: [], related: [2] },
  143. issues: new Map([[2, { priority: null }]]),
  144. })
  145. test('keeps only Bug, Feature, and Task Issue templates with used frontmatter', () => {
  146. const directory = new URL('../ISSUE_TEMPLATE/', import.meta.url)
  147. assert.deepEqual(readdirSync(directory).sort(), ['bug.md', 'config.yml', 'feature.md', 'task.md'])
  148. for (const file of ['bug.md', 'feature.md', 'task.md']) {
  149. const source = readFileSync(new URL(file, directory), 'utf8')
  150. const frontmatter = source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? ''
  151. const keys = frontmatter
  152. .split('\n')
  153. .filter(Boolean)
  154. .map((line) => line.slice(0, line.indexOf(':')))
  155. .sort()
  156. assert.deepEqual(keys, ['about', 'name', 'type'], file)
  157. assert.match(source, /^## /m, file)
  158. assert.match(source, /<!-- [^\n]+ -->/, file)
  159. assert.doesNotMatch(source, /<details\b/i, file)
  160. }
  161. assert.equal(
  162. readFileSync(new URL('config.yml', directory), 'utf8'),
  163. 'blank_issues_enabled: false\n',
  164. )
  165. })
  166. test('keeps Feature Issues limited to motivation and behavior', () => {
  167. const source = readFileSync(
  168. new URL('../ISSUE_TEMPLATE/feature.md', import.meta.url),
  169. 'utf8',
  170. )
  171. assert.deepEqual(
  172. [...source.matchAll(/^## .+$/gm)].map(([heading]) => heading),
  173. ['## Motivation', '## Behavior'],
  174. )
  175. })
  176. test('keeps Bug Issues limited to the problem report', () => {
  177. const source = readFileSync(new URL('../ISSUE_TEMPLATE/bug.md', import.meta.url), 'utf8')
  178. assert.deepEqual(
  179. [...source.matchAll(/^## .+$/gm)].map(([heading]) => heading),
  180. ['## Summary', '## Reproduction', '## Current behavior', '## Expected behavior', '## Environment'],
  181. )
  182. })
  183. test('keeps Task Issues limited to summary and deliverables', () => {
  184. const source = readFileSync(new URL('../ISSUE_TEMPLATE/task.md', import.meta.url), 'utf8')
  185. assert.deepEqual(
  186. [...source.matchAll(/^## .+$/gm)].map(([heading]) => heading),
  187. ['## Summary', '## Deliverables'],
  188. )
  189. })
  190. test('structures the pull request template around motivation, changes, and testing', () => {
  191. const source = readFileSync(new URL('../pull_request_template.md', import.meta.url), 'utf8')
  192. for (const heading of ['## Motivation', '## Changes', '## Testing']) {
  193. assert.match(source, new RegExp(`^${heading.replaceAll('#', '\\#')}$`, 'm'))
  194. }
  195. assert.doesNotMatch(source, /^### /m)
  196. assert.match(
  197. source,
  198. /<!-- 高层次说明命令[^\n]+ -->\n<!-- 高层次说明用户[^\n]+ -->/,
  199. )
  200. assert.match(source, /- <!-- [^\n]+ -->\n\n <details>\n <summary>Proof<\/summary>/)
  201. assert.equal(source.match(/<details>/g)?.length, 1)
  202. assert.equal(source.match(/<\/details>/g)?.length, 1)
  203. })
  204. test('ignores Issue title, body presentation, and assignee ownership', () => {
  205. assert.deepEqual(
  206. validateIssue({
  207. ...legalIssue,
  208. title: '[Bug] English title',
  209. body: `Owner: @octocat\n\n${'visible '.repeat(60)}<details open>unclosed`,
  210. assignees: ['octocat', 'hubot'],
  211. }),
  212. [],
  213. )
  214. })
  215. test('allows optional metadata in every open Status', () => {
  216. assert.deepEqual(validateIssue(legalIssue), [])
  217. for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) {
  218. assert.deepEqual(validateIssue({ ...legalIssue, status }), [])
  219. }
  220. })
  221. test('reserves PR kind and legacy labels for pull requests', () => {
  222. for (const label of [
  223. ...canonicalKinds,
  224. 'kind/experimental',
  225. ...legacyLabels,
  226. ]) {
  227. assert.ok(
  228. validateIssue({ ...legalIssue, labels: [label] }).some((error) =>
  229. error.startsWith('Issue 不得使用 PR kind 或旧版标签:'),
  230. ),
  231. label,
  232. )
  233. }
  234. assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), [])
  235. })
  236. test('removes reserved labels from Issues before validation', async (t) => {
  237. const previousToken = process.env.GH_TOKEN
  238. process.env.GH_TOKEN = 'test-token'
  239. t.after(() => {
  240. if (previousToken === undefined) delete process.env.GH_TOKEN
  241. else process.env.GH_TOKEN = previousToken
  242. })
  243. const requests = []
  244. t.mock.method(globalThis, 'fetch', async (url, options) => {
  245. requests.push({ url, method: options.method })
  246. assert.equal(options.headers.Authorization, 'Bearer test-token')
  247. if (url.endsWith('/labels/bug-fix')) {
  248. return Response.json({ message: 'Label does not exist' }, { status: 404 })
  249. }
  250. return Response.json([])
  251. })
  252. const issue = {
  253. ...legalIssue,
  254. number: 42,
  255. labels: ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'],
  256. }
  257. const repaired = await repairIssueLabels(issue)
  258. assert.deepEqual(repaired.labels, ['area/web', 'source/member'])
  259. assert.deepEqual(issue.labels, ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'])
  260. assert.deepEqual(validateIssue(repaired), [])
  261. assert.deepEqual(requests, [
  262. {
  263. url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix',
  264. method: 'DELETE',
  265. },
  266. {
  267. url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/bug-fix',
  268. method: 'DELETE',
  269. },
  270. ])
  271. })
  272. test('deletes a stale audit comment after repairing its only violation', async (t) => {
  273. const previousToken = process.env.GH_TOKEN
  274. process.env.GH_TOKEN = 'test-token'
  275. t.after(() => {
  276. if (previousToken === undefined) delete process.env.GH_TOKEN
  277. else process.env.GH_TOKEN = previousToken
  278. })
  279. const requests = []
  280. t.mock.method(globalThis, 'fetch', async (url, options) => {
  281. requests.push({ url, method: options.method ?? 'GET' })
  282. if (url.endsWith('/issues/42')) {
  283. return Response.json({
  284. node_id: 'issue-id',
  285. labels: [{ name: 'area/web' }, { name: 'kind/bug-fix' }],
  286. type: { name: 'Bug' },
  287. state: 'open',
  288. state_reason: null,
  289. })
  290. }
  291. if (url.endsWith('/graphql')) return Response.json({ data: projectGraphqlData() })
  292. if (url.endsWith('/labels/kind%2Fbug-fix')) return Response.json([{ name: 'area/web' }])
  293. if (url.endsWith('/issues/42/comments?per_page=100')) {
  294. return Response.json([
  295. {
  296. id: 99,
  297. user: { type: 'Bot' },
  298. body: '<!-- dsh-issue-policy -->\nold audit',
  299. },
  300. ])
  301. }
  302. if (url.endsWith('/issues/comments/99')) return new Response(null, { status: 204 })
  303. return Response.json({ message: 'unexpected request' }, { status: 500 })
  304. })
  305. assert.deepEqual(await auditIssue(42), [])
  306. assert.deepEqual(
  307. requests.map(({ url, method }) => ({ path: new URL(url).pathname + new URL(url).search, method })),
  308. [
  309. { path: '/repos/deepseek-harness/deepseek-harness/issues/42', method: 'GET' },
  310. { path: '/graphql', method: 'POST' },
  311. {
  312. path: '/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix',
  313. method: 'DELETE',
  314. },
  315. {
  316. path: '/repos/deepseek-harness/deepseek-harness/issues/42/comments?per_page=100',
  317. method: 'GET',
  318. },
  319. {
  320. path: '/repos/deepseek-harness/deepseek-harness/issues/comments/99',
  321. method: 'DELETE',
  322. },
  323. ],
  324. )
  325. })
  326. test('keeps terminal Status aligned with the native close reason', () => {
  327. assert.deepEqual(
  328. validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }),
  329. [],
  330. )
  331. assert.deepEqual(
  332. validateIssue({
  333. ...legalIssue,
  334. status: 'No action',
  335. state: 'closed',
  336. stateReason: 'not_planned',
  337. }),
  338. [],
  339. )
  340. assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因'))
  341. })
  342. test('separates resolving and informational references', () => {
  343. assert.deepEqual(
  344. parseReferences({
  345. body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7',
  346. repository: 'deepseekharness/dsh-test',
  347. }),
  348. { all: [4, 7, 12], resolving: [12], related: [4, 7] },
  349. )
  350. })
  351. test('converts PR creation timestamps to Shanghai Project dates', () => {
  352. assert.equal(projectDate('2026-08-27T15:59:59Z', 'Asia/Shanghai'), '2026-08-27')
  353. assert.equal(projectDate('2026-08-27T16:00:00Z', 'Asia/Shanghai'), '2026-08-28')
  354. assert.throws(() => projectDate('invalid', 'Asia/Shanghai'), /无效的 PR 创建时间/)
  355. })
  356. test('initializes every referenced Issue only for a PR opened event', async () => {
  357. const writes = []
  358. const pull = {
  359. createdAt: '2026-08-27T16:00:00Z',
  360. references: { all: [4, 7, 12] },
  361. }
  362. const initialize = async (number, date) => writes.push({ number, date })
  363. await initializePullRequestStartDates(pull, 'opened', initialize)
  364. assert.deepEqual(writes, [
  365. { number: 4, date: '2026-08-28' },
  366. { number: 7, date: '2026-08-28' },
  367. { number: 12, date: '2026-08-28' },
  368. ])
  369. for (const action of ['edited', 'synchronize', 'reopened']) {
  370. await initializePullRequestStartDates(pull, action, initialize)
  371. }
  372. assert.equal(writes.length, 3)
  373. })
  374. test('reads Priority and Status from Project custom fields', async (t) => {
  375. const previousGhToken = process.env.GH_TOKEN
  376. const previousGithubToken = process.env.GITHUB_TOKEN
  377. const previousProjectToken = process.env.PROJECT_TOKEN
  378. delete process.env.GH_TOKEN
  379. process.env.GITHUB_TOKEN = 'repository-token'
  380. process.env.PROJECT_TOKEN = 'project-token'
  381. t.after(() => {
  382. if (previousGhToken === undefined) delete process.env.GH_TOKEN
  383. else process.env.GH_TOKEN = previousGhToken
  384. if (previousGithubToken === undefined) delete process.env.GITHUB_TOKEN
  385. else process.env.GITHUB_TOKEN = previousGithubToken
  386. if (previousProjectToken === undefined) delete process.env.PROJECT_TOKEN
  387. else process.env.PROJECT_TOKEN = previousProjectToken
  388. })
  389. const urls = []
  390. t.mock.method(globalThis, 'fetch', async (url, options) => {
  391. urls.push(url)
  392. if (url.endsWith('/issues/42')) {
  393. assert.equal(options.headers.Authorization, 'Bearer repository-token')
  394. return Response.json({
  395. node_id: 'issue-id',
  396. title: 'Project metadata',
  397. body: null,
  398. assignees: [],
  399. labels: [],
  400. type: { name: 'Task' },
  401. state: 'open',
  402. state_reason: null,
  403. })
  404. }
  405. assert.equal(url, 'https://api.github.com/graphql')
  406. assert.equal(options.headers.Authorization, 'Bearer project-token')
  407. return Response.json({ data: projectGraphqlData({ priority: 'P1' }) })
  408. })
  409. const issue = await issueSnapshot(42)
  410. assert.equal(issue.priority, 'P1')
  411. assert.equal(issue.status, 'Inbox')
  412. assert.deepEqual(urls, [
  413. 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42',
  414. 'https://api.github.com/graphql',
  415. ])
  416. })
  417. test('writes an empty Project Start Date with the configured field', async (t) => {
  418. const requests = mockGraphql(t, (request) => {
  419. if (request.query.includes('query(')) return projectGraphqlData()
  420. return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'item-id' } } }
  421. })
  422. await initializeIssueStartDate(42, '2026-08-28')
  423. assert.equal(requests.length, 2)
  424. assert.match(requests[0].query, /isIssueField/)
  425. assert.doesNotMatch(requests[0].query, /issueField\s*\{/)
  426. assert.match(requests[0].query, /priorityValue: fieldValueByName/)
  427. assert.equal(requests[0].variables.priorityField, 'Priority')
  428. assert.match(requests[0].query, /ProjectV2ItemFieldDateValue/)
  429. assert.match(requests[1].query, /updateProjectV2ItemFieldValue/)
  430. assert.match(requests[1].query, /value: \{date: \$date\}/)
  431. assert.deepEqual(requests[1].variables, {
  432. projectId: 'project-id',
  433. itemId: 'item-id',
  434. fieldId: 'start-date-field-id',
  435. date: '2026-08-28',
  436. })
  437. })
  438. test('preserves an existing Project Start Date', async (t) => {
  439. const requests = mockGraphql(t, () => projectGraphqlData({ startDate: '2026-08-01' }))
  440. await initializeIssueStartDate(42, '2026-08-28')
  441. assert.equal(requests.length, 1)
  442. })
  443. test('adds a referenced Issue to the Project before setting Start Date', async (t) => {
  444. const requests = mockGraphql(t, (request) => {
  445. if (request.query.includes('query(')) return projectGraphqlData({ projectItem: false })
  446. if (request.query.includes('addProjectV2ItemById')) {
  447. return { addProjectV2ItemById: { item: { id: 'new-item-id' } } }
  448. }
  449. return { updateProjectV2ItemFieldValue: { projectV2Item: { id: 'new-item-id' } } }
  450. })
  451. await initializeIssueStartDate(42, '2026-08-28')
  452. assert.equal(requests.length, 3)
  453. assert.deepEqual(requests[1].variables, { projectId: 'project-id', contentId: 'issue-id' })
  454. assert.deepEqual(requests[2].variables, {
  455. projectId: 'project-id',
  456. itemId: 'new-item-id',
  457. fieldId: 'start-date-field-id',
  458. date: '2026-08-28',
  459. })
  460. })
  461. test('rejects a missing, non-Date, or Issue-level Start Date field', async (t) => {
  462. let response = projectGraphqlData({ startDateField: false })
  463. const requests = mockGraphql(t, () => response)
  464. await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Start Date 字段/)
  465. response = projectGraphqlData({ startDateType: 'TEXT' })
  466. await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Start Date 字段必须为 Date/)
  467. response = projectGraphqlData({ startDateIsIssueField: true })
  468. await assert.rejects(
  469. initializeIssueStartDate(42, '2026-08-28'),
  470. /Start Date 字段必须为 Project Date 字段/,
  471. )
  472. assert.equal(requests.length, 3)
  473. })
  474. test('rejects a missing, non-select, or Issue-level Priority field', async (t) => {
  475. let response = projectGraphqlData({ priorityField: false })
  476. const requests = mockGraphql(t, () => response)
  477. await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Priority 字段/)
  478. response = projectGraphqlData({ priorityType: 'TEXT' })
  479. await assert.rejects(
  480. initializeIssueStartDate(42, '2026-08-28'),
  481. /Priority 字段必须为 Single Select/,
  482. )
  483. response = projectGraphqlData({ priorityIsIssueField: true })
  484. await assert.rejects(
  485. initializeIssueStartDate(42, '2026-08-28'),
  486. /Priority 字段必须为 Project custom field/,
  487. )
  488. assert.equal(requests.length, 3)
  489. })
  490. test('does not treat pull request references as Issue associations', () => {
  491. const references = {
  492. all: [123, 1180, 1181],
  493. resolving: [123, 1180],
  494. related: [1181],
  495. }
  496. const issues = new Map([
  497. [1180, {}],
  498. [1181, {}],
  499. ])
  500. assert.deepEqual(retainIssueReferences(references, issues), {
  501. all: [1180, 1181],
  502. resolving: [1180],
  503. related: [1181],
  504. })
  505. })
  506. test('allows informational references without cross-object constraints', () => {
  507. const errors = validatePullRequest({
  508. isDraft: false,
  509. authorType: 'User',
  510. reviewRequestCount: 1,
  511. reviewCount: 0,
  512. labels: ['kind/cleanup', 'area/infra'],
  513. references: { all: [4], resolving: [], related: [4] },
  514. issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]),
  515. })
  516. assert.deepEqual(errors, [])
  517. })
  518. test('enforces highest resolving Priority without Type or area synchronization', () => {
  519. const pull = {
  520. isDraft: false,
  521. authorType: 'User',
  522. reviewRequestCount: 0,
  523. reviewCount: 1,
  524. labels: ['kind/cleanup', 'p0', 'area/web'],
  525. references: { all: [2, 3], resolving: [2, 3], related: [] },
  526. issues: new Map([
  527. [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }],
  528. [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }],
  529. ]),
  530. }
  531. assert.deepEqual(validatePullRequest(pull), [])
  532. assert.ok(
  533. validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes(
  534. 'PR Priority 应为 p0',
  535. ),
  536. )
  537. })
  538. test('requires policy only after a human PR enters review', () => {
  539. assert.equal(
  540. requiresPullRequestPolicy({
  541. isDraft: false,
  542. authorType: 'User',
  543. reviewRequestCount: 1,
  544. reviewCount: 0,
  545. }),
  546. true,
  547. )
  548. assert.equal(
  549. requiresPullRequestPolicy({
  550. isDraft: false,
  551. authorType: 'User',
  552. reviewRequestCount: 0,
  553. reviewCount: 0,
  554. }),
  555. false,
  556. )
  557. })
  558. test('maps only explicit review handoffs to review status commands', () => {
  559. assert.equal(
  560. resolvingIssueStatusCommand('pull_request', {
  561. action: 'review_requested',
  562. }),
  563. 'review-requested',
  564. )
  565. assert.equal(
  566. resolvingIssueStatusCommand('pull_request_review', {
  567. action: 'submitted',
  568. review: { state: 'changes_requested' },
  569. }),
  570. 'changes-requested',
  571. )
  572. for (const state of ['approved', 'commented']) {
  573. assert.equal(
  574. resolvingIssueStatusCommand('pull_request_review', {
  575. action: 'submitted',
  576. review: { state },
  577. }),
  578. null,
  579. )
  580. }
  581. assert.equal(
  582. resolvingIssueStatusCommand('pull_request_review', {
  583. action: 'dismissed',
  584. review: { state: 'changes_requested' },
  585. }),
  586. null,
  587. )
  588. })
  589. test('keeps ordinary pull request events as forward-only implementation signals', () => {
  590. for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
  591. assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
  592. }
  593. assert.equal(
  594. resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
  595. null,
  596. )
  597. })
  598. test('toggles automation-owned work on request changes and repeated review request', () => {
  599. for (const status of ['Inbox', 'Backlog', 'Ready']) {
  600. assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress')
  601. assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review')
  602. assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress')
  603. }
  604. let status = nextResolvingIssueStatus(
  605. 'In review',
  606. 'changes-requested',
  607. 'dsh-issue-management',
  608. )
  609. assert.equal(status, 'In progress')
  610. status = nextResolvingIssueStatus(status, 'review-requested')
  611. assert.equal(status, 'In review')
  612. })
  613. test('preserves human review status and terminal Issues', () => {
  614. assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null)
  615. assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null)
  616. assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null)
  617. assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null)
  618. assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null)
  619. assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null)
  620. assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null)
  621. assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null)
  622. })
  623. test('keeps lifecycle projection independent of PR metadata enforcement', () => {
  624. const pull = {
  625. isDraft: false,
  626. authorType: 'User',
  627. reviewRequestCount: 1,
  628. reviewCount: 0,
  629. labels: [],
  630. references: { all: [2], resolving: [2], related: [] },
  631. issues: new Map([[2, { priority: null }]]),
  632. }
  633. assert.ok(validatePullRequest(pull).length > 0)
  634. assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review')
  635. })
  636. test('exempts Draft, Bot, and App PRs', () => {
  637. const invalid = {
  638. isDraft: false,
  639. labels: [],
  640. references: { all: [], resolving: [], related: [] },
  641. issues: new Map(),
  642. reviewRequestCount: 1,
  643. reviewCount: 0,
  644. }
  645. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), [])
  646. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), [])
  647. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), [])
  648. assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0)
  649. })
  650. test('requires repository PR labels in the enforcement scope', () => {
  651. const errors = validatePullRequest({
  652. isDraft: false,
  653. authorType: 'User',
  654. reviewRequestCount: 1,
  655. reviewCount: 0,
  656. labels: [],
  657. references: { all: [2], resolving: [], related: [2] },
  658. issues: new Map([[2, { priority: null }]]),
  659. })
  660. assert.ok(errors.includes('PR 必须恰好有一个允许的 kind/*,当前为 0'))
  661. assert.ok(errors.includes('PR 必须至少有一个 area/*'))
  662. })
  663. test('accepts exactly the canonical kinds with extensible areas', () => {
  664. for (const kind of canonicalKinds) {
  665. assert.deepEqual(validatePullRequest(reviewedPull([kind, 'area/future-domain'])), [], kind)
  666. }
  667. })
  668. test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => {
  669. assert.ok(
  670. validatePullRequest(
  671. reviewedPull(['kind/feature', 'kind/doc', 'area/web']),
  672. ).includes('PR 必须恰好有一个允许的 kind/*,当前为 2'),
  673. )
  674. assert.ok(
  675. validatePullRequest(reviewedPull(['kind/experimental', 'area/web'])).includes(
  676. 'PR 含不支持的 kind/*:kind/experimental',
  677. ),
  678. )
  679. for (const label of legacyLabels) {
  680. assert.ok(
  681. validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) =>
  682. error.startsWith('PR 含旧版标签:'),
  683. ),
  684. label,
  685. )
  686. }
  687. assert.ok(
  688. validatePullRequest(
  689. reviewedPull(['kind/feature', 'area/web', 'source/internal-pr']),
  690. ).includes('source/* 仅用于 Issue:source/internal-pr'),
  691. )
  692. })
  693. test('allows missing Priority only when resolving Issues are also unprioritized', () => {
  694. const pull = {
  695. isDraft: false,
  696. authorType: 'User',
  697. reviewRequestCount: 1,
  698. reviewCount: 0,
  699. labels: ['kind/feature', 'area/web'],
  700. references: { all: [2], resolving: [2], related: [] },
  701. issues: new Map([[2, { priority: null }]]),
  702. }
  703. assert.deepEqual(validatePullRequest(pull), [])
  704. assert.ok(
  705. validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes(
  706. 'PR Priority 应为 p2',
  707. ),
  708. )
  709. assert.ok(
  710. validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes(
  711. '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority',
  712. ),
  713. )
  714. })