policy.test.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. import assert from 'node:assert/strict'
  2. import test from 'node:test'
  3. import {
  4. countVisibleUnits,
  5. nextResolvingIssueStatus,
  6. parseReferences,
  7. retainIssueReferences,
  8. resolvingIssueStatusCommand,
  9. requiresPullRequestPolicy,
  10. validateBody,
  11. validateIssue,
  12. validatePullRequest,
  13. } from './policy.mjs'
  14. const withDetails = (summary) =>
  15. `${summary}\n\n<details><summary>验收与细节</summary>待补充。</details>`
  16. const legalIssue = {
  17. title: '完成议题管理校验',
  18. body: withDetails('完成议题管理校验。'),
  19. assignees: [],
  20. labels: [],
  21. type: 'Idea',
  22. priority: null,
  23. status: 'In review',
  24. state: 'open',
  25. stateReason: null,
  26. }
  27. const canonicalKinds = [
  28. 'kind/feature',
  29. 'kind/bug-fix',
  30. 'kind/doc',
  31. 'kind/testing',
  32. 'kind/cleanup',
  33. 'kind/dependency',
  34. ]
  35. // Keep an independent oracle rather than importing the implementation's reserved set.
  36. const legacyLabels = [
  37. 'kind/bug',
  38. 'kind/documentation',
  39. 'feature',
  40. 'bug-fix',
  41. 'doc',
  42. 'cleanup',
  43. 'testing',
  44. 'dependencies',
  45. 'ci',
  46. 'cli',
  47. 'llm',
  48. 'web-search',
  49. ]
  50. const reviewedPull = (labels) => ({
  51. isDraft: false,
  52. authorType: 'User',
  53. reviewRequestCount: 1,
  54. reviewCount: 0,
  55. labels,
  56. references: { all: [2], resolving: [], related: [2] },
  57. issues: new Map([[2, { priority: null }]]),
  58. })
  59. test('counts only text outside details', () => {
  60. assert.deepEqual(countVisibleUnits('支持 GitHub Project。<details>隐藏文字</details>'), {
  61. units: 4,
  62. balanced: true,
  63. detailsCount: 1,
  64. allCollapsed: true,
  65. })
  66. })
  67. test('requires a balanced default-collapsed details region', () => {
  68. assert.deepEqual(validateBody({ body: '完成工作。', assignees: [] }), [
  69. '正文必须包含默认收起的 <details> 区域',
  70. ])
  71. assert.deepEqual(
  72. validateBody({
  73. body: '完成工作。\n\n<details open><summary>细节</summary>待补充。</details>',
  74. assignees: [],
  75. }),
  76. ['details 必须默认收起,不得设置 open'],
  77. )
  78. assert.deepEqual(
  79. validateBody({ body: '完成工作。\n\n<details><summary>细节</summary>', assignees: [] }),
  80. ['details 标签必须成对闭合'],
  81. )
  82. })
  83. test('requires Owner for multiple assignees', () => {
  84. assert.deepEqual(
  85. validateBody({
  86. body: withDetails('完成工作。'),
  87. assignees: ['tianyicui', 'tianyicui-bot'],
  88. }),
  89. ['多个 Assignees 时首个非空行必须是 Owner: @login'],
  90. )
  91. })
  92. test('accepts an intended Owner while assignment permission is pending', () => {
  93. assert.deepEqual(
  94. validateBody({
  95. body: withDetails('Owner: @octocat\n\n完成工作。'),
  96. assignees: [],
  97. }),
  98. [],
  99. )
  100. assert.deepEqual(
  101. validateBody({
  102. body: withDetails('Owner: @octocat\n\n完成工作。'),
  103. assignees: ['hubot'],
  104. }),
  105. ['零或一个 Assignee 时不得写 Owner 行'],
  106. )
  107. })
  108. test('allows optional metadata in every open Status', () => {
  109. assert.deepEqual(validateIssue(legalIssue), [])
  110. for (const status of ['Inbox', 'Backlog', 'Ready', 'In progress', 'In review']) {
  111. assert.deepEqual(validateIssue({ ...legalIssue, status }), [])
  112. }
  113. })
  114. test('rejects metadata prefixes in an Issue title', () => {
  115. const errors = validateIssue({ ...legalIssue, title: '[Bug] 修复恢复错误' })
  116. assert.ok(errors.includes('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀'))
  117. })
  118. test('reserves PR kind and legacy labels for pull requests', () => {
  119. for (const label of [
  120. ...canonicalKinds,
  121. 'kind/experimental',
  122. ...legacyLabels,
  123. ]) {
  124. assert.ok(
  125. validateIssue({ ...legalIssue, labels: [label] }).some((error) =>
  126. error.startsWith('Issue 不得使用 PR kind 或旧版标签:'),
  127. ),
  128. label,
  129. )
  130. }
  131. assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), [])
  132. })
  133. test('keeps terminal Status aligned with the native close reason', () => {
  134. assert.deepEqual(
  135. validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }),
  136. [],
  137. )
  138. assert.deepEqual(
  139. validateIssue({
  140. ...legalIssue,
  141. status: 'No action',
  142. state: 'closed',
  143. stateReason: 'not_planned',
  144. }),
  145. [],
  146. )
  147. assert.ok(validateIssue({ ...legalIssue, status: 'Done' }).includes('Done 必须对应 Completed 关闭原因'))
  148. })
  149. test('separates resolving and informational references', () => {
  150. assert.deepEqual(
  151. parseReferences({
  152. body: 'Fixes #12\nRelated to #4\nRefs deepseekharness/dsh-test#7',
  153. repository: 'deepseekharness/dsh-test',
  154. }),
  155. { all: [4, 7, 12], resolving: [12], related: [4, 7] },
  156. )
  157. })
  158. test('does not treat pull request references as Issue associations', () => {
  159. const references = {
  160. all: [123, 1180, 1181],
  161. resolving: [123, 1180],
  162. related: [1181],
  163. }
  164. const issues = new Map([
  165. [1180, {}],
  166. [1181, {}],
  167. ])
  168. assert.deepEqual(retainIssueReferences(references, issues), {
  169. all: [1180, 1181],
  170. resolving: [1180],
  171. related: [1181],
  172. })
  173. })
  174. test('allows informational references without cross-object constraints', () => {
  175. const errors = validatePullRequest({
  176. isDraft: false,
  177. authorType: 'User',
  178. reviewRequestCount: 1,
  179. reviewCount: 0,
  180. labels: ['kind/cleanup', 'area/infra'],
  181. references: { all: [4], resolving: [], related: [4] },
  182. issues: new Map([[4, { type: 'Bug', priority: 'P0', labels: ['area/web'] }]]),
  183. })
  184. assert.deepEqual(errors, [])
  185. })
  186. test('enforces highest resolving Priority without Type or area synchronization', () => {
  187. const pull = {
  188. isDraft: false,
  189. authorType: 'User',
  190. reviewRequestCount: 0,
  191. reviewCount: 1,
  192. labels: ['kind/cleanup', 'p0', 'area/web'],
  193. references: { all: [2, 3], resolving: [2, 3], related: [] },
  194. issues: new Map([
  195. [2, { type: 'Feature', priority: 'P2', labels: ['area/web'] }],
  196. [3, { type: 'Bug', priority: 'P0', labels: ['area/session'] }],
  197. ]),
  198. }
  199. assert.deepEqual(validatePullRequest(pull), [])
  200. assert.ok(
  201. validatePullRequest({ ...pull, labels: ['kind/cleanup', 'p2', 'area/web'] }).includes(
  202. 'PR Priority 应为 p0',
  203. ),
  204. )
  205. })
  206. test('requires policy only after a human PR enters review', () => {
  207. assert.equal(
  208. requiresPullRequestPolicy({
  209. isDraft: false,
  210. authorType: 'User',
  211. reviewRequestCount: 1,
  212. reviewCount: 0,
  213. }),
  214. true,
  215. )
  216. assert.equal(
  217. requiresPullRequestPolicy({
  218. isDraft: false,
  219. authorType: 'User',
  220. reviewRequestCount: 0,
  221. reviewCount: 0,
  222. }),
  223. false,
  224. )
  225. })
  226. test('maps only explicit review handoffs to review status commands', () => {
  227. assert.equal(
  228. resolvingIssueStatusCommand('pull_request', {
  229. action: 'review_requested',
  230. }),
  231. 'review-requested',
  232. )
  233. assert.equal(
  234. resolvingIssueStatusCommand('pull_request_review', {
  235. action: 'submitted',
  236. review: { state: 'changes_requested' },
  237. }),
  238. 'changes-requested',
  239. )
  240. for (const state of ['approved', 'commented']) {
  241. assert.equal(
  242. resolvingIssueStatusCommand('pull_request_review', {
  243. action: 'submitted',
  244. review: { state },
  245. }),
  246. null,
  247. )
  248. }
  249. assert.equal(
  250. resolvingIssueStatusCommand('pull_request_review', {
  251. action: 'dismissed',
  252. review: { state: 'changes_requested' },
  253. }),
  254. null,
  255. )
  256. })
  257. test('keeps ordinary pull request events as forward-only implementation signals', () => {
  258. for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) {
  259. assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation')
  260. }
  261. assert.equal(
  262. resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }),
  263. null,
  264. )
  265. })
  266. test('toggles automation-owned work on request changes and repeated review request', () => {
  267. for (const status of ['Inbox', 'Backlog', 'Ready']) {
  268. assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress')
  269. assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review')
  270. assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress')
  271. }
  272. let status = nextResolvingIssueStatus(
  273. 'In review',
  274. 'changes-requested',
  275. 'dsh-issue-management',
  276. )
  277. assert.equal(status, 'In progress')
  278. status = nextResolvingIssueStatus(status, 'review-requested')
  279. assert.equal(status, 'In review')
  280. })
  281. test('preserves human review status and terminal Issues', () => {
  282. assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null)
  283. assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null)
  284. assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null)
  285. assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null)
  286. assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null)
  287. assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null)
  288. assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null)
  289. assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null)
  290. })
  291. test('keeps lifecycle projection independent of PR metadata enforcement', () => {
  292. const pull = {
  293. isDraft: false,
  294. authorType: 'User',
  295. reviewRequestCount: 1,
  296. reviewCount: 0,
  297. labels: [],
  298. references: { all: [2], resolving: [2], related: [] },
  299. issues: new Map([[2, { priority: null }]]),
  300. }
  301. assert.ok(validatePullRequest(pull).length > 0)
  302. assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review')
  303. })
  304. test('exempts Draft, Bot, and App PRs', () => {
  305. const invalid = {
  306. isDraft: false,
  307. labels: [],
  308. references: { all: [], resolving: [], related: [] },
  309. issues: new Map(),
  310. reviewRequestCount: 1,
  311. reviewCount: 0,
  312. }
  313. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'Bot' }), [])
  314. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'App' }), [])
  315. assert.deepEqual(validatePullRequest({ ...invalid, authorType: 'User', isDraft: true }), [])
  316. assert.ok(validatePullRequest({ ...invalid, authorType: 'User' }).length > 0)
  317. })
  318. test('requires repository PR labels in the enforcement scope', () => {
  319. const errors = validatePullRequest({
  320. isDraft: false,
  321. authorType: 'User',
  322. reviewRequestCount: 1,
  323. reviewCount: 0,
  324. labels: [],
  325. references: { all: [2], resolving: [], related: [2] },
  326. issues: new Map([[2, { priority: null }]]),
  327. })
  328. assert.ok(errors.includes('PR 必须恰好有一个允许的 kind/*,当前为 0'))
  329. assert.ok(errors.includes('PR 必须至少有一个 area/*'))
  330. })
  331. test('accepts exactly the canonical kinds with extensible areas', () => {
  332. for (const kind of canonicalKinds) {
  333. assert.deepEqual(validatePullRequest(reviewedPull([kind, 'area/future-domain'])), [], kind)
  334. }
  335. })
  336. test('rejects multiple, unknown, legacy, and Issue-source PR labels', () => {
  337. assert.ok(
  338. validatePullRequest(
  339. reviewedPull(['kind/feature', 'kind/doc', 'area/web']),
  340. ).includes('PR 必须恰好有一个允许的 kind/*,当前为 2'),
  341. )
  342. assert.ok(
  343. validatePullRequest(reviewedPull(['kind/experimental', 'area/web'])).includes(
  344. 'PR 含不支持的 kind/*:kind/experimental',
  345. ),
  346. )
  347. for (const label of legacyLabels) {
  348. assert.ok(
  349. validatePullRequest(reviewedPull(['kind/feature', 'area/web', label])).some((error) =>
  350. error.startsWith('PR 含旧版标签:'),
  351. ),
  352. label,
  353. )
  354. }
  355. assert.ok(
  356. validatePullRequest(
  357. reviewedPull(['kind/feature', 'area/web', 'source/internal-pr']),
  358. ).includes('source/* 仅用于 Issue:source/internal-pr'),
  359. )
  360. })
  361. test('allows missing Priority only when resolving Issues are also unprioritized', () => {
  362. const pull = {
  363. isDraft: false,
  364. authorType: 'User',
  365. reviewRequestCount: 1,
  366. reviewCount: 0,
  367. labels: ['kind/feature', 'area/web'],
  368. references: { all: [2], resolving: [2], related: [] },
  369. issues: new Map([[2, { priority: null }]]),
  370. }
  371. assert.deepEqual(validatePullRequest(pull), [])
  372. assert.ok(
  373. validatePullRequest({ ...pull, issues: new Map([[2, { priority: 'P2' }]]) }).includes(
  374. 'PR Priority 应为 p2',
  375. ),
  376. )
  377. assert.ok(
  378. validatePullRequest({ ...pull, labels: [...pull.labels, 'p2'] }).includes(
  379. '有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority',
  380. ),
  381. )
  382. })