lifecycle.mjs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. /** Repository event orchestration and Issue audit comments. */
  2. import config from './config.json' with { type: 'json' }
  3. import {
  4. api,
  5. ensureProjectItem,
  6. initializeIssueStartDate,
  7. issueSnapshot,
  8. projectContext,
  9. setStatus,
  10. updateStatus,
  11. } from './github.mjs'
  12. import { lifecyclePullRequestSnapshot } from './pull-request.mjs'
  13. import {
  14. isInvalidIssueLabel,
  15. nextResolvingIssueStatus,
  16. projectDate,
  17. resolvingIssueStatusCommand,
  18. validateIssue,
  19. } from './rules.mjs'
  20. const AUDIT_MARKER = '<!-- dsh-issue-policy -->'
  21. /**
  22. * Initialize every referenced Issue from a newly opened PR.
  23. * @param {{createdAt: string, references: {all: number[]}}} pull Pull-request snapshot.
  24. * @param {string} action Pull-request event action.
  25. * @param {(number: number, date: string) => Promise<void>} initialize Date writer.
  26. * @returns {Promise<void>} Resolves after all eligible Issues are processed.
  27. */
  28. export async function initializePullRequestStartDates(
  29. pull,
  30. action,
  31. initialize = initializeIssueStartDate,
  32. ) {
  33. if (action !== 'opened') return
  34. const date = projectDate(pull.createdAt)
  35. for (const number of pull.references.all) await initialize(number, date)
  36. }
  37. async function upsertAudit(number, errors) {
  38. const comments = await api(
  39. `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`,
  40. )
  41. const existing = comments.find(
  42. (comment) => comment.user?.type === 'Bot' && comment.body?.includes(AUDIT_MARKER),
  43. )
  44. if (errors.length === 0) {
  45. if (existing) {
  46. await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
  47. method: 'DELETE',
  48. })
  49. }
  50. return
  51. }
  52. const body = `${AUDIT_MARKER}\n⚠️ Issue policy 未通过:\n\n${errors.map((error) => `- ${error}`).join('\n')}`
  53. if (existing) {
  54. if (existing.body === body) return
  55. await api(`/repos/${config.organization}/${config.repository}/issues/comments/${existing.id}`, {
  56. method: 'PATCH',
  57. body: JSON.stringify({ body }),
  58. headers: { 'Content-Type': 'application/json' },
  59. })
  60. } else {
  61. await api(`/repos/${config.organization}/${config.repository}/issues/${number}/comments`, {
  62. method: 'POST',
  63. body: JSON.stringify({ body }),
  64. headers: { 'Content-Type': 'application/json' },
  65. })
  66. }
  67. }
  68. /**
  69. * Remove pull-request kinds and retired aliases from one Issue snapshot.
  70. * @param {{number: number, labels: string[]}} issue Issue snapshot.
  71. * @returns {Promise<object>} Snapshot containing only labels that remain on the Issue.
  72. */
  73. export async function repairIssueLabels(issue) {
  74. const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
  75. for (const label of invalidLabels) {
  76. await api(
  77. `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`,
  78. { method: 'DELETE', allow404: true },
  79. )
  80. }
  81. return {
  82. ...issue,
  83. labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)),
  84. }
  85. }
  86. /**
  87. * Repair deterministic Issue metadata violations and publish the remaining audit result.
  88. * @param {number} number Same-repository Issue number.
  89. * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation.
  90. * @param {string|null|undefined} status Optional known Project status.
  91. * @returns {Promise<string[]>} Violations that remain after repair.
  92. */
  93. export async function auditIssue(number, extraErrors = [], status = undefined) {
  94. const issue = await issueSnapshot(number, status)
  95. if (!issue) return []
  96. const repairedIssue = await repairIssueLabels(issue)
  97. const errors = [...extraErrors, ...validateIssue(repairedIssue)]
  98. await upsertAudit(number, errors)
  99. return errors
  100. }
  101. async function transitionResolvingIssues(pull, command) {
  102. for (const number of pull.references.resolving) {
  103. const context = await projectContext(number, command === 'changes-requested')
  104. const target = nextResolvingIssueStatus(
  105. context.item?.fieldValueByName?.name ?? null,
  106. command,
  107. context.statusActor,
  108. )
  109. if (!target) continue
  110. // TODO: Replace this latest-state guard with per-Issue serialization or a
  111. // conditional ProjectV2 update; GraphQL currently has no compare-and-swap.
  112. await updateStatus(context, target)
  113. await auditIssue(number)
  114. }
  115. }
  116. /**
  117. * Apply repository lifecycle events; irrelevant PR events perform no requests.
  118. * @param {string} eventName GitHub event name.
  119. * @param {object} event GitHub event payload.
  120. * @returns {Promise<void>} Resolves after lifecycle updates and audits.
  121. */
  122. export async function runLifecycle(eventName, event) {
  123. if (eventName === 'issues') {
  124. const number = event.issue.number
  125. if (event.action === 'opened') await setStatus(number, 'Inbox')
  126. if (event.action === 'closed') {
  127. const target = event.issue.state_reason === 'not_planned' ? 'No action' : 'Done'
  128. await setStatus(number, target)
  129. }
  130. if (event.action === 'reopened') {
  131. await setStatus(number, 'Inbox')
  132. }
  133. await ensureProjectItem(number)
  134. await auditIssue(number)
  135. return
  136. }
  137. if (eventName === 'pull_request' || eventName === 'pull_request_review') {
  138. const command = resolvingIssueStatusCommand(eventName, event)
  139. if (!command) return
  140. const pull = await lifecyclePullRequestSnapshot(event.pull_request.number)
  141. await transitionResolvingIssues(pull, command)
  142. if (eventName === 'pull_request') {
  143. await initializePullRequestStartDates(pull, event.action)
  144. }
  145. }
  146. }