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
package/src/cli/update.ts CHANGED
@@ -5,6 +5,7 @@ import { reviewPrompt } from '../core/prompts.js';
5
5
  import { detect, buildDescriptionLine } from '../core/detect.js';
6
6
  import { loadBaseline, readManifest, writeManifest, type LoadBaselineOptions } from './skills.js';
7
7
  import { applyToRepo as applyAgentDocs } from './agents-md.js';
8
+ import { mergeLocalReviewHook, buildCommitReviewCommand, CLUD_BUG_HOOK_MARKER } from './hooks.js';
8
9
 
9
10
  // Re-render the user's workflow + refresh baseline skills using the
10
11
  // templates / baseline shipped with the currently-installed clud-bug.
@@ -67,17 +68,25 @@ export async function runUpdate(opts: RunUpdateOptions): Promise<RunUpdateResult
67
68
  const unchanged: UpdateChangeRecord[] = [];
68
69
  const skipped: UpdateSkippedRecord[] = [];
69
70
 
70
- // 1. Re-render review workflow with the latest template.
71
- const signals = await detect(cwd);
72
- const tmplName = pickTemplate(signals.languages);
73
- // REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
74
- const newReview = await renderFile(join(templatesDir, tmplName), {
75
- REVIEW_PROMPT: reviewPrompt({
76
- projectDescription: buildDescriptionLine(signals),
77
- language: templateLanguage(tmplName),
78
- }),
79
- });
80
- await maybeRefreshVersioned(join(cwd, '.github/workflows/clud-bug-review.yml'), newReview, changed, unchanged, skipped, 'review workflow');
71
+ // 1. Re-render the review workflow with the latest template — ONLY if it is
72
+ // already installed. A `--local-only` (max-mode) repo has no review
73
+ // workflow and must NOT have one created by `update`: that would
74
+ // re-introduce the ANTHROPIC_API_KEY Action the local install deliberately
75
+ // skips (dogfood caught `update` doing exactly this). Mirrors the
76
+ // pathExists-gating the audit + self-update workflows already use below.
77
+ const reviewPath = join(cwd, '.github/workflows/clud-bug-review.yml');
78
+ if (await pathExists(reviewPath)) {
79
+ const signals = await detect(cwd);
80
+ const tmplName = pickTemplate(signals.languages);
81
+ // REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
82
+ const newReview = await renderFile(join(templatesDir, tmplName), {
83
+ REVIEW_PROMPT: reviewPrompt({
84
+ projectDescription: buildDescriptionLine(signals),
85
+ language: templateLanguage(tmplName),
86
+ }),
87
+ });
88
+ await maybeRefreshVersioned(reviewPath, newReview, changed, unchanged, skipped, 'review workflow');
89
+ }
81
90
 
82
91
  // 2. Re-render audit workflow if it's installed (init from v0.3+ ships it).
83
92
  // Routed through renderFile (was raw readFile pre-v0.5.11) so
@@ -143,6 +152,46 @@ export async function runUpdate(opts: RunUpdateOptions): Promise<RunUpdateResult
143
152
  for (const p of agentDocs.created) changed.push({ path: join(cwd, p), label: `agent docs: created ${p}` });
144
153
  for (const p of agentDocs.touched) changed.push({ path: join(cwd, p), label: `agent docs: ${p}` });
145
154
 
155
+ // 5b. Refresh the local-review slash command (Wave 6b) when it was scaffolded
156
+ // via `clud-bug init --with-local-review`. Only files carrying the
157
+ // `<!-- clud-bug-local-version:` marker are refreshed; a markerless file
158
+ // is user-owned (hand-customized) and left untouched.
159
+ const localReviewPath = join(cwd, '.claude', 'commands', 'clud-bug-review.md');
160
+ if (await pathExists(localReviewPath)) {
161
+ const prior = await readSafe(localReviewPath);
162
+ if (prior && prior.includes('<!-- clud-bug-local-version:')) {
163
+ const newCommand = await renderFile(join(templatesDir, 'clud-bug-review.md.tmpl'), {});
164
+ await maybeWrite(localReviewPath, newCommand, changed, unchanged, 'local-review slash command');
165
+ } else {
166
+ skipped.push({
167
+ path: localReviewPath,
168
+ label: 'local-review slash command',
169
+ reason: 'markerless (user-customized); delete + `clud-bug init --with-local-review` to refresh',
170
+ });
171
+ }
172
+ }
173
+
174
+ // 5c. Refresh the native commit-review hook (Wave 6b) in place when it was
175
+ // scaffolded via `clud-bug init --with-hooks` and our entry is intact (the
176
+ // `clud-bug-local-review` marker). settings.json is user-managed — we only
177
+ // re-merge OUR marked hook, never touching the user's other hooks/settings.
178
+ const settingsPath = join(cwd, '.claude', 'settings.json');
179
+ if (await pathExists(settingsPath)) {
180
+ const prior = await readSafe(settingsPath);
181
+ if (prior && prior.includes(CLUD_BUG_HOOK_MARKER)) {
182
+ try {
183
+ const merged = mergeLocalReviewHook(JSON.parse(prior), buildCommitReviewCommand());
184
+ await maybeWrite(settingsPath, JSON.stringify(merged, null, 2) + '\n', changed, unchanged, 'commit-review hook');
185
+ } catch {
186
+ skipped.push({
187
+ path: settingsPath,
188
+ label: 'commit-review hook',
189
+ reason: 'settings.json is not valid JSON; left untouched',
190
+ });
191
+ }
192
+ }
193
+ }
194
+
146
195
  // 6. Stamp the manifest with the version that ran the update.
147
196
  manifest['lastUpdate'] = new Date().toISOString();
148
197
  manifest['lastUpdateVersion'] = ourVersion;
@@ -0,0 +1,488 @@
1
+ // Wave 5b — D.2.6 auto-resolve fidelity, npm-workflow flavor.
2
+ //
3
+ // On every fix-push (`pull_request.synchronize`), for each open thread
4
+ // posted by the bot on a prior pass, decide whether the new commit
5
+ // addressed the original concern, and act accordingly:
6
+ //
7
+ // ADDRESSED → resolve the thread + post "Auto-resolved (verified)"
8
+ // NOT_ADDRESSED → keep open + post "Re-review found this still applies"
9
+ // + flag REQUEST_CHANGES intent if 🔴 critical
10
+ // UNCERTAIN + 🟡 → keep open + post "human review recommended"
11
+ // UNCERTAIN + 🔴 → keep open + flag REQUEST_CHANGES + escalate. NEVER dismiss.
12
+ //
13
+ // This module owns the pure rule tables + config merge + marker
14
+ // rendering. The CLI verb in `src/cli/main.ts::runResolveThreads` owns
15
+ // the IO: fetching threads via `gh api graphql`, calling Anthropic
16
+ // Messages API for the verifier, applying these actions via REST/
17
+ // GraphQL mutations.
18
+ //
19
+ // Ported from `clud-bug-app/lib/auto-resolve.ts` (and the marker
20
+ // helpers from `clud-bug-app/lib/comment.ts`). Differences from the
21
+ // App impl:
22
+ // - DROP `aggregateMultiPassVerdicts` — OSS workflow is single-pass.
23
+ // - DROP heuristic fallback (`heuristicAction`) — OSS supports only
24
+ // `mode: 'verified' | 'off'` for now; heuristic adds complexity
25
+ // without a customer ask. Future Wave 5c can add it back if the
26
+ // App migrates to consume from core.
27
+ // - DROP per-PR budget gate — OSS workflow uses the customer's own
28
+ // `ANTHROPIC_API_KEY`; spend-cap is their concern, not ours.
29
+
30
+ import { createHash } from 'node:crypto';
31
+
32
+ import type { Severity } from './inline-threads.js';
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Public types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** Configuration for `autoResolve` in `.clud-bug.json`. */
39
+ export interface AutoResolveConfig {
40
+ /**
41
+ * Which mode the install opted into.
42
+ * - 'verified' (default): call the verifier per thread.
43
+ * - 'off': do nothing — all threads stay open, no verifier calls.
44
+ */
45
+ mode: 'verified' | 'off';
46
+ /**
47
+ * Action to take when the verifier returns UNCERTAIN + the original
48
+ * finding was 🔴 critical.
49
+ *
50
+ * - 'request_changes' (default): keep open + flag REQUEST_CHANGES.
51
+ * - 'leave_open': keep open with a milder marker, no escalation.
52
+ */
53
+ uncertain_critical_action: 'request_changes' | 'leave_open';
54
+ }
55
+
56
+ export const DEFAULT_AUTO_RESOLVE_CONFIG: AutoResolveConfig = {
57
+ mode: 'verified',
58
+ uncertain_critical_action: 'request_changes',
59
+ };
60
+
61
+ /**
62
+ * One open thread from a prior review pass, plus enough state to verify
63
+ * it. The CLI verb hands these to `runAutoResolve` after parsing the
64
+ * GraphQL response + the bot's comment-body markers.
65
+ */
66
+ export interface PriorThread {
67
+ /** GitHub review-thread node ID (GraphQL ID). Used to resolve the thread. */
68
+ threadId: string;
69
+ /** The prior finding the thread anchors. */
70
+ finding: PriorFinding;
71
+ /** Code at the finding anchor BEFORE the fix-push. */
72
+ codeBefore: string;
73
+ /** Code at the finding anchor AFTER the fix-push. */
74
+ codeAfter: string;
75
+ /** Diff hunk at the anchor (optional). */
76
+ diffAtAnchor?: string;
77
+ }
78
+
79
+ /**
80
+ * The minimal shape `applyResolutionRules` + the verifier need to
81
+ * describe a prior finding. The CLI reconstructs this from
82
+ * `parseThreadBody(comment.body)` + the GraphQL `path`/`line` fields.
83
+ */
84
+ export interface PriorFinding {
85
+ severity: Severity;
86
+ /** The summary line + (optional) reasoning, joined for the verifier prompt. */
87
+ body: string;
88
+ /** The skill slug that raised the finding. */
89
+ skill: string;
90
+ /** Repo-relative path. */
91
+ file: string;
92
+ /** Optional 1-indexed line number. */
93
+ line?: number;
94
+ }
95
+
96
+ /** What the CLI verb should do with the thread. */
97
+ export type ThreadAction =
98
+ | { kind: 'resolve'; markerBody: string; verdict: VerifyOutcome }
99
+ | { kind: 'keep_open'; markerBody: string; verdict: VerifyOutcome }
100
+ | {
101
+ kind: 'keep_open_request_changes';
102
+ markerBody: string;
103
+ verdict: VerifyOutcome;
104
+ /** True when triggered by UNCERTAIN+🔴 escalation. */
105
+ escalated: boolean;
106
+ }
107
+ | {
108
+ kind: 'skipped';
109
+ reason: 'off';
110
+ thread?: PriorThread;
111
+ };
112
+
113
+ /** Verdict shape — re-exported from resolve-verifier for ergonomics. */
114
+ export interface VerifyOutcome {
115
+ verdict: 'ADDRESSED' | 'NOT_ADDRESSED' | 'UNCERTAIN';
116
+ rationale: string;
117
+ source: 'model' | 'api-error';
118
+ }
119
+
120
+ export interface AutoResolveInput {
121
+ /** Open threads from prior review passes. May be empty. */
122
+ priorThreads: PriorThread[];
123
+ /** Resolved config (after precedence merge). */
124
+ config: AutoResolveConfig;
125
+ /**
126
+ * Whether the caller can actually resolve threads (a dedicated PAT is
127
+ * present). Drives the addressed-marker wording: PAT → "Auto-resolved";
128
+ * no-PAT → "Verified fixed — not auto-closed". Defaults to true (the App
129
+ * path / unit tests, where the App token can resolve).
130
+ */
131
+ canResolve?: boolean;
132
+ /** Verifier callback. Tests inject a stub; CLI provides real Anthropic-call fn. */
133
+ verifier: (args: {
134
+ finding: PriorFinding;
135
+ codeBefore: string;
136
+ codeAfter: string;
137
+ diffAtAnchor?: string;
138
+ }) => Promise<VerifyOutcome>;
139
+ }
140
+
141
+ export interface AutoResolveResult {
142
+ /** One action per input thread, in input order. */
143
+ actions: ThreadAction[];
144
+ /** Total verifier calls made. */
145
+ verifierCallCount: number;
146
+ /**
147
+ * True when any action is `keep_open_request_changes`. The CLI verb /
148
+ * workflow uses this to decide whether to flip the formal review to
149
+ * REQUEST_CHANGES (one transition covers all flagged threads).
150
+ */
151
+ shouldRequestChanges: boolean;
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // Config resolution
156
+ // ---------------------------------------------------------------------------
157
+
158
+ /**
159
+ * Reads `autoResolve` from a `.clud-bug.json` payload + merges with
160
+ * built-in defaults. Returns a fully-resolved `AutoResolveConfig`.
161
+ *
162
+ * Unknown fields tolerated (forward compat). Invalid values fall back
163
+ * to the default for that key + log a warning. NEVER throws — a
164
+ * config-validation throw on the fix-push hot path would degrade the
165
+ * whole step to a fail.
166
+ */
167
+ export function resolveAutoResolveConfig(
168
+ raw: unknown,
169
+ onWarn: (msg: string) => void = () => {},
170
+ ): AutoResolveConfig {
171
+ const defaults = DEFAULT_AUTO_RESOLVE_CONFIG;
172
+ if (!raw || typeof raw !== 'object') return defaults;
173
+ const obj = raw as Record<string, unknown>;
174
+
175
+ let mode: AutoResolveConfig['mode'] = defaults.mode;
176
+ if (typeof obj.mode === 'string') {
177
+ if (obj.mode === 'verified' || obj.mode === 'off') {
178
+ mode = obj.mode;
179
+ } else {
180
+ onWarn(
181
+ `autoResolve.mode = ${JSON.stringify(obj.mode)} is invalid; falling back to "${defaults.mode}". Valid: verified, off.`,
182
+ );
183
+ }
184
+ }
185
+
186
+ let uncertainCritical: AutoResolveConfig['uncertain_critical_action'] =
187
+ defaults.uncertain_critical_action;
188
+ if (typeof obj.uncertain_critical_action === 'string') {
189
+ if (
190
+ obj.uncertain_critical_action === 'request_changes' ||
191
+ obj.uncertain_critical_action === 'leave_open'
192
+ ) {
193
+ uncertainCritical = obj.uncertain_critical_action;
194
+ } else {
195
+ onWarn(
196
+ `autoResolve.uncertain_critical_action = ${JSON.stringify(obj.uncertain_critical_action)} is invalid; falling back to "${defaults.uncertain_critical_action}".`,
197
+ );
198
+ }
199
+ }
200
+
201
+ return {
202
+ mode,
203
+ uncertain_critical_action: uncertainCritical,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Convenience: extracts the `autoResolve` block from a top-level
209
+ * `.clud-bug.json` config blob.
210
+ */
211
+ export function readAutoResolveConfigFromCludBug(
212
+ config: { autoResolve?: unknown } | null | undefined,
213
+ onWarn?: (msg: string) => void,
214
+ ): AutoResolveConfig {
215
+ if (!config) return DEFAULT_AUTO_RESOLVE_CONFIG;
216
+ return resolveAutoResolveConfig(config.autoResolve, onWarn);
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Main entry point — pure orchestration with injected verifier
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * For each prior thread, call the injected verifier + apply the
225
+ * resolution rules. Returns one action per input thread, plus
226
+ * aggregate stats.
227
+ *
228
+ * The CLI verb executes the returned actions (GraphQL mutations);
229
+ * this function is pure modulo the verifier callback.
230
+ */
231
+ export async function runAutoResolve(
232
+ input: AutoResolveInput,
233
+ ): Promise<AutoResolveResult> {
234
+ if (input.config.mode === 'off') {
235
+ return {
236
+ actions: input.priorThreads.map((thread) => ({
237
+ kind: 'skipped' as const,
238
+ reason: 'off' as const,
239
+ thread,
240
+ })),
241
+ verifierCallCount: 0,
242
+ shouldRequestChanges: false,
243
+ };
244
+ }
245
+
246
+ const actions: ThreadAction[] = [];
247
+ let verifierCallCount = 0;
248
+ let shouldRequestChanges = false;
249
+
250
+ for (const thread of input.priorThreads) {
251
+ // Conditionally include `diffAtAnchor` only when present so the
252
+ // narrowed `verifier` callback type (which forbids `undefined`
253
+ // under `exactOptionalPropertyTypes`) stays satisfied.
254
+ const verifierArgs: Parameters<typeof input.verifier>[0] = {
255
+ finding: thread.finding,
256
+ codeBefore: thread.codeBefore,
257
+ codeAfter: thread.codeAfter,
258
+ ...(thread.diffAtAnchor !== undefined
259
+ ? { diffAtAnchor: thread.diffAtAnchor }
260
+ : {}),
261
+ };
262
+ const verdict = await input.verifier(verifierArgs);
263
+ verifierCallCount++;
264
+ const action = applyResolutionRules({
265
+ thread,
266
+ verdict,
267
+ config: input.config,
268
+ canResolve: input.canResolve !== false,
269
+ });
270
+ actions.push(action);
271
+ if (action.kind === 'keep_open_request_changes') shouldRequestChanges = true;
272
+ }
273
+
274
+ return { actions, verifierCallCount, shouldRequestChanges };
275
+ }
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // Rule table — pure function from (verdict, finding, config) → action
279
+ // ---------------------------------------------------------------------------
280
+
281
+ /**
282
+ * Maps a verifier verdict + the thread context to the action the CLI
283
+ * verb should take. Pure function, no I/O.
284
+ *
285
+ * Resolution table:
286
+ * ADDRESSED, any → resolve, "Auto-resolved (verified)"
287
+ * NOT_ADDRESSED, 🔴 → keep_open_request_changes (escalated=false)
288
+ * NOT_ADDRESSED, 🟡 → keep_open, "Re-review found this still applies"
289
+ * UNCERTAIN, 🔴, request → keep_open_request_changes (escalated=true)
290
+ * UNCERTAIN, 🔴, leave → keep_open, "Auto-resolve uncertain — human review recommended"
291
+ * UNCERTAIN, 🟡 → keep_open, "human review recommended"
292
+ */
293
+ export function applyResolutionRules(args: {
294
+ thread: PriorThread;
295
+ verdict: VerifyOutcome;
296
+ config: AutoResolveConfig;
297
+ /** Whether the resolve mutation is available (PAT present). Defaults true. */
298
+ canResolve?: boolean;
299
+ }): ThreadAction {
300
+ const { verdict, thread, config } = args;
301
+ const canResolve = args.canResolve !== false;
302
+ const severity = thread.finding.severity;
303
+
304
+ if (verdict.verdict === 'ADDRESSED') {
305
+ return {
306
+ kind: 'resolve',
307
+ markerBody: renderAutoResolveMarker({
308
+ kind: 'verified-addressed',
309
+ rationale: verdict.rationale,
310
+ resolved: canResolve,
311
+ }),
312
+ verdict,
313
+ };
314
+ }
315
+
316
+ if (verdict.verdict === 'NOT_ADDRESSED') {
317
+ const markerBody = renderAutoResolveMarker({
318
+ kind: 'verified-not-addressed',
319
+ rationale: verdict.rationale,
320
+ });
321
+ if (severity === 'critical') {
322
+ return {
323
+ kind: 'keep_open_request_changes',
324
+ markerBody,
325
+ verdict,
326
+ escalated: false,
327
+ };
328
+ }
329
+ return { kind: 'keep_open', markerBody, verdict };
330
+ }
331
+
332
+ // UNCERTAIN.
333
+ const markerBody = renderAutoResolveMarker({
334
+ kind: 'verified-uncertain',
335
+ rationale: verdict.rationale,
336
+ severity,
337
+ });
338
+ if (
339
+ severity === 'critical' &&
340
+ config.uncertain_critical_action === 'request_changes'
341
+ ) {
342
+ return {
343
+ kind: 'keep_open_request_changes',
344
+ markerBody,
345
+ verdict,
346
+ escalated: true,
347
+ };
348
+ }
349
+ return { kind: 'keep_open', markerBody, verdict };
350
+ }
351
+
352
+ // ---------------------------------------------------------------------------
353
+ // Marker rendering — ported from clud-bug-app/lib/comment.ts
354
+ // ---------------------------------------------------------------------------
355
+
356
+ type AutoResolveMarkerInput =
357
+ | { kind: 'verified-addressed'; rationale?: string; resolved?: boolean }
358
+ | { kind: 'verified-not-addressed'; rationale?: string }
359
+ | { kind: 'verified-uncertain'; rationale?: string; severity: Severity };
360
+
361
+ /**
362
+ * Renders the comment body the CLI verb posts as a reply on the
363
+ * thread before resolving (or alongside keep-open). Wording mirrors
364
+ * the App's `renderAutoResolveMarker` so both consumers emit a
365
+ * uniform audit trail.
366
+ */
367
+ export function renderAutoResolveMarker(input: AutoResolveMarkerInput): string {
368
+ const tail = input.rationale ? `: ${input.rationale.trim()}` : '.';
369
+ switch (input.kind) {
370
+ case 'verified-addressed':
371
+ // No PAT → we did NOT resolve the thread, so don't claim "Auto-resolved".
372
+ // Post an accurate "verified fixed" badge + the manual-Resolve hint.
373
+ if (input.resolved === false) {
374
+ return (
375
+ `**✅ Verified fixed — not auto-closed**${tail} ` +
376
+ `_The Actions \`GITHUB_TOKEN\` can't resolve review threads; click ` +
377
+ `**Resolve conversation** to dismiss, or add a \`CLUD_BUG_RESOLVE_PAT\` ` +
378
+ `secret to auto-close (see README)._`
379
+ );
380
+ }
381
+ return `**✓ Auto-resolved (verified by D.2.6 fix-check)**${tail}`;
382
+ case 'verified-not-addressed':
383
+ return `**❌ Re-review found this still applies**${tail}`;
384
+ case 'verified-uncertain':
385
+ if (input.severity === 'critical') {
386
+ return `**⚠ Auto-resolve uncertain on a 🔴 critical — escalated, never silently dismissed**${tail}`;
387
+ }
388
+ return `**⚠ Auto-resolve uncertain — human review recommended**${tail}`;
389
+ }
390
+ }
391
+
392
+ // ---------------------------------------------------------------------------
393
+ // Wave 5b.1 (rc.9) — graceful PAT-or-fallback resolve + idempotency
394
+ // ---------------------------------------------------------------------------
395
+
396
+ /** Which token the resolve mutation should use, and whether it's a dedicated PAT. */
397
+ export interface ResolveAuth {
398
+ token: string;
399
+ source: 'pat' | 'gh_token';
400
+ hasDedicatedPat: boolean;
401
+ }
402
+
403
+ /**
404
+ * Picks the token for the `resolveReviewThread` GraphQL mutation. A dedicated
405
+ * fine-grained PAT (`CLUD_BUG_RESOLVE_PAT`, or the `RESOLVE_PAT` alias) is
406
+ * preferred — the Actions `GITHUB_TOKEN` can't resolve review threads
407
+ * ("Resource not accessible by integration"). With no PAT the verb keeps using
408
+ * `GH_TOKEN` for the other gh calls and skips the resolve mutation entirely.
409
+ *
410
+ * Empty / whitespace-only values are treated as absent — GitHub renders an
411
+ * unset secret as the empty string, so `CLUD_BUG_RESOLVE_PAT=''` must NOT
412
+ * shadow a hand-set `RESOLVE_PAT` alias.
413
+ */
414
+ export function selectResolveAuth(env: {
415
+ CLUD_BUG_RESOLVE_PAT?: string;
416
+ RESOLVE_PAT?: string;
417
+ GH_TOKEN?: string;
418
+ }): ResolveAuth {
419
+ const pat =
420
+ [env.CLUD_BUG_RESOLVE_PAT, env.RESOLVE_PAT]
421
+ .map((v) => (v ?? '').trim())
422
+ .find((v) => v !== '') ?? '';
423
+ if (pat) return { token: pat, source: 'pat', hasDedicatedPat: true };
424
+ return { token: (env.GH_TOKEN ?? '').trim(), source: 'gh_token', hasDedicatedPat: false };
425
+ }
426
+
427
+ /** Verifier verdict, as embedded in the idempotency marker. */
428
+ export type ResolveVerdict = 'ADDRESSED' | 'NOT_ADDRESSED' | 'UNCERTAIN';
429
+
430
+ const RESOLVE_MARKER_RE =
431
+ /<!--\s*clud-bug-resolve\s+v=(ADDRESSED|NOT_ADDRESSED|UNCERTAIN)\s+sig=([a-f0-9]{16})\s*-->/;
432
+
433
+ /**
434
+ * Hidden HTML-comment marker appended to each auto-resolve reply so a later
435
+ * fix-push can tell whether it already replied to this thread for the current
436
+ * (verdict, anchor) — making the reply idempotent instead of one-per-push.
437
+ */
438
+ export function renderResolveMarkerTag(verdict: ResolveVerdict, sig: string): string {
439
+ return `<!-- clud-bug-resolve v=${verdict} sig=${sig} -->`;
440
+ }
441
+
442
+ /** Parses the hidden marker out of a comment body. Null when absent / malformed. */
443
+ export function parseResolveMarkerTag(
444
+ body: string,
445
+ ): { verdict: ResolveVerdict; sig: string } | null {
446
+ const m = body.match(RESOLVE_MARKER_RE);
447
+ if (!m || m[1] === undefined || m[2] === undefined) return null;
448
+ return { verdict: m[1] as ResolveVerdict, sig: m[2] };
449
+ }
450
+
451
+ /**
452
+ * 8-hex-char signature of a finding's post-fix anchor. Stable while the anchor
453
+ * is unchanged, changes when `codeAfter` changes — lets the verb skip
454
+ * re-verifying + re-replying to a thread whose anchor hasn't moved.
455
+ */
456
+ export function anchorSignature(
457
+ finding: { file?: string; line?: number },
458
+ codeAfter: string,
459
+ ): string {
460
+ const file = finding.file ?? '<no-file>';
461
+ const line = finding.line ?? 0;
462
+ // 16 hex chars (64-bit), matching `findingId`'s convention — wide enough
463
+ // that a collision can't suppress a re-review marker.
464
+ return createHash('sha256').update(`${file}:${line}:${codeAfter}`).digest('hex').slice(0, 16);
465
+ }
466
+
467
+ /**
468
+ * Scans a thread's comments (oldest→newest, as GraphQL returns them) for the
469
+ * most-recent bot-authored reply carrying a resolve marker. Returns its
470
+ * `{verdict, sig}` or null. Only bot-authored comments count, so a user pasting
471
+ * a marker can't spoof the idempotency state.
472
+ */
473
+ export function latestResolveMarker(
474
+ comments: Array<
475
+ { author?: { login?: string | null } | null; body?: string | null } | null | undefined
476
+ >,
477
+ botAuthors: Set<string>,
478
+ ): { verdict: ResolveVerdict; sig: string } | null {
479
+ let latest: { verdict: ResolveVerdict; sig: string } | null = null;
480
+ for (const c of comments) {
481
+ if (!c) continue;
482
+ const login = c.author?.login ?? '';
483
+ if (!botAuthors.has(login)) continue;
484
+ const tag = parseResolveMarkerTag(c.body ?? '');
485
+ if (tag) latest = tag;
486
+ }
487
+ return latest;
488
+ }