pull-request.mjs 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. /** Read-only PR snapshots and policy preflight/check workflow entry points. */
  2. import fs from 'node:fs'
  3. import process from 'node:process'
  4. import config from './config.json' with { type: 'json' }
  5. import { api, projectContext } from './github.mjs'
  6. import {
  7. parseReferences,
  8. retainIssueReferences,
  9. requiresPullRequestPolicy,
  10. validatePullRequest,
  11. } from './rules.mjs'
  12. /**
  13. * Resolve all same-repository body references with REST, excluding pull-request numbers.
  14. * @param {number} number Pull-request number.
  15. * @param {{body?: string}} pull Current REST pull-request data.
  16. * @returns {Promise<object>} Issue-only references and placeholder priorities; never reads Project data.
  17. */
  18. export async function resolvingReferencesSnapshot(number, pull) {
  19. const references = parseReferences({
  20. body: pull.body ?? '',
  21. repository: `${config.organization}/${config.repository}`,
  22. })
  23. const issues = new Map()
  24. for (const issueNumber of references.all) {
  25. const issue = await api(
  26. `/repos/${config.organization}/${config.repository}/issues/${issueNumber}`,
  27. )
  28. if (!issue.pull_request) issues.set(issueNumber, { priority: null })
  29. }
  30. return {
  31. number,
  32. references: retainIssueReferences(references, issues),
  33. issues,
  34. }
  35. }
  36. /**
  37. * Read current PR policy inputs, skipping references for exempt PRs.
  38. * @param {number} number Pull-request number.
  39. * @param {boolean} includeProject Read Project Priority for resolving Issues when true.
  40. * @returns {Promise<object>} Policy snapshot; rejects any failed read and performs no writes.
  41. */
  42. export async function pullRequestSnapshot(number, includeProject = true) {
  43. const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
  44. const snapshot = {
  45. number,
  46. isDraft: pull.draft,
  47. authorType: pull.user?.type ?? 'User',
  48. reviewRequestCount: 0,
  49. reviewCount: 0,
  50. labels: pull.labels.map((label) => label.name),
  51. references: { all: [], resolving: [], related: [] },
  52. issues: new Map(),
  53. }
  54. if (snapshot.isDraft || ['Bot', 'App'].includes(snapshot.authorType)) return snapshot
  55. const [reviewRequests, reviews] = await Promise.all([
  56. api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`),
  57. api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`),
  58. ])
  59. snapshot.reviewRequestCount = reviewRequests.users.length + reviewRequests.teams.length
  60. snapshot.reviewCount = reviews.length
  61. if (!requiresPullRequestPolicy(snapshot)) return snapshot
  62. Object.assign(snapshot, await resolvingReferencesSnapshot(number, pull))
  63. if (includeProject) {
  64. for (const issueNumber of snapshot.references.resolving) {
  65. const context = await projectContext(issueNumber)
  66. snapshot.issues.get(issueNumber).priority = context.item?.priorityValue?.name ?? null
  67. }
  68. }
  69. return snapshot
  70. }
  71. /**
  72. * Read PR creation time and Issue references without review eligibility gating.
  73. * @param {number} number Pull-request number.
  74. * @returns {Promise<object>} Lifecycle snapshot without Project reads or writes.
  75. */
  76. export async function lifecyclePullRequestSnapshot(number) {
  77. const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`)
  78. return {
  79. ...(await resolvingReferencesSnapshot(number, pull)),
  80. createdAt: pull.created_at,
  81. }
  82. }
  83. const EXEMPT_MESSAGE =
  84. 'Issue policy exempt:当前 PR 不在强制范围(Draft、Bot/App 或尚无 review request/review)。\n'
  85. /**
  86. * Determine current policy eligibility and Project access needs without Project credentials.
  87. * @param {{pull_request: {number: number}}} event GitHub event identifying the PR.
  88. * @returns {Promise<{eligible: boolean, needsProject: boolean}>} Trusted workflow decisions.
  89. */
  90. export async function runPullRequestPreflight(event) {
  91. const pull = await pullRequestSnapshot(event.pull_request.number, false)
  92. const eligible = requiresPullRequestPolicy(pull)
  93. const needsProject = eligible && pull.references.resolving.length > 0
  94. if (process.env.GITHUB_OUTPUT) {
  95. fs.appendFileSync(
  96. process.env.GITHUB_OUTPUT,
  97. `eligible=${eligible}\nexempt=${!eligible}\nneeds-project=${needsProject}\n`,
  98. )
  99. }
  100. process.stdout.write(eligible ? 'Issue policy applicable;执行完整校验。\n' : EXEMPT_MESSAGE)
  101. return { eligible, needsProject }
  102. }
  103. /**
  104. * Enforce all PR rules against current GitHub state, independently of preflight.
  105. * @param {{pull_request: {number: number}}} event GitHub event identifying the PR.
  106. * @returns {Promise<void>} Resolves on success or exemption; rejects policy failures.
  107. */
  108. export async function runPullRequestCheck(event) {
  109. const pull = await pullRequestSnapshot(event.pull_request.number)
  110. const errors = validatePullRequest(pull)
  111. if (errors.length > 0) {
  112. for (const error of errors) process.stdout.write(`::error::${error}\n`)
  113. throw new Error(`Issue policy 未通过,共 ${errors.length} 项`)
  114. }
  115. process.stdout.write(
  116. requiresPullRequestPolicy(pull) ? 'Issue policy 通过。\n' : EXEMPT_MESSAGE,
  117. )
  118. }