clud-bug 0.7.0-rc.2 → 0.7.0-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +20 -0
  2. package/data/canonical-v1.json +37 -0
  3. package/dist/cli/configure-github.d.ts +58 -0
  4. package/dist/cli/configure-github.d.ts.map +1 -0
  5. package/dist/cli/configure-github.js +242 -0
  6. package/dist/cli/configure-github.js.map +1 -0
  7. package/dist/cli/hooks.d.ts +51 -0
  8. package/dist/cli/hooks.d.ts.map +1 -0
  9. package/dist/cli/hooks.js +138 -0
  10. package/dist/cli/hooks.js.map +1 -0
  11. package/dist/cli/index.d.ts +1 -0
  12. package/dist/cli/index.d.ts.map +1 -1
  13. package/dist/cli/index.js +1 -0
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/main.d.ts.map +1 -1
  16. package/dist/cli/main.js +949 -35
  17. package/dist/cli/main.js.map +1 -1
  18. package/dist/cli/post-check-run.d.ts +16 -0
  19. package/dist/cli/post-check-run.d.ts.map +1 -0
  20. package/dist/cli/post-check-run.js +93 -0
  21. package/dist/cli/post-check-run.js.map +1 -0
  22. package/dist/cli/review-prompt.d.ts +43 -0
  23. package/dist/cli/review-prompt.d.ts.map +1 -0
  24. package/dist/cli/review-prompt.js +287 -0
  25. package/dist/cli/review-prompt.js.map +1 -0
  26. package/dist/cli/skills.d.ts +7 -0
  27. package/dist/cli/skills.d.ts.map +1 -1
  28. package/dist/cli/skills.js +42 -2
  29. package/dist/cli/skills.js.map +1 -1
  30. package/dist/cli/update-notifier.d.ts +17 -0
  31. package/dist/cli/update-notifier.d.ts.map +1 -0
  32. package/dist/cli/update-notifier.js +88 -0
  33. package/dist/cli/update-notifier.js.map +1 -0
  34. package/dist/cli/update.d.ts.map +1 -1
  35. package/dist/cli/update.js +60 -11
  36. package/dist/cli/update.js.map +1 -1
  37. package/dist/core/auto-resolve.d.ts +235 -0
  38. package/dist/core/auto-resolve.d.ts.map +1 -0
  39. package/dist/core/auto-resolve.js +293 -0
  40. package/dist/core/auto-resolve.js.map +1 -0
  41. package/dist/core/budget-plan.d.ts +125 -0
  42. package/dist/core/budget-plan.d.ts.map +1 -0
  43. package/dist/core/budget-plan.js +211 -0
  44. package/dist/core/budget-plan.js.map +1 -0
  45. package/dist/core/check-verdict.d.ts +31 -0
  46. package/dist/core/check-verdict.d.ts.map +1 -0
  47. package/dist/core/check-verdict.js +66 -0
  48. package/dist/core/check-verdict.js.map +1 -0
  49. package/dist/core/configure-github.d.ts +179 -0
  50. package/dist/core/configure-github.d.ts.map +1 -0
  51. package/dist/core/configure-github.js +289 -0
  52. package/dist/core/configure-github.js.map +1 -0
  53. package/dist/core/design.d.ts +37 -0
  54. package/dist/core/design.d.ts.map +1 -0
  55. package/dist/core/design.js +47 -0
  56. package/dist/core/design.js.map +1 -0
  57. package/dist/core/diff-findings.d.ts +96 -0
  58. package/dist/core/diff-findings.d.ts.map +1 -0
  59. package/dist/core/diff-findings.js +261 -0
  60. package/dist/core/diff-findings.js.map +1 -0
  61. package/dist/core/formal-review.d.ts +79 -0
  62. package/dist/core/formal-review.d.ts.map +1 -0
  63. package/dist/core/formal-review.js +71 -0
  64. package/dist/core/formal-review.js.map +1 -0
  65. package/dist/core/index.d.ts +15 -2
  66. package/dist/core/index.d.ts.map +1 -1
  67. package/dist/core/index.js +53 -1
  68. package/dist/core/index.js.map +1 -1
  69. package/dist/core/inline-threads.d.ts +160 -0
  70. package/dist/core/inline-threads.d.ts.map +1 -0
  71. package/dist/core/inline-threads.js +369 -0
  72. package/dist/core/inline-threads.js.map +1 -0
  73. package/dist/core/multi-pass-aggregate.d.ts +161 -0
  74. package/dist/core/multi-pass-aggregate.d.ts.map +1 -0
  75. package/dist/core/multi-pass-aggregate.js +457 -0
  76. package/dist/core/multi-pass-aggregate.js.map +1 -0
  77. package/dist/core/plan-review.d.ts +57 -0
  78. package/dist/core/plan-review.d.ts.map +1 -0
  79. package/dist/core/plan-review.js +77 -0
  80. package/dist/core/plan-review.js.map +1 -0
  81. package/dist/core/prompt-builder.d.ts +18 -0
  82. package/dist/core/prompt-builder.d.ts.map +1 -1
  83. package/dist/core/prompt-builder.js +24 -2
  84. package/dist/core/prompt-builder.js.map +1 -1
  85. package/dist/core/render.d.ts.map +1 -1
  86. package/dist/core/render.js +1 -19
  87. package/dist/core/render.js.map +1 -1
  88. package/dist/core/resolve-verifier.d.ts +53 -0
  89. package/dist/core/resolve-verifier.d.ts.map +1 -0
  90. package/dist/core/resolve-verifier.js +204 -0
  91. package/dist/core/resolve-verifier.js.map +1 -0
  92. package/dist/core/review-context.d.ts +39 -0
  93. package/dist/core/review-context.d.ts.map +1 -0
  94. package/dist/core/review-context.js +99 -0
  95. package/dist/core/review-context.js.map +1 -0
  96. package/dist/core/review-plan.d.ts +219 -0
  97. package/dist/core/review-plan.d.ts.map +1 -0
  98. package/dist/core/review-plan.js +274 -0
  99. package/dist/core/review-plan.js.map +1 -0
  100. package/dist/core/review-writeback.d.ts +74 -0
  101. package/dist/core/review-writeback.d.ts.map +1 -1
  102. package/dist/core/review-writeback.js +71 -4
  103. package/dist/core/review-writeback.js.map +1 -1
  104. package/dist/core/skills.d.ts +25 -0
  105. package/dist/core/skills.d.ts.map +1 -1
  106. package/dist/core/skills.js +100 -4
  107. package/dist/core/skills.js.map +1 -1
  108. package/dist/core/version.d.ts +2 -0
  109. package/dist/core/version.d.ts.map +1 -0
  110. package/dist/core/version.js +4 -0
  111. package/dist/core/version.js.map +1 -0
  112. package/package.json +11 -5
  113. package/src/cli/configure-github.ts +329 -0
  114. package/src/cli/hooks.ts +155 -0
  115. package/src/cli/index.ts +6 -0
  116. package/src/cli/main.ts +1031 -37
  117. package/src/cli/post-check-run.ts +115 -0
  118. package/src/cli/review-prompt.ts +355 -0
  119. package/src/cli/skills.ts +42 -2
  120. package/src/cli/update-notifier.ts +100 -0
  121. package/src/cli/update.ts +60 -11
  122. package/src/core/auto-resolve.ts +488 -0
  123. package/src/core/budget-plan.ts +324 -0
  124. package/src/core/check-verdict.ts +93 -0
  125. package/src/core/configure-github.ts +497 -0
  126. package/src/core/design.ts +73 -0
  127. package/src/core/diff-findings.ts +323 -0
  128. package/src/core/formal-review.ts +168 -0
  129. package/src/core/index.ts +188 -0
  130. package/src/core/inline-threads.ts +471 -0
  131. package/src/core/multi-pass-aggregate.ts +591 -0
  132. package/src/core/plan-review.ts +136 -0
  133. package/src/core/prompt-builder.ts +44 -2
  134. package/src/core/render.ts +10 -16
  135. package/src/core/resolve-verifier.ts +254 -0
  136. package/src/core/review-context.ts +106 -0
  137. package/src/core/review-plan.ts +485 -0
  138. package/src/core/review-writeback.ts +161 -4
  139. package/src/core/skills.ts +128 -5
  140. package/src/core/version.ts +3 -0
  141. package/templates/clud-bug-review.md.tmpl +115 -0
  142. package/templates/skills/design/design-system-consistency.md +41 -0
  143. package/templates/skills/design/frontend-a11y.md +41 -0
  144. package/templates/skills/design/visual-polish.md +41 -0
  145. package/templates/workflow-py.yml.tmpl +92 -2
  146. package/templates/workflow-ts.yml.tmpl +92 -2
  147. package/templates/workflow.yml.tmpl +173 -2
@@ -0,0 +1,323 @@
1
+ // SPEC §1.8.1 multi-pass diff: prior `docs/reviews/PR-<n>.md` vs current
2
+ // review findings. Produces the `**Resolved this round:**` /
3
+ // `**Still open:**` lists that `renderReviewFile` consumes.
4
+ //
5
+ // Identity model
6
+ // --------------
7
+ // Each finding's identity is a stable hash of:
8
+ //
9
+ // `${file}:${line}:${severity}:${skillName}:${summary.slice(0, 100)}`
10
+ //
11
+ // A 100-character truncation on `summary` is enough to discriminate
12
+ // distinct findings without being so long that whitespace/punctuation
13
+ // drift between passes breaks identity. A severity-bucket change (e.g.
14
+ // the same skill flags the same line as 🔴 then 🟡 the next round)
15
+ // produces a DIFFERENT identity — by design — so a severity downgrade
16
+ // counts as one resolved finding + one new finding (the user gets
17
+ // credit for the fix AND can see the bot still has a concern, just at
18
+ // a lower severity).
19
+ //
20
+ // The same identity shape is what auto-fix / auto-resolve in
21
+ // clud-bug-app uses for thread anchoring; keeping it identical means a
22
+ // future per-finding cross-reference between the doc-file diff and the
23
+ // inline-thread surface is trivial.
24
+
25
+ import {
26
+ flattenFindings,
27
+ type Finding,
28
+ type Review,
29
+ } from './review-schema-zod.js';
30
+
31
+ import { SEVERITY_EMOJI } from './review-writeback.js';
32
+
33
+ /** One parsed finding from a prior `docs/reviews/PR-<n>.md`. */
34
+ export type ParsedFinding = {
35
+ /** Relative path. Falls back to '(unknown file)' when the prior file
36
+ * itself rendered the unknown-file marker; we keep it intact for
37
+ * identity stability. */
38
+ file: string;
39
+ /**
40
+ * 1-indexed line number. Zero when the prior file had no `:N` suffix
41
+ * (cross-cutting findings). Zero participates in identity as-is.
42
+ */
43
+ line: number;
44
+ severity: 'critical' | 'minor' | 'preexisting';
45
+ skillName: string;
46
+ summary: string;
47
+ };
48
+
49
+ export type ParsedReview = { findings: ParsedFinding[] };
50
+
51
+ /**
52
+ * Parse a `docs/reviews/PR-<n>.md` markdown back into structured findings.
53
+ *
54
+ * Robust to:
55
+ * - `null` / `undefined` input → returns `null`
56
+ * - empty / whitespace-only markdown → returns `null`
57
+ * - missing severity sections → those sections contribute 0
58
+ * - lines that don't match the SPEC bullet shape → silently dropped
59
+ * - mixed unknown-file markers (`(unknown file)` vs absent)
60
+ *
61
+ * Parsing strategy: walk top-down, switch active severity on each
62
+ * `### <emoji> <Label>` header (one of the three SPEC §1.8.1 buckets),
63
+ * then collect every line starting with `- **` until the next header
64
+ * (or `---` end marker). Each `- **<file>:<line>** — <skill>: <summary>`
65
+ * line is split into its 4 fields; `:<line>` is optional (cross-cutting).
66
+ *
67
+ * `(unknown file)` files are preserved verbatim — they participate in
68
+ * identity, so the same cross-cutting finding can still be diffed across
69
+ * rounds even when neither pass has a line anchor.
70
+ */
71
+ export function parsePriorReviewFile(
72
+ markdown: string | null | undefined,
73
+ ): ParsedReview | null {
74
+ if (markdown == null) return null;
75
+ if (markdown.trim() === '') return null;
76
+
77
+ const lines = markdown.split('\n');
78
+ const out: ParsedFinding[] = [];
79
+ let current: ParsedFinding['severity'] | null = null;
80
+
81
+ for (const raw of lines) {
82
+ // Strip trailing whitespace; leading indent is meaningful for
83
+ // sub-lines (Reasoning, attribution) which we deliberately ignore.
84
+ const line = raw.replace(/\s+$/, '');
85
+
86
+ // The SPEC §1.8.1 trailing `---` separator ends the findings region.
87
+ // Anything after it (the [Link to PR] line) is metadata and parsed
88
+ // by short-circuit so we don't mistake the literal `---` for a
89
+ // missing header.
90
+ if (line === '---') {
91
+ current = null;
92
+ continue;
93
+ }
94
+
95
+ // Switch active severity on every `### <emoji> <Label>` heading.
96
+ // We pattern-match on the emoji codepoint (not the label text) so a
97
+ // future SPEC tweak to "Critical" → "Blocking" doesn't break us.
98
+ if (line.startsWith('### ')) {
99
+ if (line.includes(SEVERITY_EMOJI.critical)) {
100
+ current = 'critical';
101
+ } else if (line.includes(SEVERITY_EMOJI.minor)) {
102
+ current = 'minor';
103
+ } else if (line.includes(SEVERITY_EMOJI.preexisting)) {
104
+ current = 'preexisting';
105
+ } else {
106
+ current = null;
107
+ }
108
+ continue;
109
+ }
110
+
111
+ // Resolved / Still-open blocks under SPEC §1.8.1 — these list
112
+ // findings from PRIOR rounds, NOT this-round findings. Skip them
113
+ // so a multi-round PR doesn't double-count its own history.
114
+ // (`**Resolved this round:**` / `**Still open:**` headings.)
115
+ if (line.startsWith('**Resolved this round:') || line.startsWith('**Still open:')) {
116
+ current = null;
117
+ continue;
118
+ }
119
+
120
+ if (current == null) continue;
121
+
122
+ // SPEC §1.8.1 bullet: `- **<file>:<line>** — <skill>: <summary>`
123
+ // OR `- **<file>** — <skill>: <summary>`
124
+ // OR `- **(unknown file)** — <skill>: <summary>`
125
+ //
126
+ // The multi-pass renderer prepends `[Pass N — Role · model]` to the
127
+ // bullet; we accept that prefix and discard it for parsing.
128
+ const parsed = parseFindingBullet(line);
129
+ if (parsed) {
130
+ out.push({ ...parsed, severity: current });
131
+ }
132
+ }
133
+
134
+ if (out.length === 0) return null;
135
+ return { findings: out };
136
+ }
137
+
138
+ /**
139
+ * Diff prior vs current.
140
+ *
141
+ * `resolvedFindings`: findings that appeared in `prior` but NOT in
142
+ * `current`. The PR author (or auto-fix) addressed them.
143
+ *
144
+ * `stillOpenFindings`: findings that appeared in BOTH `prior` and
145
+ * `current`. These are persistent — the PR author hasn't fixed them
146
+ * (or the bot still considers them findings post-fix-push).
147
+ *
148
+ * Findings unique to `current` (newly raised this round) appear in
149
+ * neither list — those are surfaced directly by the renderer's normal
150
+ * severity-bucket emission.
151
+ *
152
+ * Order is preserved from `prior` for stability across rounds.
153
+ */
154
+ export function diffFindings(
155
+ prior: ParsedReview | null,
156
+ current: {
157
+ critical_findings?: Array<{
158
+ skill: string;
159
+ file?: string;
160
+ line?: number;
161
+ summary: string;
162
+ }>;
163
+ minor_findings?: Array<{
164
+ skill: string;
165
+ file?: string;
166
+ line?: number;
167
+ summary: string;
168
+ }>;
169
+ preexisting_findings?: Array<{
170
+ skill: string;
171
+ file?: string;
172
+ line?: number;
173
+ summary: string;
174
+ }>;
175
+ },
176
+ ): {
177
+ resolvedFindings: ParsedFinding[];
178
+ stillOpenFindings: ParsedFinding[];
179
+ } {
180
+ if (prior === null || prior.findings.length === 0) {
181
+ return { resolvedFindings: [], stillOpenFindings: [] };
182
+ }
183
+
184
+ // Build identity set for the current round. We do not need the
185
+ // current-round ParsedFinding objects — we only need to know which
186
+ // identities are still present.
187
+ //
188
+ // Cast through `Review` shape — the schema's three arrays mirror our
189
+ // input slice exactly (skill / file / line / summary), so we can lean
190
+ // on `flattenFindings` to produce a tagged list.
191
+ const currentReview: Review = {
192
+ status_header: 'clean',
193
+ summary_counts: {
194
+ critical: 0,
195
+ minor: 0,
196
+ preexisting: 0,
197
+ resolved_from_prior: 0,
198
+ still_open: 0,
199
+ },
200
+ per_skill_scan: [],
201
+ critical_findings: current.critical_findings ?? [],
202
+ minor_findings: current.minor_findings ?? [],
203
+ preexisting_findings: current.preexisting_findings ?? [],
204
+ skills_referenced: [],
205
+ last_reviewed_sha: '',
206
+ };
207
+ const currentFlat: Finding[] = flattenFindings(currentReview);
208
+ const currentIds = new Set<string>(currentFlat.map((f) => findingIdentity({
209
+ file: f.file ?? '(unknown file)',
210
+ line: f.line ?? 0,
211
+ severity: f.severity,
212
+ skillName: f.skill,
213
+ summary: f.summary,
214
+ })));
215
+
216
+ const resolved: ParsedFinding[] = [];
217
+ const stillOpen: ParsedFinding[] = [];
218
+ for (const f of prior.findings) {
219
+ const id = findingIdentity(f);
220
+ if (currentIds.has(id)) {
221
+ stillOpen.push(f);
222
+ } else {
223
+ resolved.push(f);
224
+ }
225
+ }
226
+ return { resolvedFindings: resolved, stillOpenFindings: stillOpen };
227
+ }
228
+
229
+ /**
230
+ * Stable identity for a finding. Used for diffing prior vs current
231
+ * across review rounds AND (by intention) shareable with the
232
+ * clud-bug-app inline-thread anchor hash so future cross-feature
233
+ * surfaces (e.g. "the auto-fix that resolved this thread also resolves
234
+ * this doc-file finding") align without re-computing.
235
+ *
236
+ * Exposed for tests + downstream callers that want to align their own
237
+ * finding storage on the same scheme.
238
+ */
239
+ export function findingIdentity(f: {
240
+ file: string;
241
+ line: number;
242
+ severity: 'critical' | 'minor' | 'preexisting';
243
+ skillName: string;
244
+ summary: string;
245
+ }): string {
246
+ // Truncate summary to 100 chars to absorb whitespace/punctuation
247
+ // drift between rounds without losing discrimination between
248
+ // genuinely-distinct findings (the SPEC §1.8.1 summary line is
249
+ // user-visible so it tends to be stable; 100 chars is enough for any
250
+ // realistic distinct summary while tolerating "fix the X" → "fix X"
251
+ // drift).
252
+ const summaryPart = f.summary.slice(0, 100);
253
+ return `${f.file}:${f.line}:${f.severity}:${f.skillName}:${summaryPart}`;
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Parsing internals
258
+ // ---------------------------------------------------------------------------
259
+
260
+ /**
261
+ * Parse one SPEC §1.8.1 bullet line into a {file, line, skill, summary}
262
+ * tuple. Returns null for any line that doesn't match the SPEC shape.
263
+ *
264
+ * Accepts both shapes:
265
+ * `- **<file>:<line>** — <skill>: <summary>`
266
+ * `- **<file>** — <skill>: <summary>`
267
+ *
268
+ * Also tolerates the multi-pass attribution prefix (D.2.5):
269
+ * `- [Pass 1 — Role · model] **<file>:<line>** — <skill>: <summary>`
270
+ *
271
+ * Em-dash recognition: the SPEC pins U+2014 EM DASH between location
272
+ * and skill. Some downstream tools have been observed using `--` or
273
+ * regular hyphens; we accept both for resilience.
274
+ */
275
+ function parseFindingBullet(line: string): Omit<ParsedFinding, 'severity'> | null {
276
+ // Strip the leading `- ` bullet marker.
277
+ if (!line.startsWith('- ')) return null;
278
+ let rest = line.slice(2);
279
+
280
+ // Strip optional `[Pass N — ...] ` attribution prefix (D.2.5).
281
+ if (rest.startsWith('[')) {
282
+ const closeIdx = rest.indexOf('] ');
283
+ if (closeIdx === -1) return null;
284
+ rest = rest.slice(closeIdx + 2);
285
+ }
286
+
287
+ // Expect `**<location>** ` next.
288
+ if (!rest.startsWith('**')) return null;
289
+ const locEnd = rest.indexOf('**', 2);
290
+ if (locEnd === -1) return null;
291
+ const location = rest.slice(2, locEnd);
292
+ rest = rest.slice(locEnd + 2);
293
+
294
+ // Strip the location separator. SPEC pins ` — ` (U+2014 surrounded by
295
+ // spaces). We accept hyphen-minus variants as a courtesy.
296
+ // The separator may be ` — `, ` -- `, or ` - `.
297
+ const sepMatch = rest.match(/^\s+(?:—|--|-)\s+/);
298
+ if (!sepMatch) return null;
299
+ rest = rest.slice(sepMatch[0].length);
300
+
301
+ // `<skill>: <summary>` — split on the first `: `.
302
+ const sepIdx = rest.indexOf(': ');
303
+ if (sepIdx === -1) return null;
304
+ const skillName = rest.slice(0, sepIdx).trim();
305
+ const summary = rest.slice(sepIdx + 2).trim();
306
+ if (skillName === '' || summary === '') return null;
307
+
308
+ // Split location into file + optional line. Walk RIGHTWARD from the
309
+ // last `:` so file names containing colons (Windows-style, rare) on
310
+ // the LHS don't confuse us.
311
+ const colonIdx = location.lastIndexOf(':');
312
+ let file = location;
313
+ let lineNum = 0;
314
+ if (colonIdx !== -1) {
315
+ const tail = location.slice(colonIdx + 1);
316
+ if (/^\d+$/.test(tail)) {
317
+ file = location.slice(0, colonIdx);
318
+ lineNum = Number(tail);
319
+ }
320
+ }
321
+
322
+ return { file, line: lineNum, skillName, summary };
323
+ }
@@ -0,0 +1,168 @@
1
+ // SPEC §7.2.1 formal-review event selector.
2
+ //
3
+ // This is the PURE half of clud-bug-app's `lib/formal-review.ts` (the
4
+ // Octokit-side `postFormalReview` IO wrapper stays App-side — it depends
5
+ // on `getInstallationOctokit` + `@octokit/rest` which we don't want to
6
+ // pull into core). The pure rule-table lives here so:
7
+ //
8
+ // 1. The npm workflow template's new post-step (added in v0.7.0-rc.3)
9
+ // can `import { selectReviewEvent } from 'clud-bug/core'` and post
10
+ // formal `pulls.createReview` calls under the workflow path — until
11
+ // this PR shipped, the workflow path NEVER satisfied the canonical
12
+ // ruleset's `required_approving_review_count: 1` floor because no
13
+ // APPROVE review was ever posted.
14
+ //
15
+ // 2. clud-bug-app (Phase 7 PR B) deletes its local copy and imports
16
+ // this version, gaining the §7.2.1 author_association extension
17
+ // (clud-bug-app's PR #40 shipped APPROVE without it, so the App
18
+ // currently auto-approves drive-by external-contributor PRs — a
19
+ // security bug closed by Phase 7 PR B's dep bump).
20
+ //
21
+ // Ported from clud-bug-app/lib/formal-review.ts (PR #40, MERGED 2026-06-10)
22
+ // with the §7.2.1 `authorAssociation` extension new in Phase 7 PR A.
23
+
24
+ /** GitHub's `pulls.createReview` `event` enum, narrowed to the three we use. */
25
+ export type FormalReviewEvent = 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT';
26
+
27
+ /**
28
+ * GitHub `author_association` on a PR. Verbatim union from the REST API
29
+ * (per https://docs.github.com/en/rest/pulls/pulls — `author_association`).
30
+ *
31
+ * The "external" tier for §7.2.1 auto-approve gating is:
32
+ *
33
+ * { 'NONE', 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'MANNEQUIN' }
34
+ *
35
+ * Everything else (OWNER, MEMBER, COLLABORATOR, CONTRIBUTOR) is the
36
+ * "org-trusted" tier and eligible for auto-approve on a clean review.
37
+ *
38
+ * `FIRST_TIMER` is the legacy spelling (October 2018 era); GitHub
39
+ * currently emits `FIRST_TIME_CONTRIBUTOR` but some PR fixtures in
40
+ * downstream tests still carry the old token. We keep both so the
41
+ * external-contributor gate doesn't silently regress on the next
42
+ * REST-API rename.
43
+ */
44
+ export type AuthorAssociation =
45
+ | 'OWNER'
46
+ | 'MEMBER'
47
+ | 'COLLABORATOR'
48
+ | 'CONTRIBUTOR'
49
+ | 'FIRST_TIME_CONTRIBUTOR'
50
+ | 'FIRST_TIMER'
51
+ | 'NONE'
52
+ | 'MANNEQUIN';
53
+
54
+ /**
55
+ * Author-association values that route a clean-review APPROVE *down* to
56
+ * COMMENT per SPEC §7.2.1 precondition #3 ("PR author is an org member —
57
+ * NOT a first-time external contributor"). These authors still get a
58
+ * COMMENT review (so they SEE the bot's verdict) but the formal APPROVE
59
+ * vote stays withheld — a human reviewer must click Approve before the
60
+ * PR can merge under the canonical ruleset.
61
+ *
62
+ * Drive-by exploitation prevention: a malicious external contributor
63
+ * who opens a clean-looking PR cannot leverage clud-bug[bot]'s APPROVE
64
+ * vote to auto-merge.
65
+ */
66
+ const EXTERNAL_ASSOCIATIONS: ReadonlySet<AuthorAssociation> = new Set<
67
+ AuthorAssociation
68
+ >(['NONE', 'FIRST_TIME_CONTRIBUTOR', 'FIRST_TIMER', 'MANNEQUIN']);
69
+
70
+ /**
71
+ * SPEC §7.2.1 + §7.2 rule table.
72
+ *
73
+ * Order matters — earlier rows short-circuit later ones:
74
+ *
75
+ * | Priority | Condition | Event |
76
+ * |----------|--------------------------------------------------------|-----------------|
77
+ * | 1 | PR author === clud-bug[bot] | 'skip' |
78
+ * | 2 | authorAssociation ∈ EXTERNAL | 'COMMENT' |
79
+ * | 3 | criticalCount > 0 AND strictMode=true | REQUEST_CHANGES |
80
+ * | 4 | criticalCount > 0 AND strictMode=false | COMMENT |
81
+ * | 5 | minorCount > 0 (no critical) | COMMENT |
82
+ * | 6 | 0 critical + 0 minor | APPROVE |
83
+ *
84
+ * The external-contributor row (Priority 2) sits BETWEEN self-PR-skip
85
+ * and the severity-driven rules: external contributors who open a
86
+ * critical-finding PR still get COMMENT (NOT REQUEST_CHANGES — we don't
87
+ * block their PR on a bot review; that's a human reviewer's call to
88
+ * make). External contributors who open a clean PR also get COMMENT
89
+ * (NOT APPROVE — the §7.2.1 precondition #3 gate).
90
+ *
91
+ * The 'skip' verdict tells the caller NOT to invoke `pulls.createReview`
92
+ * — GitHub returns 422 when a user reviews their own PR, so we
93
+ * short-circuit before the network round-trip. Self-PRs land during D.7
94
+ * migration fan-out (the App opens cross-repo update PRs under its own
95
+ * identity).
96
+ */
97
+ export interface SelectReviewEventInput {
98
+ /** Count of `severity: critical` findings on the review. */
99
+ criticalCount: number;
100
+ /** Count of `severity: minor` findings on the review. */
101
+ minorCount: number;
102
+ /**
103
+ * `strictMode` flag read from `.clud-bug.json` at the PR's BASE ref.
104
+ * Older manifests may not carry this field — callers MUST pass
105
+ * `undefined` in that case so this function applies the safe default
106
+ * (false) rather than surprising users with REQUEST_CHANGES.
107
+ */
108
+ strictMode?: boolean;
109
+ /**
110
+ * GitHub login of the PR author. When it equals 'clud-bug[bot]' we
111
+ * skip the formal review entirely (GitHub disallows self-review with
112
+ * 422). This is the structural guard for D.7 migration fan-out PRs.
113
+ */
114
+ prAuthorLogin: string;
115
+ /**
116
+ * GitHub `author_association` on the PR. NEW in v0.7.0-rc.3 / SPEC
117
+ * §7.2.1: when this is in EXTERNAL_ASSOCIATIONS, a clean review gets
118
+ * COMMENT (not APPROVE) so external-contributor PRs require a human
119
+ * reviewer to satisfy the `required_approving_review_count: 1` floor.
120
+ *
121
+ * Callers that don't have this metadata (older webhook payloads,
122
+ * tests, etc.) should pass `'CONTRIBUTOR'` as the safe default — that
123
+ * tier is org-trusted and preserves pre-§7.2.1 behaviour.
124
+ */
125
+ authorAssociation: AuthorAssociation;
126
+ }
127
+
128
+ export function selectReviewEvent(
129
+ input: SelectReviewEventInput,
130
+ ): FormalReviewEvent | 'skip' {
131
+ // Priority 1: self-PR guard. GitHub returns 422 on a self-review;
132
+ // we short-circuit before the network round-trip. Self-PRs land
133
+ // during D.7 migration fan-out (the App opens cross-repo update PRs
134
+ // under its own identity).
135
+ if (input.prAuthorLogin === 'clud-bug[bot]') {
136
+ return 'skip';
137
+ }
138
+
139
+ // Priority 2: external-contributor gate (SPEC §7.2.1 precondition #3).
140
+ // External contributors NEVER get APPROVE (no auto-merge bypass via
141
+ // drive-by) and NEVER get REQUEST_CHANGES (we don't block their PR on
142
+ // a bot review — that escalation is a human reviewer's call). They
143
+ // always get an advisory COMMENT so they see the bot's verdict.
144
+ if (EXTERNAL_ASSOCIATIONS.has(input.authorAssociation)) {
145
+ return 'COMMENT';
146
+ }
147
+
148
+ // Priority 6 (note: priorities 3-5 fall through to here when there
149
+ // are no findings): clean review on an org-trusted author → APPROVE.
150
+ // APPROVE flips the `required_approving_review_count: 1` ruleset and
151
+ // lets auto-merge fire under the canonical SPEC §7.2 ruleset.
152
+ if (input.criticalCount === 0 && input.minorCount === 0) {
153
+ return 'APPROVE';
154
+ }
155
+
156
+ // Priority 3 + 4: critical finding → gate on strictMode. Default
157
+ // `strictMode === undefined` → false (advisory-only). REQUEST_CHANGES
158
+ // blocks the PR until the author dismisses or fixes; we only honor
159
+ // it when the repo opted in.
160
+ if (input.criticalCount > 0) {
161
+ return input.strictMode === true ? 'REQUEST_CHANGES' : 'COMMENT';
162
+ }
163
+
164
+ // Priority 5: minor-only or preexisting-only finding → advisory
165
+ // COMMENT. We never REQUEST_CHANGES on a minor — those are noted,
166
+ // not blocking.
167
+ return 'COMMENT';
168
+ }