policy.mjs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559
  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. /**
  13. * Return Markdown outside balanced details elements.
  14. * @param {string} body Markdown body.
  15. * @returns {{text: string, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible source and details shape.
  16. */
  17. export function extractOutsideDetails(body) {
  18. const source = body.replace(/<!--[\s\S]*?-->/g, '')
  19. const tag = /<\/?details\b[^>]*>/gi
  20. let depth = 0
  21. let cursor = 0
  22. let balanced = true
  23. let text = ''
  24. let detailsCount = 0
  25. let allCollapsed = true
  26. for (const match of source.matchAll(tag)) {
  27. const index = match.index ?? 0
  28. if (depth === 0) text += source.slice(cursor, index)
  29. if (/^<\//.test(match[0])) {
  30. if (depth === 0) balanced = false
  31. else depth -= 1
  32. } else {
  33. depth += 1
  34. detailsCount += 1
  35. if (/\sopen(?:\s|=|>)/i.test(match[0])) allCollapsed = false
  36. }
  37. cursor = index + match[0].length
  38. }
  39. if (depth === 0) text += source.slice(cursor)
  40. if (depth !== 0) balanced = false
  41. return { text, balanced, detailsCount, allCollapsed }
  42. }
  43. /**
  44. * Count Chinese characters and contiguous Latin, numeric, or code tokens.
  45. * @param {string} body Markdown body.
  46. * @returns {{units: number, balanced: boolean, detailsCount: number, allCollapsed: boolean}} Visible unit count and details shape.
  47. */
  48. export function countVisibleUnits(body) {
  49. const outside = extractOutsideDetails(body)
  50. const visible = outside.text
  51. .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
  52. .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
  53. .replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1')
  54. .replace(/<((?:https?:\/\/|mailto:)[^>]+)>/gi, '$1')
  55. .replace(/<[^>]+>/g, ' ')
  56. .replace(/&(?:[A-Za-z]+|#\d+|#x[0-9A-Fa-f]+);/g, ' ')
  57. .replace(/[\u0060*~\[\]{}()<>#!|]/g, ' ')
  58. const han = visible.match(/\p{Script=Han}/gu)?.length ?? 0
  59. const tokens = visible.match(/[\p{Script=Latin}\p{Number}_./:@+-]+/gu)?.length ?? 0
  60. return {
  61. units: han + tokens,
  62. balanced: outside.balanced,
  63. detailsCount: outside.detailsCount,
  64. allCollapsed: outside.allCollapsed,
  65. }
  66. }
  67. function firstNonblankLine(body) {
  68. return body
  69. .split(/\r?\n/)
  70. .map((line) => line.trim())
  71. .find(Boolean)
  72. }
  73. /**
  74. * Validate body shape and Owner against assignees.
  75. * @param {{body: string, assignees: string[], allowUnassignedOwner?: boolean}} input Body input.
  76. * @returns {string[]} Validation errors.
  77. */
  78. export function validateBody({
  79. body,
  80. assignees,
  81. allowUnassignedOwner = config.allowUnassignedOwner ?? false,
  82. }) {
  83. const errors = []
  84. const count = countVisibleUnits(body)
  85. const owner = firstNonblankLine(body)?.match(OWNER_LINE)?.[1] ?? null
  86. const normalized = [...new Set(assignees.map((login) => login.toLowerCase()))]
  87. if (!count.balanced) errors.push('details 标签必须成对闭合')
  88. if (count.detailsCount === 0) errors.push('正文必须包含默认收起的 <details> 区域')
  89. if (!count.allCollapsed) errors.push('details 必须默认收起,不得设置 open')
  90. if (count.units > BODY_LIMIT) {
  91. errors.push(`正文外露部分为 ${count.units} 单位,超过 50 单位`)
  92. }
  93. if (normalized.length >= 2 && !owner) {
  94. errors.push('多个 Assignees 时首个非空行必须是 Owner: @login')
  95. } else if (normalized.length >= 2 && !normalized.includes(owner.toLowerCase())) {
  96. errors.push('Owner 必须属于 Assignees')
  97. } else if (
  98. normalized.length < 2 &&
  99. owner &&
  100. !(normalized.length === 0 && allowUnassignedOwner)
  101. ) {
  102. errors.push('零或一个 Assignee 时不得写 Owner 行')
  103. }
  104. return errors
  105. }
  106. /**
  107. * Decide whether a PR has entered the human-review enforcement boundary.
  108. * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state.
  109. * @returns {boolean} Whether the PR policy is mandatory.
  110. */
  111. export function requiresPullRequestPolicy({
  112. isDraft,
  113. authorType,
  114. reviewRequestCount,
  115. reviewCount,
  116. }) {
  117. const automated = authorType === 'Bot' || authorType === 'App'
  118. return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0)
  119. }
  120. function stripIgnoredMarkdown(body) {
  121. const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
  122. const kept = []
  123. let fence = null
  124. for (const line of lines) {
  125. const marker = line.match(/^\s*([\u0060~]{3,})/)
  126. if (marker) {
  127. if (fence === null) fence = marker[1][0]
  128. else if (marker[1][0] === fence) fence = null
  129. continue
  130. }
  131. if (fence === null) kept.push(line)
  132. }
  133. return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ')
  134. }
  135. /**
  136. * Parse same-repository resolving and informational references.
  137. * @param {{body: string, repository: string}} input PR body and repository.
  138. * @returns {{all: number[], resolving: number[], related: number[]}} References.
  139. */
  140. export function parseReferences({ body, repository }) {
  141. const source = stripIgnoredMarkdown(body)
  142. const expected = repository.toLowerCase()
  143. const all = new Set()
  144. const resolving = new Set()
  145. const reference =
  146. /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi
  147. const closing =
  148. /\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
  149. for (const match of source.matchAll(reference)) {
  150. const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
  151. const number = Number(match[2] ?? match[4])
  152. if (!explicit || explicit === expected) all.add(number)
  153. }
  154. for (const match of source.matchAll(closing)) {
  155. const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
  156. const number = Number(match[2] ?? match[4])
  157. if (!explicit || explicit === expected) {
  158. all.add(number)
  159. resolving.add(number)
  160. }
  161. }
  162. return {
  163. all: [...all].sort((left, right) => left - right),
  164. resolving: [...resolving].sort((left, right) => left - right),
  165. related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b),
  166. }
  167. }
  168. /**
  169. * Retain only references that resolve to Issues rather than pull requests.
  170. * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
  171. * @param {Map<number, unknown>} issues Resolved same-repository Issues.
  172. * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
  173. */
  174. export function retainIssueReferences(references, issues) {
  175. return {
  176. all: references.all.filter((number) => issues.has(number)),
  177. resolving: references.resolving.filter((number) => issues.has(number)),
  178. related: references.related.filter((number) => issues.has(number)),
  179. }
  180. }
  181. /**
  182. * Validate one Issue with its Project status.
  183. * @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.
  184. * @returns {string[]} Validation errors.
  185. */
  186. export function validateIssue(issue) {
  187. const errors = validateBody(issue)
  188. const status = issue.status
  189. if (!/\p{Script=Han}/u.test(issue.title)) errors.push('Issue 标题必须包含中文')
  190. if (
  191. /^\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(
  192. issue.title,
  193. )
  194. ) {
  195. errors.push('Issue 标题不得带 Type、Priority、Status、area 或 Owner 前缀')
  196. }
  197. if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一')
  198. if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status')
  199. if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) {
  200. errors.push('Priority 必须为空或为 P0–P3')
  201. }
  202. if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) {
  203. errors.push('Done 必须对应 Completed 关闭原因')
  204. }
  205. if (
  206. status === 'No action' &&
  207. (issue.state !== 'closed' || issue.stateReason !== 'not_planned')
  208. ) {
  209. errors.push('No action 必须对应 Not planned 关闭原因')
  210. }
  211. if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') {
  212. errors.push(`${status} 必须对应开放 Issue`)
  213. }
  214. return errors
  215. }
  216. /**
  217. * Validate PR metadata and its referenced Issues.
  218. * @param {{authorType: string, labels: string[], references: ReturnType<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} input PR snapshot.
  219. * @returns {string[]} Validation errors.
  220. */
  221. export function validatePullRequest(input) {
  222. if (!requiresPullRequestPolicy(input)) return []
  223. const errors = []
  224. const kinds = input.labels.filter((label) => label.startsWith('kind/'))
  225. const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
  226. const areas = input.labels.filter((label) => label.startsWith('area/'))
  227. if (input.references.all.length === 0) errors.push('PR 正文必须引用至少一个同仓库 Issue')
  228. if (kinds.length !== 1) errors.push(`PR 必须恰好有一个 kind/*,当前为 ${kinds.length}`)
  229. if (priorities.length > 1) errors.push(`PR 最多有一个 p0–p3,当前为 ${priorities.length}`)
  230. if (areas.length === 0) errors.push('PR 必须至少有一个 area/*')
  231. for (const number of input.references.all) {
  232. if (!input.issues.has(number)) errors.push(`#${number} 不是同仓库 Issue`)
  233. }
  234. const resolving = input.references.resolving
  235. .map((number) => [number, input.issues.get(number)])
  236. .filter((entry) => entry[1])
  237. if (resolving.length === 0) return errors
  238. const issuePriorities = resolving
  239. .map(([, issue]) => issue.priority?.toLowerCase())
  240. .filter((priority) => PRIORITIES.includes(priority))
  241. if (priorities.length === 0 && issuePriorities.length > 0) {
  242. const highest = issuePriorities.sort(
  243. (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
  244. )[0]
  245. errors.push(`PR Priority 应为 ${highest}`)
  246. } else if (priorities.length === 1 && issuePriorities.length !== resolving.length) {
  247. errors.push('有 Priority 的解决型 PR 要求每个被解决 Issue 都设置 Priority')
  248. } else if (priorities.length === 1) {
  249. const highest = issuePriorities.sort(
  250. (left, right) => PRIORITIES.indexOf(left) - PRIORITIES.indexOf(right),
  251. )[0]
  252. if (priorities[0] !== highest) errors.push(`PR Priority 应为 ${highest}`)
  253. }
  254. return errors
  255. }
  256. function token() {
  257. const value = process.env.GH_TOKEN || process.env.GITHUB_TOKEN
  258. if (!value) throw new Error('GH_TOKEN 或 GITHUB_TOKEN 未设置')
  259. return value
  260. }
  261. async function api(path, options = {}) {
  262. const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, {
  263. ...options,
  264. headers: {
  265. Accept: 'application/vnd.github+json',
  266. Authorization: `Bearer ${token()}`,
  267. 'X-GitHub-Api-Version': API_VERSION,
  268. 'User-Agent': 'dsh-issue-policy',
  269. ...options.headers,
  270. },
  271. })
  272. if (options.allow404 && response.status === 404) return null
  273. if (!response.ok) {
  274. const body = await response.text()
  275. throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`)
  276. }
  277. if (response.status === 204) return null
  278. return response.json()
  279. }
  280. async function graphql(query, variables) {
  281. const result = await api('/graphql', {
  282. method: 'POST',
  283. body: JSON.stringify({ query, variables }),
  284. headers: { 'Content-Type': 'application/json' },
  285. })
  286. if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; '))
  287. return result.data
  288. }
  289. async function issueSnapshot(number, status = undefined) {
  290. const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`)
  291. if (issue.pull_request) return null
  292. const values = await api(
  293. `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`,
  294. )
  295. const field = (name) => values.find((value) => value.issue_field_name === name)
  296. return {
  297. number,
  298. nodeId: issue.node_id,
  299. title: issue.title,
  300. body: issue.body ?? '',
  301. assignees: issue.assignees.map((assignee) => assignee.login),
  302. labels: issue.labels.map((label) => label.name),
  303. type: issue.type?.name ?? null,
  304. priority: field(config.priorityField)?.single_select_option?.name ?? null,
  305. status: status === undefined ? await projectStatus(number) : status,
  306. state: issue.state,
  307. stateReason: issue.state_reason ?? null,
  308. }
  309. }
  310. async function projectContext(number) {
  311. const data = await graphql(
  312. `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) {
  313. organization(login: $organization) {
  314. projectV2(number: $project) {
  315. id
  316. title
  317. fields(first: 50) {
  318. nodes {
  319. ... on ProjectV2SingleSelectField { id name options { id name } }
  320. }
  321. }
  322. }
  323. }
  324. repository(owner: $organization, name: $repository) {
  325. issue(number: $number) {
  326. id
  327. projectItems(first: 20, includeArchived: true) {
  328. nodes {
  329. id
  330. project { id }
  331. fieldValueByName(name: "Status") {
  332. ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
  333. }
  334. }
  335. }
  336. }
  337. }
  338. }`,
  339. {
  340. organization: config.organization,
  341. repository: config.repository,
  342. number,
  343. project: config.projectNumber,
  344. },
  345. )
  346. const project = data.organization?.projectV2
  347. const issue = data.repository?.issue
  348. if (!project || project.title !== config.projectTitle) throw new Error('目标 Project 不存在或标题不匹配')
  349. if (!issue) throw new Error(`#${number} 不存在`)
  350. const statusField = project.fields.nodes.find((field) => field?.name === 'Status')
  351. if (!statusField) throw new Error('Project 缺少 Status 字段')
  352. const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id)
  353. return { project, issue, statusField, item }
  354. }
  355. async function projectStatus(number) {
  356. const context = await projectContext(number)
  357. return context.item?.fieldValueByName?.name ?? null
  358. }
  359. async function ensureProjectItem(number) {
  360. const context = await projectContext(number)
  361. if (context.item) return context
  362. const data = await graphql(
  363. `mutation($projectId: ID!, $contentId: ID!) {
  364. addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
  365. item { id }
  366. }
  367. }`,
  368. { projectId: context.project.id, contentId: context.issue.id },
  369. )
  370. return {
  371. ...context,
  372. item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null },
  373. }
  374. }
  375. async function setStatus(number, status) {
  376. const context = await ensureProjectItem(number)
  377. const option = context.statusField.options.find((candidate) => candidate.name === status)
  378. if (!option) throw new Error(`Status 不存在:${status}`)
  379. if (context.item.fieldValueByName?.name === status) return
  380. await graphql(
  381. `mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
  382. updateProjectV2ItemFieldValue(input: {
  383. projectId: $projectId,
  384. itemId: $itemId,
  385. fieldId: $fieldId,
  386. value: {singleSelectOptionId: $optionId}
  387. }) { projectV2Item { id } }
  388. }`,
  389. {
  390. projectId: context.project.id,
  391. itemId: context.item.id,
  392. fieldId: context.statusField.id,
  393. optionId: option.id,
  394. },
  395. )
  396. }
  397. async function upsertAudit(number, errors) {
  398. const comments = await api(
  399. `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
  400. )
  401. const existing = comments.find(
  402. (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER),
  403. )
  404. if (errors.length === 0) {
  405. if (existing) {
  406. await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
  407. method: 'DELETE',
  408. })
  409. }
  410. return
  411. }
  412. const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}`
  413. if (existing) {
  414. if (existing.body === body) return
  415. await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
  416. method: 'PATCH',
  417. body: JSON.stringify({ body }),
  418. headers: { 'Content-Type': 'application/json' },
  419. })
  420. } else {
  421. await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, {
  422. method: 'POST',
  423. body: JSON.stringify({ body }),
  424. headers: { 'Content-Type': 'application/json' },
  425. })
  426. }
  427. }
  428. async function auditIssue(number, extraErrors = [], status = undefined) {
  429. const issue = await issueSnapshot(number, status)
  430. if (!issue) return []
  431. const errors = [...extraErrors, ...validateIssue(issue)]
  432. await upsertAudit(number, errors)
  433. return errors
  434. }
  435. async function pullRequestSnapshot(number) {
  436. const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
  437. const [reviewRequests, reviews] = await Promise.all([
  438. api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
  439. api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
  440. ])
  441. const references = parseReferences({
  442. body: pull.body ?? '',
  443. repository: `${config.organization}/${config.repository}`,
  444. })
  445. const issues = new Map()
  446. for (const issueNumber of references.all) {
  447. const issue = await issueSnapshot(issueNumber, null)
  448. if (issue) issues.set(issueNumber, issue)
  449. }
  450. return {
  451. number,
  452. isDraft: pull.draft,
  453. authorType: pull.user?.type ?? 'User',
  454. reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length,
  455. reviewCount: reviews.length,
  456. labels: pull.labels.map((label) => label.name),
  457. references: retainIssueReferences(references, issues),
  458. issues,
  459. }
  460. }
  461. async function moveResolvingIssues(pull, from, to) {
  462. for (const number of pull.references.resolving) {
  463. const current = await issueSnapshot(number)
  464. if (!current || current.status !== from) continue
  465. await setStatus(number, to)
  466. await auditIssue(number)
  467. }
  468. }
  469. async function runPullRequestCheck(event) {
  470. const pull = await pullRequestSnapshot(event.pull_request.number)
  471. const errors = validatePullRequest(pull)
  472. if (errors.length > 0) {
  473. for (const error of errors) process.stdout.write(`::error::${error}\n`)
  474. throw new Error(`Issue policy 未通过,共 ${errors.length} 项`)
  475. }
  476. process.stdout.write(
  477. requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : 'PR 尚未进入 Issue policy 强制范围。\n',
  478. )
  479. }
  480. async function runLifecycle(eventName, event) {
  481. if (eventName === 'issues') {
  482. const number = event.issue.number
  483. if (event.action === 'opened') await setStatus(number, 'Inbox')
  484. if (event.action === 'closed') {
  485. const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done'
  486. await setStatus(number, target)
  487. }
  488. if (event.action === 'reopened') {
  489. await setStatus(number, 'Inbox')
  490. }
  491. await ensureProjectItem(number)
  492. await auditIssue(number)
  493. return
  494. }
  495. if (eventName === 'pull_request' || eventName === 'pull_request_review') {
  496. const pull = await pullRequestSnapshot(event.pull_request.number)
  497. const errors = validatePullRequest(pull)
  498. if (errors.length > 0) return
  499. await moveResolvingIssues(pull, 'Ready', 'In progress')
  500. if (pull.reviewRequestCount > 0 || pull.reviewCount > 0) {
  501. await moveResolvingIssues(pull, 'In progress', 'In review')
  502. }
  503. }
  504. }
  505. function readEvent() {
  506. if (!process.env.GITHUB_EVENT_PATH) throw new Error('GITHUB_EVENT_PATH 未设置')
  507. return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8'))
  508. }
  509. async function main(argv) {
  510. const [command] = argv
  511. if (command === 'pr') await runPullRequestCheck(readEvent())
  512. else if (command === 'lifecycle') await runLifecycle(process.env.GITHUB_EVENT_NAME, readEvent())
  513. else throw new Error('用法:policy.mjs pr|lifecycle')
  514. }
  515. if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  516. main(process.argv.slice(2)).catch((error) => {
  517. process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`)
  518. process.exitCode = 1
  519. })
  520. }