policy.mjs 26 KB

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