policy.mjs 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760
  1. #!/usr/bin/env node
  2. import fs from 'node:fs'
  3. import process from 'node:process'
  4. import { pathToFileURL } from 'node:url'
  5. import config from './config.json' with { type: 'json' }
  6. const API_VERSION = '2026-03-10'
  7. const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
  8. const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task'])
  9. const PRIORITIES = ['p0', 'p1', 'p2', 'p3']
  10. const PR_KINDS = new Set([
  11. 'kind/feature',
  12. 'kind/bug-fix',
  13. 'kind/doc',
  14. 'kind/testing',
  15. 'kind/cleanup',
  16. 'kind/dependency',
  17. ])
  18. // Retired label aliases stay reserved so they cannot be recreated.
  19. const LEGACY_LABELS = new Set([
  20. 'kind/bug',
  21. 'kind/documentation',
  22. 'feature',
  23. 'bug-fix',
  24. 'doc',
  25. 'cleanup',
  26. 'testing',
  27. 'dependencies',
  28. 'ci',
  29. 'cli',
  30. 'llm',
  31. 'web-search',
  32. ])
  33. const TERMINAL_STATUSES = new Set(['Done', 'No action'])
  34. const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
  35. const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([
  36. 'opened',
  37. 'edited',
  38. 'synchronize',
  39. 'reopened',
  40. 'labeled',
  41. 'unlabeled',
  42. ])
  43. for (const status of ['In progress', 'In review']) {
  44. if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`)
  45. }
  46. if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
  47. throw new Error('config.lifecycleActor 未设置')
  48. }
  49. if (typeof config.priorityField !== 'string' || !config.priorityField) {
  50. throw new Error('config.priorityField 未设置')
  51. }
  52. if (typeof config.startDateField !== 'string' || !config.startDateField) {
  53. throw new Error('config.startDateField 未设置')
  54. }
  55. if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) {
  56. throw new Error('config.projectTimeZone 未设置')
  57. }
  58. Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone })
  59. /**
  60. * Decide whether the human-review policy applies to a PR.
  61. * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state.
  62. * @returns {boolean} Whether the PR policy is mandatory.
  63. */
  64. export function requiresPullRequestPolicy({
  65. isDraft,
  66. authorType,
  67. reviewRequestCount,
  68. reviewCount,
  69. }) {
  70. const automated = authorType === 'Bot' || authorType === 'App'
  71. return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0)
  72. }
  73. /**
  74. * Translate a repository event into one resolving-Issue lifecycle command.
  75. * @param {string} eventName GitHub event name.
  76. * @param {{action?: string, review?: {state?: string}}} event GitHub event payload.
  77. * @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command.
  78. */
  79. export function resolvingIssueStatusCommand(eventName, event) {
  80. if (eventName === 'pull_request') {
  81. if (event.action === 'review_requested') return 'review-requested'
  82. return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null
  83. }
  84. if (
  85. eventName === 'pull_request_review' &&
  86. event.action === 'submitted' &&
  87. event.review?.state?.toLowerCase() === 'changes_requested'
  88. ) {
  89. return 'changes-requested'
  90. }
  91. return null
  92. }
  93. /**
  94. * Plan one event-directed resolving-Issue status transition.
  95. * @param {string|null} currentStatus Current Project status.
  96. * @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command.
  97. * @param {string|null} currentStatusActor Actor that last set the current Project status.
  98. * @returns {string|null} Status to write, or null when no permitted transition exists.
  99. */
  100. export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) {
  101. let target
  102. if (command === 'review-requested') target = 'In review'
  103. else if (command === 'implementation' || command === 'changes-requested') target = 'In progress'
  104. else throw new Error(`未知 lifecycle command:${command}`)
  105. const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus)
  106. const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target)
  107. if (
  108. command === 'changes-requested' &&
  109. currentStatus === 'In review' &&
  110. currentStatusActor === config.lifecycleActor
  111. ) {
  112. return target
  113. }
  114. return currentIndex >= 0 && currentIndex < targetIndex ? target : null
  115. }
  116. /**
  117. * Convert a GitHub timestamp to a Project date in one configured time zone.
  118. * @param {string} timestamp ISO timestamp.
  119. * @param {string} timeZone IANA time-zone name.
  120. * @returns {string} Calendar date in YYYY-MM-DD form.
  121. */
  122. export function projectDate(timestamp, timeZone = config.projectTimeZone) {
  123. const instant = new Date(timestamp)
  124. if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`)
  125. const parts = Object.fromEntries(
  126. new Intl.DateTimeFormat('en-US', {
  127. timeZone,
  128. year: 'numeric',
  129. month: '2-digit',
  130. day: '2-digit',
  131. })
  132. .formatToParts(instant)
  133. .filter((part) => part.type !== 'literal')
  134. .map((part) => [part.type, part.value]),
  135. )
  136. return `${parts.year}-${parts.month}-${parts.day}`
  137. }
  138. function stripIgnoredMarkdown(body) {
  139. const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
  140. const kept = []
  141. let fence = null
  142. for (const line of lines) {
  143. const marker = line.match(/^\s*([\u0060~]{3,})/)
  144. if (marker) {
  145. if (fence === null) fence = marker[1][0]
  146. else if (marker[1][0] === fence) fence = null
  147. continue
  148. }
  149. if (fence === null) kept.push(line)
  150. }
  151. return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ')
  152. }
  153. /**
  154. * Parse same-repository resolving and informational references.
  155. * @param {{body: string, repository: string}} input PR body and repository.
  156. * @returns {{all: number[], resolving: number[], related: number[]}} References.
  157. */
  158. export function parseReferences({ body, repository }) {
  159. const source = stripIgnoredMarkdown(body)
  160. const expected = repository.toLowerCase()
  161. const all = new Set()
  162. const resolving = new Set()
  163. const reference =
  164. /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi
  165. const closing =
  166. /\b(?:close(?:s|d)?|fix(?:es|ed)?|resolve(?:s|d)?)\s*:?\s+(?:(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+))/gi
  167. for (const match of source.matchAll(reference)) {
  168. const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
  169. const number = Number(match[2] ?? match[4])
  170. if (!explicit || explicit === expected) all.add(number)
  171. }
  172. for (const match of source.matchAll(closing)) {
  173. const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
  174. const number = Number(match[2] ?? match[4])
  175. if (!explicit || explicit === expected) {
  176. all.add(number)
  177. resolving.add(number)
  178. }
  179. }
  180. return {
  181. all: [...all].sort((left, right) => left - right),
  182. resolving: [...resolving].sort((left, right) => left - right),
  183. related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b),
  184. }
  185. }
  186. /**
  187. * Retain only references that resolve to Issues rather than pull requests.
  188. * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
  189. * @param {Map<number, unknown>} issues Resolved same-repository Issues.
  190. * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
  191. */
  192. export function retainIssueReferences(references, issues) {
  193. return {
  194. all: references.all.filter((number) => issues.has(number)),
  195. resolving: references.resolving.filter((number) => issues.has(number)),
  196. related: references.related.filter((number) => issues.has(number)),
  197. }
  198. }
  199. /**
  200. * Validate one Issue with its Project status.
  201. * @param {{labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot.
  202. * @returns {string[]} Validation errors.
  203. */
  204. export function validateIssue(issue) {
  205. const errors = []
  206. const status = issue.status
  207. const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
  208. if (invalidLabels.length > 0) {
  209. errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
  210. }
  211. if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一')
  212. if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status')
  213. if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) {
  214. errors.push('Priority 必须为空或为 P0–P3')
  215. }
  216. if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) {
  217. errors.push('Done 必须对应 Completed 关闭原因')
  218. }
  219. if (
  220. status === 'No action' &&
  221. (issue.state !== 'closed' || issue.stateReason !== 'not_planned')
  222. ) {
  223. errors.push('No action 必须对应 Not planned 关闭原因')
  224. }
  225. if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') {
  226. errors.push(`${status} 必须对应开放 Issue`)
  227. }
  228. return errors
  229. }
  230. function isInvalidIssueLabel(label) {
  231. return label.startsWith('kind/') || LEGACY_LABELS.has(label)
  232. }
  233. /**
  234. * Validate PR metadata and its referenced Issues.
  235. * @param {{authorType: string, labels: string[], references: ReturnType<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} input PR snapshot.
  236. * @returns {string[]} Validation errors.
  237. */
  238. export function validatePullRequest(input) {
  239. if (!requiresPullRequestPolicy(input)) return []
  240. const errors = []
  241. const kinds = input.labels.filter((label) => PR_KINDS.has(label))
  242. const unknownKinds = input.labels.filter(
  243. (label) => label.startsWith('kind/') && !PR_KINDS.has(label) && !LEGACY_LABELS.has(label),
  244. )
  245. const legacyLabels = input.labels.filter((label) => LEGACY_LABELS.has(label))
  246. const sourceLabels = input.labels.filter((label) => label.startsWith('source/'))
  247. const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
  248. const areas = input.labels.filter((label) => label.startsWith('area/'))
  249. if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue')
  250. if (kinds.length !== 1) {
  251. errors.push(`PR 必须恰好有一个允许的 kind/*,当前为 ${kinds.length}`)
  252. }
  253. if (unknownKinds.length > 0) {
  254. errors.push(`PR 含不支持的 kind/*:${unknownKinds.join(', ')}`)
  255. }
  256. if (legacyLabels.length > 0) errors.push(`PR 含旧版标签:${legacyLabels.join(', ')}`)
  257. if (sourceLabels.length > 0) errors.push(`source/* 仅用于 Issue:${sourceLabels.join(', ')}`)
  258. if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`)
  259. if (areas.length === 0) errors.push('PR 必须至少有一个 area/*')
  260. for (const number of input.references.all) {
  261. if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`)
  262. }
  263. const resolving = input.references.resolving
  264. .map((number) => [number, input.issues.get(number)])
  265. .filter((entry) => entry[1])
  266. if (resolving.length === 0) return errors
  267. const issuePriorities = resolving
  268. .map(([, issue]) => issue.priority?.toLowerCase())
  269. .filter((priority) => PRIORITIES.includes(priority))
  270. if (priorities.length === 0 && issuePriorities.length > 0) {
  271. const highest = issuePriorities.sort(
  272. (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
  273. )[0]
  274. errors.push(`PR Priority 应为 ${highest}`)
  275. } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) {
  276. errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority')
  277. } else if (priorities.length === 1) {
  278. const highest = issuePriorities.sort(
  279. (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
  280. )[0]
  281. if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`)
  282. }
  283. return errors
  284. }
  285. function token() {
  286. const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
  287. if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置')
  288. return value
  289. }
  290. function projectToken() {
  291. return process.env.PROJECT_TOKEN || token()
  292. }
  293. async function api(path, options = {}) {
  294. const { allow404 = false, ...requestOptions } = options
  295. const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
  296. ...requestOptions,
  297. headers: {
  298. Accept: 'application/vnd.github+json',
  299. Authorization: `Bearer ${token()}`,
  300. 'X-GitHub-Api-Version': API_VERSION,
  301. 'User-Agent': 'dsh-issue-policy',
  302. ...options.headers,
  303. },
  304. })
  305. if (allow404 && response.status === 404) return null
  306. if (!response.ok) {
  307. const body = await response.text()
  308. throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`)
  309. }
  310. if (response.status === 204) return null
  311. return response.json()
  312. }
  313. async function graphql(query, variables) {
  314. const result = await api('/graphql', {
  315. method: 'POST',
  316. body: JSON.stringify({ query, variables }),
  317. headers: {
  318. Authorization: `Bearer ${projectToken()}`,
  319. 'Content-Type': 'application/json',
  320. },
  321. })
  322. if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
  323. return result.data
  324. }
  325. /**
  326. * Read one Issue together with its Project planning values.
  327. * @param {number} number Same-repository Issue number.
  328. * @param {string|null|undefined} status Optional known Project status.
  329. * @returns {Promise<object|null>} Issue snapshot, or null when the number identifies a pull request.
  330. */
  331. export async function issueSnapshot(number, status = undefined) {
  332. const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
  333. if (issue.pull_request) return null
  334. const context = await projectContext(number)
  335. return {
  336. number,
  337. nodeId: issue.node_id,
  338. labels: issue.labels.map((label) => label.name),
  339. type: issue.type?.name ?? null,
  340. priority: context.item?.priorityValue?.name ?? null,
  341. status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status,
  342. state: issue.state,
  343. stateReason: issue.state_reason ?? null,
  344. }
  345. }
  346. async function projectContext(number, includeStatusActor = false, includeStartDate = false) {
  347. const data = await graphql(
  348. `query(
  349. $organization: String!
  350. $repository: String!
  351. $number: Int!
  352. $project: Int!
  353. $includeStatusActor: Boolean!
  354. $includeStartDate: Boolean!
  355. $priorityField: String!
  356. $startDateField: String!
  357. ) {
  358. organization(login: $organization) {
  359. projectV2(number: $project) {
  360. id
  361. title
  362. fields(first: 50) {
  363. nodes {
  364. ... on ProjectV2Field {
  365. id
  366. name
  367. dataType
  368. isIssueField
  369. }
  370. ... on ProjectV2SingleSelectField {
  371. id
  372. name
  373. dataType
  374. isIssueField
  375. options { id name }
  376. }
  377. }
  378. }
  379. }
  380. }
  381. repository(owner: $organization, name: $repository) {
  382. issue(number: $number) {
  383. id
  384. timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT])
  385. @include(if: $includeStatusActor) {
  386. nodes {
  387. ... on ProjectV2ItemStatusChangedEvent {
  388. actor { login }
  389. project { id }
  390. status
  391. }
  392. }
  393. }
  394. projectItems(first: 20, includeArchived: true) {
  395. nodes {
  396. id
  397. project { id }
  398. fieldValueByName(name: "Status") {
  399. ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
  400. }
  401. priorityValue: fieldValueByName(name: $priorityField) {
  402. ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
  403. }
  404. startDateValue: fieldValueByName(name: $startDateField)
  405. @include(if: $includeStartDate) {
  406. ... on ProjectV2ItemFieldDateValue { date }
  407. }
  408. }
  409. }
  410. }
  411. }
  412. }`,
  413. {
  414. organization: config.organization,
  415. repository: config.repository,
  416. number,
  417. project: config.projectNumber,
  418. includeStatusActor,
  419. includeStartDate,
  420. priorityField: config.priorityField,
  421. startDateField: config.startDateField,
  422. },
  423. )
  424. const project = data.organization?.projectV2
  425. const issue = data.repository?.issue
  426. if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配')
  427. if (!issue) throw new Error(`#${number} 不存在`)
  428. const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
  429. if (!statusField) throw new Error('Project 缺少 Status 字段')
  430. const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField)
  431. if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`)
  432. if (priorityField.dataType !== 'SINGLE_SELECT') {
  433. throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`)
  434. }
  435. if (priorityField.isIssueField) {
  436. throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`)
  437. }
  438. const startDateField = includeStartDate
  439. ? project.fields.nodes.find((field) => field?.name === config.startDateField)
  440. : null
  441. if (includeStartDate && !startDateField) {
  442. throw new Error(`Project 缺少 ${config.startDateField} 字段`)
  443. }
  444. if (startDateField && startDateField.dataType !== 'DATE') {
  445. throw new Error(`Project ${config.startDateField} 字段必须为 Date`)
  446. }
  447. if (startDateField?.isIssueField) {
  448. throw new Error(`Project ${config.startDateField} 字段必须为 Project Date 字段`)
  449. }
  450. const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
  451. const latestStatusEvent = issue.timelineItems?.nodes
  452. ?.filter((event) => event?.project?.id === project.id)
  453. .at(-1)
  454. const statusActor =
  455. latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name
  456. ? (latestStatusEvent.actor?.login ?? null)
  457. : null
  458. return { project, issue, statusField, priorityField, startDateField, item, statusActor }
  459. }
  460. async function ensureProjectItem(number, includeStartDate = false) {
  461. const context = await projectContext(number, false, includeStartDate)
  462. if (context.item) return context
  463. const data = await graphql(
  464. `mutation($projectId: ID!, $contentId: ID!) {
  465. addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
  466. item { id }
  467. }
  468. }`,
  469. { projectId: context.project.id, contentId: context.issue.id },
  470. )
  471. return {
  472. ...context,
  473. item: {
  474. id: data.addProjectV2ItemById.item.id,
  475. fieldValueByName: null,
  476. priorityValue: null,
  477. startDateValue: null,
  478. },
  479. }
  480. }
  481. /**
  482. * Initialize one Issue's Project Start Date when it is empty.
  483. * @param {number} number Same-repository Issue number.
  484. * @param {string} date Date in YYYY-MM-DD form.
  485. * @returns {Promise<void>} Resolves after the conditional Project update.
  486. */
  487. export async function initializeIssueStartDate(number, date) {
  488. const context = await ensureProjectItem(number, true)
  489. if (context.item.startDateValue?.date) return
  490. await graphql(
  491. `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
  492. updateProjectV2ItemFieldValue(input: {
  493. projectId: $projectId,
  494. itemId: $itemId,
  495. fieldId: $fieldId,
  496. value: {date: $date}
  497. }) { projectV2Item { id } }
  498. }`,
  499. {
  500. projectId: context.project.id,
  501. itemId: context.item.id,
  502. fieldId: context.startDateField.id,
  503. date,
  504. },
  505. )
  506. }
  507. /**
  508. * Initialize every referenced Issue from a newly opened PR.
  509. * @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot.
  510. * @param {string} action Pull-request event action.
  511. * @param {(number: number, date: string) => Promise<void>} initialize Date writer.
  512. * @returns {Promise<void>} Resolves after all eligible Issues are processed.
  513. */
  514. export async function initializePullRequestStartDates(
  515. pull,
  516. action,
  517. initialize = initializeIssueStartDate,
  518. ) {
  519. if (action !== 'opened') return
  520. const date = projectDate(pull.createdAt)
  521. for (const number of pull.references.all) await initialize(number, date)
  522. }
  523. async function updateStatus(context, status) {
  524. const option = context.statusField.options.find((candidate) => candidate.name === status)
  525. if (!option) throw new Error(`Status 不存在:${status}`)
  526. if (context.item.fieldValueByName?.name === status) return
  527. await graphql(
  528. `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
  529. updateProjectV2ItemFieldValue(input: {
  530. projectId: $projectId,
  531. itemId: $itemId,
  532. fieldId: $fieldId,
  533. value: {singleSelectOptionId: $optionId}
  534. }) { projectV2Item { id } }
  535. }`,
  536. {
  537. projectId: context.project.id,
  538. itemId: context.item.id,
  539. fieldId: context.statusField.id,
  540. optionId: option.id,
  541. },
  542. )
  543. }
  544. async function setStatus(number, status) {
  545. await updateStatus(await ensureProjectItem(number), status)
  546. }
  547. /**
  548. * Remove pull-request kinds and retired aliases from one Issue snapshot.
  549. * @param {{number: number, labels: string[]}} issue Issue snapshot.
  550. * @returns {Promise<object>} Snapshot containing only labels that remain on the Issue.
  551. */
  552. export async function repairIssueLabels(issue) {
  553. const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
  554. for (const label of invalidLabels) {
  555. await api(
  556. `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`,
  557. { method: 'DELETE', allow404: true },
  558. )
  559. }
  560. return {
  561. ...issue,
  562. labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)),
  563. }
  564. }
  565. async function upsertAudit(number, errors) {
  566. const comments = await api(
  567. `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
  568. )
  569. const existing = comments.find(
  570. (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER),
  571. )
  572. if (errors.length === 0) {
  573. if (existing) {
  574. await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
  575. method: 'DELETE',
  576. })
  577. }
  578. return
  579. }
  580. const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}`
  581. if (existing) {
  582. if (existing.body === body) return
  583. await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
  584. method: 'PATCH',
  585. body: JSON.stringify({ body }),
  586. headers: { 'Content-Type': 'application/json' },
  587. })
  588. } else {
  589. await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, {
  590. method: 'POST',
  591. body: JSON.stringify({ body }),
  592. headers: { 'Content-Type': 'application/json' },
  593. })
  594. }
  595. }
  596. /**
  597. * Repair deterministic Issue metadata violations and publish the remaining audit result.
  598. * @param {number} number Same-repository Issue number.
  599. * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation.
  600. * @param {string|null|undefined} status Optional known Project status.
  601. * @returns {Promise<string[]>} Violations that remain after repair.
  602. */
  603. export async function auditIssue(number, extraErrors = [], status = undefined) {
  604. const issue = await issueSnapshot(number, status)
  605. if (!issue) return []
  606. const repairedIssue = await repairIssueLabels(issue)
  607. const errors = [...extraErrors, ...validateIssue(repairedIssue)]
  608. await upsertAudit(number, errors)
  609. return errors
  610. }
  611. async function resolvingReferencesSnapshot(number, pull) {
  612. const references = parseReferences({
  613. body: pull.body ?? '',
  614. repository: `${config.organization}/${config.repository}`,
  615. })
  616. const issues = new Map()
  617. for (const issueNumber of references.all) {
  618. const issue = await issueSnapshot(issueNumber, null)
  619. if (issue) issues.set(issueNumber, issue)
  620. }
  621. return {
  622. number,
  623. references: retainIssueReferences(references, issues),
  624. issues,
  625. }
  626. }
  627. async function pullRequestSnapshot(number) {
  628. const [pull, reviewRequests, reviews] = await Promise.all([
  629. api(`/repos/${config.organization}/${config.repository}/pulls/${number}`),
  630. api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
  631. api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
  632. ])
  633. const resolving = await resolvingReferencesSnapshot(number, pull)
  634. return {
  635. ...resolving,
  636. isDraft: pull.draft,
  637. authorType: pull.user?.type ?? 'User',
  638. reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
  639. reviewCount: reviews.length,
  640. labels: pull.labels.map((label) => label.name),
  641. }
  642. }
  643. async function lifecyclePullRequestSnapshot(number) {
  644. const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
  645. return {
  646. ...(await resolvingReferencesSnapshot(number, pull)),
  647. createdAt: pull.created_at,
  648. }
  649. }
  650. async function transitionResolvingIssues(pull, command) {
  651. for (const number of pull.references.resolving) {
  652. const context = await projectContext(number, command === 'changes-requested')
  653. const target = nextResolvingIssueStatus(
  654. context.item?.fieldValueByName?.name ?? null,
  655. command,
  656. context.statusActor,
  657. )
  658. if (!target) continue
  659. // TODO: Replace this latest-state guard with per-Issue serialization or a
  660. // conditional ProjectV2 update; GraphQL currently has no compare-and-swap.
  661. await updateStatus(context, target)
  662. await auditIssue(number)
  663. }
  664. }
  665. async function runPullRequestCheck(event) {
  666. const pull = await pullRequestSnapshot(event.pull_request.number)
  667. const errors = validatePullRequest(pull)
  668. if (errors.length > 0) {
  669. for (const error of errors) process.stdout.write(`::error::${error}\n`)
  670. throw new Error(`Issue policy 未通过,共 ${errors.length} 项`)
  671. }
  672. process.stdout.write(
  673. requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n',
  674. )
  675. }
  676. async function runLifecycle(eventName, event) {
  677. if (eventName === 'issues') {
  678. const number = event.issue.number
  679. if (event.action === 'opened') await setStatus(number, 'Inbox')
  680. if (event.action === 'closed') {
  681. const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done'
  682. await setStatus(number, target)
  683. }
  684. if (event.action === 'reopened') {
  685. await setStatus(number, 'Inbox')
  686. }
  687. await ensureProjectItem(number)
  688. await auditIssue(number)
  689. return
  690. }
  691. if (eventName === 'pull_request' || eventName === 'pull_request_review') {
  692. const command = resolvingIssueStatusCommand(eventName, event)
  693. if (!command) return
  694. const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
  695. await transitionResolvingIssues(pull, command)
  696. if (eventName === 'pull_request') {
  697. await initializePullRequestStartDates(pull, event.action)
  698. }
  699. }
  700. }
  701. function readEvent() {
  702. if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置')
  703. return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
  704. }
  705. async function main(argv) {
  706. const [command] = argv
  707. if (command === 'pr') await runPullRequestCheck(readEvent())
  708. else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent())
  709. else throw new Error('用法:policy.mjs pr|lifecycle')
  710. }
  711. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  712. main(process.argv.slice(2)).catch((error) => {
  713. process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
  714. process.exitCode = 1
  715. })
  716. }