rules.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. /** Pure Issue/PR validation, reference parsing, lifecycle decisions, and Project dates. */
  2. import config from './config.json' with { type: 'json' }
  3. const TYPES = new Set(['Idea', 'Feature', 'Bug', 'Research', 'Task'])
  4. const PRIORITIES = ['p0', 'p1', 'p2', 'p3']
  5. const PR_KINDS = new Set([
  6. 'kind/feature',
  7. 'kind/bug-fix',
  8. 'kind/doc',
  9. 'kind/testing',
  10. 'kind/cleanup',
  11. 'kind/dependency',
  12. ])
  13. // Retired label aliases stay reserved so they cannot be recreated.
  14. const LEGACY_LABELS = new Set([
  15. 'kind/bug',
  16. 'kind/documentation',
  17. 'feature',
  18. 'bug-fix',
  19. 'doc',
  20. 'cleanup',
  21. 'testing',
  22. 'dependencies',
  23. 'ci',
  24. 'cli',
  25. 'llm',
  26. 'web-search',
  27. ])
  28. const TERMINAL_STATUSES = new Set(['Done', 'No action'])
  29. const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status))
  30. const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([
  31. 'opened',
  32. 'edited',
  33. 'reopened',
  34. ])
  35. for (const status of ['In progress', 'In review']) {
  36. if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`)
  37. }
  38. if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) {
  39. throw new Error('config.lifecycleActor 未设置')
  40. }
  41. if (typeof config.priorityField !== 'string' || !config.priorityField) {
  42. throw new Error('config.priorityField 未设置')
  43. }
  44. if (typeof config.startDateField !== 'string' || !config.startDateField) {
  45. throw new Error('config.startDateField 未设置')
  46. }
  47. if (typeof config.projectTimeZone !== 'string' || !config.projectTimeZone) {
  48. throw new Error('config.projectTimeZone 未设置')
  49. }
  50. Intl.DateTimeFormat('en-US', { timeZone: config.projectTimeZone })
  51. /**
  52. * Decide whether the human-review policy applies to a PR.
  53. * @param {{isDraft: boolean, authorType: string, reviewRequestCount: number, reviewCount: number}} input PR state.
  54. * @returns {boolean} Whether the PR policy is mandatory.
  55. */
  56. export function requiresPullRequestPolicy({
  57. isDraft,
  58. authorType,
  59. reviewRequestCount,
  60. reviewCount,
  61. }) {
  62. const automated = authorType === 'Bot' || authorType === 'App'
  63. return !isDraft && !automated && (reviewRequestCount > 0 || reviewCount > 0)
  64. }
  65. /**
  66. * Translate a repository event into one resolving-Issue lifecycle command.
  67. * @param {string} eventName GitHub event name.
  68. * @param {{action?: string, changes?: {body?: object}, review?: {state?: string}}} event GitHub event payload.
  69. * @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command.
  70. */
  71. export function resolvingIssueStatusCommand(eventName, event) {
  72. if (eventName === 'pull_request') {
  73. if (event.action === 'edited' && !event.changes?.body) return null
  74. if (event.action === 'review_requested') return 'review-requested'
  75. return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null
  76. }
  77. if (
  78. eventName === 'pull_request_review' &&
  79. event.action === 'submitted' &&
  80. event.review?.state?.toLowerCase() === 'changes_requested'
  81. ) {
  82. return 'changes-requested'
  83. }
  84. return null
  85. }
  86. /**
  87. * Plan one event-directed resolving-Issue status transition.
  88. * @param {string|null} currentStatus Current Project status.
  89. * @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command.
  90. * @param {string|null} currentStatusActor Actor that last set the current Project status.
  91. * @returns {string|null} Status to write, or null when no permitted transition exists.
  92. */
  93. export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) {
  94. let target
  95. if (command === 'review-requested') target = 'In review'
  96. else if (command === 'implementation' || command === 'changes-requested') target = 'In progress'
  97. else throw new Error(`未知 lifecycle command:${command}`)
  98. const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus)
  99. const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target)
  100. if (
  101. command === 'changes-requested' &&
  102. currentStatus === 'In review' &&
  103. currentStatusActor === config.lifecycleActor
  104. ) {
  105. return target
  106. }
  107. return currentIndex >= 0 && currentIndex < targetIndex ? target : null
  108. }
  109. /**
  110. * Convert a GitHub timestamp to a Project date in one configured time zone.
  111. * @param {string} timestamp ISO timestamp.
  112. * @param {string} timeZone IANA time-zone name.
  113. * @returns {string} Calendar date in YYYY-MM-DD form.
  114. */
  115. export function projectDate(timestamp, timeZone = config.projectTimeZone) {
  116. const instant = new Date(timestamp)
  117. if (Number.isNaN(instant.getTime())) throw new Error(`无效的 PR 创建时间:${timestamp}`)
  118. const parts = Object.fromEntries(
  119. new Intl.DateTimeFormat('en-US', {
  120. timeZone,
  121. year: 'numeric',
  122. month: '2-digit',
  123. day: '2-digit',
  124. })
  125. .formatToParts(instant)
  126. .filter((part) => part.type !== 'literal')
  127. .map((part) => [part.type, part.value]),
  128. )
  129. return `${parts.year}-${parts.month}-${parts.day}`
  130. }
  131. function stripIgnoredMarkdown(body) {
  132. const lines = body.replace(/<!--[\s\S]*?-->/g, '').split(/\r?\n/)
  133. const kept = []
  134. let fence = null
  135. for (const line of lines) {
  136. const marker = line.match(/^\s*([\u0060~]{3,})/)
  137. if (marker) {
  138. if (fence === null) fence = marker[1][0]
  139. else if (marker[1][0] === fence) fence = null
  140. continue
  141. }
  142. if (fence === null) kept.push(line)
  143. }
  144. return kept.join('\n').replace(/\u0060[^\u0060]*\u0060/g, ' ')
  145. }
  146. /**
  147. * Parse same-repository resolving and informational references.
  148. * @param {{body: string, repository: string}} input PR body and repository.
  149. * @returns {{all: number[], resolving: number[], related: number[]}} References.
  150. */
  151. export function parseReferences({ body, repository }) {
  152. const source = stripIgnoredMarkdown(body)
  153. const expected = repository.toLowerCase()
  154. const all = new Set()
  155. const resolving = new Set()
  156. const reference =
  157. /(?:([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)#|#)(\d+)|https:\/\/github\.com\/([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\/issues\/(\d+)/gi
  158. const closing =
  159. /\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
  160. for (const match of source.matchAll(reference)) {
  161. const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
  162. const number = Number(match[2] ?? match[4])
  163. if (!explicit || explicit === expected) all.add(number)
  164. }
  165. for (const match of source.matchAll(closing)) {
  166. const explicit = (match[1] ?? match[3] ?? '').toLowerCase()
  167. const number = Number(match[2] ?? match[4])
  168. if (!explicit || explicit === expected) {
  169. all.add(number)
  170. resolving.add(number)
  171. }
  172. }
  173. return {
  174. all: [...all].sort((left, right) => left - right),
  175. resolving: [...resolving].sort((left, right) => left - right),
  176. related: [...all].filter((number) => !resolving.has(number)).sort((a, b) => a - b),
  177. }
  178. }
  179. /**
  180. * Retain only references that resolve to Issues rather than pull requests.
  181. * @param {{all: number[], resolving: number[], related: number[]}} references Parsed references.
  182. * @param {Map<number, unknown>} issues Resolved same-repository Issues.
  183. * @returns {{all: number[], resolving: number[], related: number[]}} Issue-only references.
  184. */
  185. export function retainIssueReferences(references, issues) {
  186. return {
  187. all: references.all.filter((number) => issues.has(number)),
  188. resolving: references.resolving.filter((number) => issues.has(number)),
  189. related: references.related.filter((number) => issues.has(number)),
  190. }
  191. }
  192. /**
  193. * Validate one Issue with its Project status.
  194. * @param {{labels: string[], type: string|null, priority: string|null, status: string|null, state: string, stateReason: string|null}} issue Issue snapshot.
  195. * @returns {string[]} Validation errors.
  196. */
  197. export function validateIssue(issue) {
  198. const errors = []
  199. const status = issue.status
  200. const invalidLabels = issue.labels.filter(isInvalidIssueLabel)
  201. if (invalidLabels.length > 0) {
  202. errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`)
  203. }
  204. if (!TYPES.has(issue.type ?? '')) errors.push('Type 必须是五种原生英文 Type 之一')
  205. if (!status || !config.statuses.includes(status)) errors.push('Issue 必须在 Project 中且具有合法 Status')
  206. if (issue.priority !== null && !PRIORITIES.includes(issue.priority.toLowerCase())) {
  207. errors.push('Priority 必须为空或为 P0–P3')
  208. }
  209. if (status === 'Done' && (issue.state !== 'closed' || issue.stateReason !== 'completed')) {
  210. errors.push('Done 必须对应 Completed 关闭原因')
  211. }
  212. if (
  213. status === 'No action' &&
  214. (issue.state !== 'closed' || issue.stateReason !== 'not_planned')
  215. ) {
  216. errors.push('No action 必须对应 Not planned 关闭原因')
  217. }
  218. if (!['Done', 'No action'].includes(status ?? '') && issue.state !== 'open') {
  219. errors.push(`${status} 必须对应开放 Issue`)
  220. }
  221. return errors
  222. }
  223. /**
  224. * Identify PR kinds and retired aliases that cannot label an Issue.
  225. * @param {string} label Label name.
  226. * @returns {boolean} Whether the label is invalid for Issues.
  227. */
  228. export function isInvalidIssueLabel(label) {
  229. return label.startsWith('kind/') || LEGACY_LABELS.has(label)
  230. }
  231. /**
  232. * Validate PR metadata and its referenced Issues.
  233. * @param {{authorType: string, labels: string[], references: ReturnType<typeof parseReferences>, issues: Map<number, {priority: string|null}>}} input PR snapshot.
  234. * @returns {string[]} Validation errors.
  235. */
  236. export function validatePullRequest(input) {
  237. if (!requiresPullRequestPolicy(input)) return []
  238. const errors = []
  239. const kinds = input.labels.filter((label) => PR_KINDS.has(label))
  240. const unknownKinds = input.labels.filter(
  241. (label) => label.startsWith('kind/') && !PR_KINDS.has(label) && !LEGACY_LABELS.has(label),
  242. )
  243. const legacyLabels = input.labels.filter((label) => LEGACY_LABELS.has(label))
  244. const sourceLabels = input.labels.filter((label) => label.startsWith('source/'))
  245. const priorities = input.labels.filter((label) => PRIORITIES.includes(label))
  246. const areas = input.labels.filter((label) => label.startsWith('area/'))
  247. if (input.references.all.length === 0) {
  248. errors.push('PR 正文必须引用至少一个同仓库 Issue;PR 编号(包括堆叠依赖 PR)不算 Issue 引用')
  249. }
  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. }