release-gap-classifier.mjs 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. const RULES = [
  2. {
  3. test: message => message.includes('local source worktree') || message.startsWith('local HEAD'),
  4. channel: 'source-worktree',
  5. scope: 'local',
  6. externalBlocker: false,
  7. nextAction: 'Commit or clean the local source worktree, then rerun the release audit.'
  8. },
  9. {
  10. test: message => message.startsWith('source remote') || message.includes('source remote missing branch'),
  11. channel: 'source-remote',
  12. scope: 'private-gitea',
  13. externalBlocker: true,
  14. nextAction: 'Restore the private Gitea source branch or push the expected release source.'
  15. },
  16. {
  17. test: message => message.includes('open-source main GitHub') || message.endsWith('GitHub repository missing'),
  18. channel: 'github',
  19. scope: 'public-source',
  20. externalBlocker: true,
  21. nextAction: 'Create or refresh the GitHub public repository, then rerun the GitHub content verifier.'
  22. },
  23. {
  24. test: message => message.includes('GitHub Release'),
  25. channel: 'github-release',
  26. scope: 'release-assets',
  27. externalBlocker: true,
  28. nextAction: 'Upload the missing GitHub Release metadata or artifact and rerun verify:github-release-assets.'
  29. },
  30. {
  31. test: message => message.includes('open-source main Gitee repository'),
  32. channel: 'gitee',
  33. scope: 'public-mirror',
  34. externalBlocker: true,
  35. nextAction:
  36. 'Run `pnpm public:gitee:snapshot -- --push --confirm-rewrite-history` to publish a shallow Gitee mirror with the same file tree.'
  37. },
  38. {
  39. test: message => message.includes('Gitee'),
  40. channel: 'gitee',
  41. scope: 'component-repository',
  42. externalBlocker: true,
  43. nextAction: 'Set `FILE_VIEWER_GITEE_TOKEN_FILE=<repo-external-token-file>` and run `pnpm components:gitee:publish`.'
  44. },
  45. {
  46. test: message => message.includes('npm') || message.includes('published'),
  47. channel: 'npm',
  48. scope: 'package-registry',
  49. externalBlocker: true,
  50. nextAction: 'Complete npm login/passkey and publish the ecosystem packages.'
  51. }
  52. ]
  53. function defaultRule(message) {
  54. return {
  55. channel: 'unknown',
  56. scope: 'release',
  57. externalBlocker: true,
  58. nextAction: `Inspect and resolve this release gap: ${message}`
  59. }
  60. }
  61. function slugify(value) {
  62. return value
  63. .toLowerCase()
  64. .replace(/`/g, '')
  65. .replace(/[^a-z0-9@._-]+/g, '-')
  66. .replace(/^-+|-+$/g, '')
  67. .slice(0, 96)
  68. }
  69. export function classifyReleaseGap(message) {
  70. const rule = RULES.find(candidate => candidate.test(message)) ?? defaultRule(message)
  71. return {
  72. id: slugify(`${rule.channel}-${message}`),
  73. channel: rule.channel,
  74. scope: rule.scope,
  75. externalBlocker: rule.externalBlocker,
  76. message,
  77. nextAction: rule.nextAction
  78. }
  79. }
  80. export function describeReleaseGaps(messages) {
  81. const details = messages.map(classifyReleaseGap)
  82. const byChannel = {}
  83. for (const detail of details) {
  84. byChannel[detail.channel] = (byChannel[detail.channel] || 0) + 1
  85. }
  86. const externalBlockerChannels = [
  87. ...new Set(details.filter(detail => detail.externalBlocker).map(detail => detail.channel))
  88. ].sort()
  89. const localActionableChannels = [
  90. ...new Set(details.filter(detail => !detail.externalBlocker).map(detail => detail.channel))
  91. ].sort()
  92. return {
  93. details,
  94. summary: {
  95. total: details.length,
  96. externalBlockers: details.filter(detail => detail.externalBlocker).length,
  97. localActionable: details.filter(detail => !detail.externalBlocker).length,
  98. byChannel,
  99. externalBlockerChannels,
  100. localActionableChannels
  101. }
  102. }
  103. }