clud-bug 0.6.20 → 0.6.22
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.
- package/bin/clud-bug.js +50 -0
- package/lib/prompts.js +58 -30
- package/lib/render-review.js +204 -0
- package/lib/render.js +17 -0
- package/lib/review-schema.js +159 -0
- package/lib/skills.js +107 -0
- package/lib/update.js +1 -0
- package/package.json +1 -1
- package/templates/workflow-py.yml.tmpl +40 -3
- package/templates/workflow-ts.yml.tmpl +40 -3
- package/templates/workflow.yml.tmpl +53 -3
package/bin/clud-bug.js
CHANGED
|
@@ -31,6 +31,8 @@ function parseArgs(argv) {
|
|
|
31
31
|
setProtection: true, quiet: false,
|
|
32
32
|
// 0.0.M.1 (v0.6.13): `clud-bug usage` flags.
|
|
33
33
|
repo: null, pr: null, limit: null, json: false,
|
|
34
|
+
// 0.0.O (v0.6.22): `clud-bug render` reads its payload from stdin.
|
|
35
|
+
stdin: false,
|
|
34
36
|
};
|
|
35
37
|
for (let i = 0; i < argv.length; i++) {
|
|
36
38
|
const a = argv[i];
|
|
@@ -49,6 +51,7 @@ function parseArgs(argv) {
|
|
|
49
51
|
else if (a === '--pr') args.pr = Number(argv[++i]);
|
|
50
52
|
else if (a === '--limit') args.limit = Number(argv[++i]);
|
|
51
53
|
else if (a === '--json') args.json = true;
|
|
54
|
+
else if (a === '--stdin') args.stdin = true;
|
|
52
55
|
else args._.push(a);
|
|
53
56
|
}
|
|
54
57
|
return args;
|
|
@@ -79,6 +82,11 @@ Commands:
|
|
|
79
82
|
eval Run the golden-set regression gate against the rendered review
|
|
80
83
|
prompt (must-contain / must-not-contain / byte-budget). Same as
|
|
81
84
|
\`node --test test/prompts.eval.test.js\` but works from any cwd.
|
|
85
|
+
render --stdin Render a structured-output JSON payload (the action's
|
|
86
|
+
\`outputs.structured_output\`, piped via stdin) to the
|
|
87
|
+
GitHub-markdown summary comment shape. Invoked by the
|
|
88
|
+
workflow post-step; output is what \`gh pr comment\`
|
|
89
|
+
receives. Empty stdin or non-object payload exits 2.
|
|
82
90
|
|
|
83
91
|
Options:
|
|
84
92
|
--offline Skip skills.sh; pin only the bundled baseline specimens.
|
|
@@ -130,12 +138,53 @@ async function main() {
|
|
|
130
138
|
case 'edit-workflow': return runEditWorkflow(args);
|
|
131
139
|
case 'usage': return runUsage(args);
|
|
132
140
|
case 'eval': return runEval();
|
|
141
|
+
case 'render': return runRender(args);
|
|
133
142
|
default:
|
|
134
143
|
process.stderr.write(`Unknown command: ${cmd || '(none)'}\n\n${HELP}`);
|
|
135
144
|
process.exit(2);
|
|
136
145
|
}
|
|
137
146
|
}
|
|
138
147
|
|
|
148
|
+
// 0.0.O (v0.6.22): render a structured-output JSON payload to the
|
|
149
|
+
// GitHub-markdown summary comment shape. Called by the post-step
|
|
150
|
+
// in the workflow templates: it reads the action's
|
|
151
|
+
// `outputs.structured_output` (one bundled JSON string), pipes it
|
|
152
|
+
// to stdin here, and we emit the rendered markdown on stdout for
|
|
153
|
+
// the shell to pass to `gh pr comment --body`.
|
|
154
|
+
//
|
|
155
|
+
// Usage: `clud-bug render --stdin` (only input source supported).
|
|
156
|
+
// Exit code: 0 on success, 2 on JSON parse error or non-object payload.
|
|
157
|
+
async function runRender(args) {
|
|
158
|
+
const { renderReview } = await import('../lib/render-review.js');
|
|
159
|
+
if (!args.stdin) {
|
|
160
|
+
process.stderr.write('clud-bug render: --stdin is required (the only supported input source).\n');
|
|
161
|
+
process.exit(2);
|
|
162
|
+
}
|
|
163
|
+
let raw = '';
|
|
164
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
165
|
+
raw = raw.trim();
|
|
166
|
+
if (!raw) {
|
|
167
|
+
// Empty structured_output → post-step is supposed to skip the
|
|
168
|
+
// render. Surface the situation rather than silently producing an
|
|
169
|
+
// empty comment.
|
|
170
|
+
process.stderr.write('clud-bug render: stdin was empty — nothing to render.\n');
|
|
171
|
+
process.exit(2);
|
|
172
|
+
}
|
|
173
|
+
let payload;
|
|
174
|
+
try {
|
|
175
|
+
payload = JSON.parse(raw);
|
|
176
|
+
} catch (e) {
|
|
177
|
+
process.stderr.write(`clud-bug render: JSON parse failed: ${e.message}\n`);
|
|
178
|
+
process.exit(2);
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
process.stdout.write(renderReview(payload));
|
|
182
|
+
} catch (e) {
|
|
183
|
+
process.stderr.write(`clud-bug render: ${e.message}\n`);
|
|
184
|
+
process.exit(2);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
139
188
|
// 0.0.E (v0.6.17): thin wrapper around the golden-set test file. Devs
|
|
140
189
|
// who follow the README invoke `clud-bug eval` — this routes to the
|
|
141
190
|
// same `node --test` runner CI uses, so dev and CI verdicts match.
|
|
@@ -220,6 +269,7 @@ async function runInit(args) {
|
|
|
220
269
|
log(' drafting field kit...');
|
|
221
270
|
const tmplName = pickTemplate(signals.languages);
|
|
222
271
|
const tmplPath = join(TEMPLATES, tmplName);
|
|
272
|
+
// REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
|
|
223
273
|
const workflow = await renderFile(tmplPath, {
|
|
224
274
|
REVIEW_PROMPT: reviewPrompt({
|
|
225
275
|
projectDescription: buildDescriptionLine(signals),
|
package/lib/prompts.js
CHANGED
|
@@ -68,20 +68,24 @@ cached system prefix is free at 10%; per-PR fetches are not.
|
|
|
68
68
|
state lives in your PRIOR SUMMARY COMMENT as an HTML marker:
|
|
69
69
|
\`<!-- last-reviewed-sha: <sha> -->\`.
|
|
70
70
|
|
|
71
|
-
Identifying the PRIOR SUMMARY
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
71
|
+
Identifying the PRIOR SUMMARY (v0.6.22+ identity note):
|
|
72
|
+
From v0.6.22 (0.0.O) onward the summary is posted by a workflow
|
|
73
|
+
post-step under the \`github-actions[bot]\` identity, not
|
|
74
|
+
claude[bot]. Inline review threads are still claude[bot] (the
|
|
75
|
+
MCP tool routes through claude-code-action). Beware: the
|
|
76
|
+
in-progress \`Claude Code is working…\` comment claude-code-action
|
|
77
|
+
posts BEFORE this prompt runs is claude[bot]-authored but is NOT
|
|
78
|
+
your prior summary (no marker). Anchor on the H2 line
|
|
79
|
+
\`## 🐛 Clud Bug review\` — same anchor strict-mode uses.
|
|
76
80
|
|
|
77
81
|
Detection in three steps:
|
|
78
82
|
|
|
79
|
-
1. Fetch
|
|
83
|
+
1. Fetch github-actions[bot] comments newest-first:
|
|
80
84
|
\`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=100&sort=created&direction=desc"\`
|
|
81
|
-
Walk them in order;
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
Walk them in order; filter to author \`github-actions[bot]\`,
|
|
86
|
+
and find the FIRST whose body starts with
|
|
87
|
+
\`## 🐛 Clud Bug review\`. THAT is your prior summary. In
|
|
88
|
+
its body, look for \`last-reviewed-sha: <sha>\`. (Pre-v0.6.22 summaries are claude[bot]-authored — if you find a recent claude[bot] comment with the H2 anchor and no newer github-actions[bot] match, treat it as the prior summary.)
|
|
85
89
|
|
|
86
90
|
2. If a SHA was found, verify it's still in HEAD's ancestry:
|
|
87
91
|
\`git merge-base --is-ancestor <prior_sha> $HEAD_SHA\`
|
|
@@ -105,7 +109,7 @@ cached system prefix is free at 10%; per-PR fetches are not.
|
|
|
105
109
|
per file (default 4,000 bytes). Baseline skills fit easily;
|
|
106
110
|
bloated user-added skills get truncated.
|
|
107
111
|
|
|
108
|
-
- PR comments: \`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=20" --jq '.[] | select(.user.login != "claude[bot]")' | head -c "$MAX_COMMENT_BYTES"\`
|
|
112
|
+
- PR comments: \`gh api "repos/$REPO_OWNER/$REPO_NAME/issues/$PR_NUMBER/comments?per_page=20" --jq '.[] | select(.user.login != "claude[bot]" and .user.login != "github-actions[bot]")' | head -c "$MAX_COMMENT_BYTES"\`
|
|
109
113
|
(default 20,000 bytes, 20 most-recent). Skips your own prior
|
|
110
114
|
comments — the FIX-PUSH FLOW handles those via reviewThreads
|
|
111
115
|
GraphQL instead.
|
|
@@ -148,7 +152,17 @@ Each SKILL.md frontmatter (first \`---\`-delimited block) has a
|
|
|
148
152
|
API-contract, test-discipline). Each gets its own H3 section.
|
|
149
153
|
- Missing → treat as \`shared\`.
|
|
150
154
|
|
|
151
|
-
|
|
155
|
+
Skill applies_to (v0.6.21 / 0.0.K):
|
|
156
|
+
Frontmatter may also have \`applies_to:\` with \`paths:\` (glob list)
|
|
157
|
+
and/or \`extensions:\` (extension list). Scan each skill's frontmatter
|
|
158
|
+
first (cheap — just the \`---\` block). If applies_to is present and
|
|
159
|
+
the PR's changed files (from \`gh pr diff --name-only\`) match NONE
|
|
160
|
+
of the declared paths or extensions, SKIP that skill's body — don't
|
|
161
|
+
read it, don't reference it. Skills without applies_to load
|
|
162
|
+
unconditionally (back-compat). Net effect: a UI-scoped skill stays
|
|
163
|
+
unread on a backend-only PR.
|
|
164
|
+
|
|
165
|
+
Read each applicable body capped: \`head -c "$MAX_SKILL_BYTES" .claude/skills/<name>/SKILL.md\`
|
|
152
166
|
|
|
153
167
|
At review end, append a single-line footer:
|
|
154
168
|
Skills referenced: [skill-name-1, skill-name-2, ...]
|
|
@@ -175,13 +189,18 @@ subsequent fix-push re-fetches only the delta since this SHA. Omit
|
|
|
175
189
|
it and the next review falls back to full \`gh pr diff\`.
|
|
176
190
|
|
|
177
191
|
Strict-mode header (opt-in): if .claude/skills/.clud-bug.json has
|
|
178
|
-
\`{ "strictMode": true }\`, the
|
|
179
|
-
|
|
180
|
-
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
192
|
+
\`{ "strictMode": true }\`, set the \`status_header\` schema field
|
|
193
|
+
to signal verdict:
|
|
194
|
+
- any critical issue flagged → \`status_header: "critical findings"\`
|
|
195
|
+
(renderer emits \`## 🐛 Clud Bug review — critical findings\`)
|
|
196
|
+
- otherwise → \`status_header: "clean"\` (renderer emits
|
|
197
|
+
\`## 🐛 Clud Bug review — clean\`)
|
|
198
|
+
The strict-mode gate post-step greps for "critical findings" and
|
|
199
|
+
fails the check.
|
|
200
|
+
|
|
201
|
+
If strictMode is unset or absent, set \`status_header: "bare"\` —
|
|
202
|
+
the renderer emits the bare \`## 🐛 Clud Bug review\` (no suffix),
|
|
203
|
+
matching the v0.6.21- visible behaviour for non-strict-mode repos.
|
|
185
204
|
|
|
186
205
|
Tone: conversational, concise field-naturalist voice (you are Clud
|
|
187
206
|
Bug examining specimens of code) — never at the cost of clarity,
|
|
@@ -204,17 +223,24 @@ Your review lives in TWO surfaces, in this order:
|
|
|
204
223
|
Cross-cutting findings (no specific line) stay summary-only —
|
|
205
224
|
but default to inline whenever you can name file:line.
|
|
206
225
|
|
|
207
|
-
2. SUMMARY PR COMMENT —
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
strict-mode
|
|
226
|
+
2. SUMMARY PR COMMENT — emitted as STRUCTURED JSON via the
|
|
227
|
+
workflow's \`--json-schema\` flag (0.0.O / v0.6.22+). Do NOT
|
|
228
|
+
post the summary yourself via \`gh pr comment\` — a post-step
|
|
229
|
+
reads your structured output and renders the comment with the
|
|
230
|
+
exact format the strict-mode gate expects. Populate every
|
|
231
|
+
schema field (\`status_header\`, \`summary_counts\`,
|
|
232
|
+
\`per_skill_scan\`, \`critical_findings\`, \`minor_findings\`,
|
|
233
|
+
\`preexisting_findings\`, \`skills_referenced\`,
|
|
234
|
+
\`last_reviewed_sha\`); \`dedicated_sections\` and
|
|
235
|
+
\`diagnostics\` are optional but emit them when applicable.
|
|
236
|
+
See workflow env for the schema; the format docs below describe
|
|
237
|
+
what each field becomes after rendering.
|
|
212
238
|
|
|
213
239
|
The comment body MUST start with:
|
|
214
240
|
|
|
215
241
|
## 🐛 Clud Bug review
|
|
216
242
|
|
|
217
|
-
(
|
|
243
|
+
(The post-step renders this H2 anchor — the strict-mode gate greps it.)
|
|
218
244
|
|
|
219
245
|
On the next non-empty line, emit:
|
|
220
246
|
|
|
@@ -287,14 +313,16 @@ Shared-mode skill findings stay in the combined "Critical findings"
|
|
|
287
313
|
(e.g. a logging-PII issue belongs in both critical-issues-only and
|
|
288
314
|
pii-and-compliance at once).
|
|
289
315
|
|
|
290
|
-
|
|
291
|
-
|
|
316
|
+
Emit the summary as structured JSON output (the workflow's
|
|
317
|
+
--json-schema captures it; a post-step renders + posts via gh pr
|
|
318
|
+
comment). Do NOT post the summary yourself.
|
|
292
319
|
|
|
293
|
-
Inline findings post via mcp__github_inline_comment__create_inline_comment
|
|
320
|
+
Inline findings still post via mcp__github_inline_comment__create_inline_comment
|
|
294
321
|
(with \`confirmed: true\`). Pass ordering: (a) post inline findings,
|
|
295
322
|
(b) resolve prior threads now fixed (FIX-PUSH FLOW below — feeds
|
|
296
|
-
"
|
|
297
|
-
|
|
323
|
+
"resolved_from_prior" counter in the JSON), (c) emit structured
|
|
324
|
+
summary output. Step (b) MUST complete before (c) so the counter
|
|
325
|
+
is accurate; (a)/(b) order between themselves doesn't matter.
|
|
298
326
|
|
|
299
327
|
FIX-PUSH FLOW (when prior claude[bot] threads exist):
|
|
300
328
|
List prior claude[bot] inline threads, resolve the ones whose issue
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Render a clud-bug review's structured-output JSON to the GitHub-markdown
|
|
2
|
+
// summary comment shape the workflow has been posting since v0.6.5.
|
|
3
|
+
//
|
|
4
|
+
// 0.0.O (v0.6.22): introduced as the receiver for `--json-schema` output.
|
|
5
|
+
// The LLM emits structured JSON (one bundled string via the action's
|
|
6
|
+
// `outputs.structured_output`); a workflow post-step pipes that JSON to
|
|
7
|
+
// `clud-bug render --stdin` (CLI subcommand), which calls renderReview()
|
|
8
|
+
// here, then posts the result via `gh pr comment`. Failure mode: if
|
|
9
|
+
// `structured_output` is empty (max retries hit), the post-step is
|
|
10
|
+
// skipped and the LLM's prior free-form behaviour stands (it had already
|
|
11
|
+
// been instructed to post via `gh pr comment` directly as a fallback).
|
|
12
|
+
//
|
|
13
|
+
// Why an outside renderer at all: with --json-schema the LLM can no
|
|
14
|
+
// longer paraphrase the comment format (good for consistency, bad if the
|
|
15
|
+
// rendered shape is wrong). Centralising the markdown shape here means a
|
|
16
|
+
// future format tweak edits one function rather than the prompt.
|
|
17
|
+
|
|
18
|
+
const SEVERITY_EMOJI = { critical: '🔴', minor: '🟡', preexisting: '🟣' };
|
|
19
|
+
const SEVERITY_LABEL = {
|
|
20
|
+
critical: 'important',
|
|
21
|
+
minor: 'nit',
|
|
22
|
+
preexisting: 'pre-existing',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Render the full summary comment markdown. `data` is the parsed JSON
|
|
26
|
+
// matching the schema (see schema.js). Returns a string suitable for
|
|
27
|
+
// `gh pr comment --body`.
|
|
28
|
+
export function renderReview(data) {
|
|
29
|
+
if (!data || typeof data !== 'object') {
|
|
30
|
+
throw new TypeError('renderReview: data must be an object');
|
|
31
|
+
}
|
|
32
|
+
const out = [];
|
|
33
|
+
out.push(renderHeader(data));
|
|
34
|
+
out.push('');
|
|
35
|
+
out.push(renderStatusLine(data.summary_counts));
|
|
36
|
+
out.push('');
|
|
37
|
+
out.push(renderStatsHeader(data.summary_counts));
|
|
38
|
+
out.push('');
|
|
39
|
+
out.push(...renderPerSkillScan(data.per_skill_scan));
|
|
40
|
+
out.push('');
|
|
41
|
+
for (const section of data.dedicated_sections || []) {
|
|
42
|
+
out.push(...renderDedicatedSection(section));
|
|
43
|
+
out.push('');
|
|
44
|
+
}
|
|
45
|
+
if (nonEmpty(data.critical_findings)) {
|
|
46
|
+
out.push('### Critical findings');
|
|
47
|
+
out.push('');
|
|
48
|
+
out.push(...renderFindings(data.critical_findings, 'critical'));
|
|
49
|
+
out.push('');
|
|
50
|
+
}
|
|
51
|
+
if (nonEmpty(data.minor_findings)) {
|
|
52
|
+
out.push('### Minor findings');
|
|
53
|
+
out.push('');
|
|
54
|
+
out.push(...renderFindings(data.minor_findings, 'minor'));
|
|
55
|
+
out.push('');
|
|
56
|
+
}
|
|
57
|
+
if (nonEmpty(data.preexisting_findings)) {
|
|
58
|
+
out.push('### Pre-existing findings');
|
|
59
|
+
out.push('');
|
|
60
|
+
out.push(...renderFindings(data.preexisting_findings, 'preexisting'));
|
|
61
|
+
out.push('');
|
|
62
|
+
}
|
|
63
|
+
if (nonEmpty(data.diagnostics)) {
|
|
64
|
+
out.push('### Diagnostics');
|
|
65
|
+
out.push('');
|
|
66
|
+
for (const line of data.diagnostics) out.push(`- ${line}`);
|
|
67
|
+
out.push('');
|
|
68
|
+
}
|
|
69
|
+
out.push(renderSkillsReferenced(data.skills_referenced));
|
|
70
|
+
out.push('');
|
|
71
|
+
if (data.last_reviewed_sha) {
|
|
72
|
+
out.push(`<!-- last-reviewed-sha: ${data.last_reviewed_sha} -->`);
|
|
73
|
+
}
|
|
74
|
+
// Trim trailing blank lines but always keep a single trailing newline so
|
|
75
|
+
// the comment ends with a final newline (markdown rendering is unchanged
|
|
76
|
+
// either way, but it matches the prior LLM-driven shape).
|
|
77
|
+
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s+$/, '') + '\n';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function renderHeader(data) {
|
|
81
|
+
const verdict = data.status_header;
|
|
82
|
+
const base = '## 🐛 Clud Bug review';
|
|
83
|
+
if (verdict === 'critical findings') return `${base} — critical findings`;
|
|
84
|
+
if (verdict === 'clean') return `${base} — clean`;
|
|
85
|
+
// 'bare' (non-strict-mode default) OR an unexpected verdict — render
|
|
86
|
+
// the unsuffixed H2. Strict-mode gate's anchor stays intact either way.
|
|
87
|
+
return base;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function renderStatusLine(counts) {
|
|
91
|
+
const c = sanitizeCounts(counts);
|
|
92
|
+
return `**This round:** ${c.critical} critical · ${c.minor} minor · ${c.resolved_from_prior} resolved from prior · ${c.still_open} still open`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Severity-emoji stats header. Counts pre-existing in 🟣 even though
|
|
96
|
+
// it's not in summary_counts (the prompt counts pre-existing separately
|
|
97
|
+
// in preexisting_findings.length).
|
|
98
|
+
function renderStatsHeader(counts) {
|
|
99
|
+
const c = sanitizeCounts(counts);
|
|
100
|
+
return `Found: ${c.critical} 🔴 / ${c.minor} 🟡 / ${c.preexisting} 🟣`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderPerSkillScan(scan) {
|
|
104
|
+
const out = ['### Per-skill scan'];
|
|
105
|
+
if (!Array.isArray(scan) || scan.length === 0) {
|
|
106
|
+
out.push('- (no skills loaded — review proceeded against the baseline.)');
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of scan) {
|
|
110
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
111
|
+
const skill = String(entry.skill || '').trim();
|
|
112
|
+
const outcome = String(entry.outcome || '').trim();
|
|
113
|
+
if (!skill) continue;
|
|
114
|
+
out.push(`- [${skill}]: ${outcome || 'scanned (no outcome reported).'}`);
|
|
115
|
+
}
|
|
116
|
+
return out;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function renderDedicatedSection(section) {
|
|
120
|
+
if (!section || typeof section !== 'object') return [];
|
|
121
|
+
const name = String(section.section_name || '').trim();
|
|
122
|
+
const skill = String(section.skill || '').trim();
|
|
123
|
+
const header = skill && name
|
|
124
|
+
? `### ${name} [${skill}]`
|
|
125
|
+
: `### ${name || skill || 'Dedicated section'}`;
|
|
126
|
+
const out = [header, ''];
|
|
127
|
+
if (Array.isArray(section.findings) && section.findings.length > 0) {
|
|
128
|
+
// Dedicated-section findings use the same emoji-prefix block.
|
|
129
|
+
// Default severity for dedicated sections is "critical" — they're
|
|
130
|
+
// domain-specific findings the skill considers important.
|
|
131
|
+
out.push(...renderFindings(section.findings, 'critical'));
|
|
132
|
+
} else {
|
|
133
|
+
out.push('No findings.');
|
|
134
|
+
}
|
|
135
|
+
return out;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function renderFindings(findings, severity) {
|
|
139
|
+
const emoji = SEVERITY_EMOJI[severity] || '🔴';
|
|
140
|
+
const out = [];
|
|
141
|
+
for (const f of findings) {
|
|
142
|
+
if (!f || typeof f !== 'object') continue;
|
|
143
|
+
const skill = String(f.skill || '').trim();
|
|
144
|
+
const summary = String(f.summary || '').trim();
|
|
145
|
+
if (!summary) continue;
|
|
146
|
+
const skillPrefix = skill ? `[${skill}]: ` : '';
|
|
147
|
+
const anchor = locationAnchor(f);
|
|
148
|
+
const claim = anchor
|
|
149
|
+
? `${emoji} ${skillPrefix}${stripTrailingPunctuation(summary)} (${anchor}).`
|
|
150
|
+
: `${emoji} ${skillPrefix}${summary}`;
|
|
151
|
+
out.push(claim);
|
|
152
|
+
if (f.reasoning) {
|
|
153
|
+
out.push('<details><summary>Reasoning</summary>');
|
|
154
|
+
out.push('');
|
|
155
|
+
out.push(String(f.reasoning).trim());
|
|
156
|
+
out.push('');
|
|
157
|
+
out.push('</details>');
|
|
158
|
+
}
|
|
159
|
+
out.push('');
|
|
160
|
+
}
|
|
161
|
+
// Remove the trailing empty line — renderReview adds its own separators.
|
|
162
|
+
if (out[out.length - 1] === '') out.pop();
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function renderSkillsReferenced(skills) {
|
|
167
|
+
if (!Array.isArray(skills) || skills.length === 0) {
|
|
168
|
+
return 'Skills referenced: [none] — no installed skill applied to this diff.';
|
|
169
|
+
}
|
|
170
|
+
return `Skills referenced: [${skills.join(', ')}]`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- helpers ---
|
|
174
|
+
|
|
175
|
+
function nonEmpty(arr) {
|
|
176
|
+
return Array.isArray(arr) && arr.length > 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function sanitizeCounts(counts) {
|
|
180
|
+
const c = counts && typeof counts === 'object' ? counts : {};
|
|
181
|
+
return {
|
|
182
|
+
critical: numOrZero(c.critical),
|
|
183
|
+
minor: numOrZero(c.minor),
|
|
184
|
+
preexisting: numOrZero(c.preexisting),
|
|
185
|
+
resolved_from_prior: numOrZero(c.resolved_from_prior),
|
|
186
|
+
still_open: numOrZero(c.still_open),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function numOrZero(v) {
|
|
191
|
+
const n = Number(v);
|
|
192
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function locationAnchor(f) {
|
|
196
|
+
const file = String(f.file || '').trim();
|
|
197
|
+
if (!file) return null;
|
|
198
|
+
const line = Number(f.line);
|
|
199
|
+
return Number.isFinite(line) && line > 0 ? `${file}:${line}` : file;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stripTrailingPunctuation(s) {
|
|
203
|
+
return s.replace(/[.!?]+$/, '');
|
|
204
|
+
}
|
package/lib/render.js
CHANGED
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { serializedReviewSchema } from './review-schema.js';
|
|
2
6
|
|
|
3
7
|
const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
|
|
4
8
|
|
|
9
|
+
// CLUD_BUG_VERSION (0.0.O / v0.6.22) — read from this package's
|
|
10
|
+
// package.json at module-load time. The rendered workflow uses
|
|
11
|
+
// `npx --yes clud-bug@<CLUD_BUG_VERSION>` in the post-step that renders
|
|
12
|
+
// structured output to markdown. Pinning to the version that ran
|
|
13
|
+
// `clud-bug init` guarantees the renderer's output shape matches the
|
|
14
|
+
// prompt's expectations.
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const PKG_VERSION = JSON.parse(
|
|
17
|
+
readFileSync(join(__dirname, '..', 'package.json'), 'utf8'),
|
|
18
|
+
).version;
|
|
19
|
+
|
|
5
20
|
// Default values for substitution tokens that every template uses.
|
|
6
21
|
// Callers can override per-render by passing the same key in `vars`.
|
|
7
22
|
//
|
|
@@ -13,6 +28,8 @@ const PLACEHOLDER_RE = /\{\{([A-Z_]+)\}\}/g;
|
|
|
13
28
|
// version in their own forked workflow.
|
|
14
29
|
export const DEFAULTS = {
|
|
15
30
|
CCA_VERSION: 'v1.0.133',
|
|
31
|
+
CLUD_BUG_VERSION: PKG_VERSION,
|
|
32
|
+
REVIEW_SCHEMA: serializedReviewSchema(),
|
|
16
33
|
};
|
|
17
34
|
|
|
18
35
|
// Multi-line value substitution preserves YAML/Markdown indentation by
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// JSON Schema for clud-bug review structured output (0.0.O / v0.6.22).
|
|
2
|
+
//
|
|
3
|
+
// Passed to claude-code-action via `claude_args: --json-schema '<JSON>'`.
|
|
4
|
+
// The Agent SDK validates the LLM's emitted JSON against this schema and
|
|
5
|
+
// re-prompts on mismatch (internal retry; not a clud-bug-level loop).
|
|
6
|
+
//
|
|
7
|
+
// Schema design choices:
|
|
8
|
+
// - Flat top-level — composite-action outputs are a single string, so we
|
|
9
|
+
// fromJSON() once and pluck fields. No deep nesting that would force
|
|
10
|
+
// extra path traversal in the post-step shell.
|
|
11
|
+
// - Word/char caps in `description:` — the SDK doc names these as the
|
|
12
|
+
// primary lever for keeping output cheap. They're advisory (the SDK
|
|
13
|
+
// does not enforce numeric caps; the LLM treats them as instruction).
|
|
14
|
+
// 0.0.X already lives in the prompt, so the schema description here is
|
|
15
|
+
// a complementary belt-and-suspenders signal.
|
|
16
|
+
// - Required minimum — only the fields a renderer absolutely needs to
|
|
17
|
+
// produce a valid summary comment. Counts are always required (the
|
|
18
|
+
// stats header + status block depend on them); finding arrays are
|
|
19
|
+
// required but may be empty (a clean review has 0 findings, not
|
|
20
|
+
// missing arrays).
|
|
21
|
+
// - `additionalProperties: false` on every object — schema-strict mode.
|
|
22
|
+
// Anthropic's structured-outputs doc explicitly recommends this to
|
|
23
|
+
// keep the model from inventing fields.
|
|
24
|
+
//
|
|
25
|
+
// Bumped via deliberate edit; not derived from a TypeScript type. The
|
|
26
|
+
// rendering side (lib/render-review.js) treats unknown fields permissively
|
|
27
|
+
// — schema and renderer can drift up to one minor version safely.
|
|
28
|
+
|
|
29
|
+
const FINDING_ITEM = {
|
|
30
|
+
type: 'object',
|
|
31
|
+
additionalProperties: false,
|
|
32
|
+
properties: {
|
|
33
|
+
skill: {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: 'The skill name in brackets, e.g. "critical-issues-only". Must match a loaded skill or "(none)".',
|
|
36
|
+
},
|
|
37
|
+
file: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Path of the affected file relative to repo root. Optional when the finding is cross-cutting.',
|
|
40
|
+
},
|
|
41
|
+
line: {
|
|
42
|
+
type: 'integer',
|
|
43
|
+
minimum: 1,
|
|
44
|
+
description: 'Line number in the affected file. Required when `file` is set.',
|
|
45
|
+
},
|
|
46
|
+
summary: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
description: 'One sentence stating the claim. Max ~20 words; no trailing period (the renderer adds one).',
|
|
49
|
+
},
|
|
50
|
+
reasoning: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'Evidence anchor + suggested fix. Max ~80 words. Rendered inside <details> block; can be omitted for self-evident findings.',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
required: ['skill', 'summary'],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const PER_SKILL_SCAN_ITEM = {
|
|
59
|
+
type: 'object',
|
|
60
|
+
additionalProperties: false,
|
|
61
|
+
properties: {
|
|
62
|
+
skill: { type: 'string' },
|
|
63
|
+
outcome: {
|
|
64
|
+
type: 'string',
|
|
65
|
+
description: 'One sentence describing what the skill found. Max ~15 words. Examples: "scanned all paths. 2 critical findings below.", "0 findings.", "not applicable to this diff."',
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
required: ['skill', 'outcome'],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export const REVIEW_SCHEMA = {
|
|
72
|
+
type: 'object',
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
properties: {
|
|
75
|
+
status_header: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
enum: ['critical findings', 'clean', 'bare'],
|
|
78
|
+
description: 'Strict-mode opt-in repos: `critical findings` when ANY 🔴 finding exists, otherwise `clean`. Non-strict-mode repos (the default): emit `bare` — the renderer produces a `## 🐛 Clud Bug review` H2 with no suffix, matching the v0.6.21- behaviour. Check .claude/skills/.clud-bug.json for `strictMode: true` to pick critical/clean vs bare.',
|
|
79
|
+
},
|
|
80
|
+
summary_counts: {
|
|
81
|
+
type: 'object',
|
|
82
|
+
additionalProperties: false,
|
|
83
|
+
properties: {
|
|
84
|
+
critical: { type: 'integer', minimum: 0 },
|
|
85
|
+
minor: { type: 'integer', minimum: 0 },
|
|
86
|
+
preexisting: { type: 'integer', minimum: 0 },
|
|
87
|
+
resolved_from_prior: { type: 'integer', minimum: 0 },
|
|
88
|
+
still_open: { type: 'integer', minimum: 0 },
|
|
89
|
+
},
|
|
90
|
+
required: ['critical', 'minor', 'preexisting', 'resolved_from_prior', 'still_open'],
|
|
91
|
+
},
|
|
92
|
+
per_skill_scan: {
|
|
93
|
+
type: 'array',
|
|
94
|
+
description: 'One entry per LOADED skill — even silent ones. Empty when no skills are installed.',
|
|
95
|
+
items: PER_SKILL_SCAN_ITEM,
|
|
96
|
+
},
|
|
97
|
+
critical_findings: {
|
|
98
|
+
type: 'array',
|
|
99
|
+
description: 'NEW 🔴 findings (bugs, security, perf, missing test coverage). May be empty.',
|
|
100
|
+
items: FINDING_ITEM,
|
|
101
|
+
},
|
|
102
|
+
minor_findings: {
|
|
103
|
+
type: 'array',
|
|
104
|
+
description: 'NEW 🟡 findings (nits, style, observations). May be empty.',
|
|
105
|
+
items: FINDING_ITEM,
|
|
106
|
+
},
|
|
107
|
+
preexisting_findings: {
|
|
108
|
+
type: 'array',
|
|
109
|
+
description: 'NEW 🟣 findings (issues that pre-date this PR). May be empty.',
|
|
110
|
+
items: FINDING_ITEM,
|
|
111
|
+
},
|
|
112
|
+
dedicated_sections: {
|
|
113
|
+
type: 'array',
|
|
114
|
+
description: 'Per-dedicated-mode-skill section blocks. Each item carries the section header text and its findings.',
|
|
115
|
+
items: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
properties: {
|
|
119
|
+
section_name: { type: 'string', description: 'Human-readable section title, e.g. "Brand voice".' },
|
|
120
|
+
skill: { type: 'string', description: 'The dedicated skill name.' },
|
|
121
|
+
findings: { type: 'array', items: FINDING_ITEM },
|
|
122
|
+
},
|
|
123
|
+
required: ['section_name', 'skill', 'findings'],
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
diagnostics: {
|
|
127
|
+
type: 'array',
|
|
128
|
+
description: '0.0.T tee-hint diagnostics. One line per `head -c` cap that fired during fetch. Empty when no truncation occurred.',
|
|
129
|
+
items: { type: 'string' },
|
|
130
|
+
},
|
|
131
|
+
skills_referenced: {
|
|
132
|
+
type: 'array',
|
|
133
|
+
description: 'Names of every skill cited in any finding. Empty list (or ["(none)"]) when no skill applied.',
|
|
134
|
+
items: { type: 'string' },
|
|
135
|
+
},
|
|
136
|
+
last_reviewed_sha: {
|
|
137
|
+
type: 'string',
|
|
138
|
+
description: 'The HEAD SHA at review time, used by the incremental-diff handshake. Set to the literal value of the workflow env var $HEAD_SHA.',
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
required: [
|
|
142
|
+
'status_header',
|
|
143
|
+
'summary_counts',
|
|
144
|
+
'per_skill_scan',
|
|
145
|
+
'critical_findings',
|
|
146
|
+
'minor_findings',
|
|
147
|
+
'preexisting_findings',
|
|
148
|
+
'skills_referenced',
|
|
149
|
+
'last_reviewed_sha',
|
|
150
|
+
],
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
// Serialize the schema for inclusion in workflow templates as the
|
|
154
|
+
// `--json-schema '<JSON>'` argument. Single-line (the workflow YAML uses
|
|
155
|
+
// the pipe block; single-quoted JSON inside that needs to stay flat to
|
|
156
|
+
// avoid YAML parser surprises with embedded newlines).
|
|
157
|
+
export function serializedReviewSchema() {
|
|
158
|
+
return JSON.stringify(REVIEW_SCHEMA);
|
|
159
|
+
}
|
package/lib/skills.js
CHANGED
|
@@ -383,6 +383,113 @@ export function readReviewMode(skillContent) {
|
|
|
383
383
|
return value === 'dedicated' ? 'dedicated' : 'shared';
|
|
384
384
|
}
|
|
385
385
|
|
|
386
|
+
// 0.0.K (v0.6.21): parse the optional `applies_to:` frontmatter block.
|
|
387
|
+
//
|
|
388
|
+
// Schema:
|
|
389
|
+
// applies_to:
|
|
390
|
+
// paths:
|
|
391
|
+
// - "src/ui/**"
|
|
392
|
+
// - "lib/components/**"
|
|
393
|
+
// extensions: [".tsx", ".jsx"]
|
|
394
|
+
//
|
|
395
|
+
// Returns `{paths: string[], extensions: string[]}` if the field is
|
|
396
|
+
// present (either sub-list optional, both default to empty array), or
|
|
397
|
+
// `null` if absent. Skills without applies_to are scope-universal —
|
|
398
|
+
// the caller should treat null as "load unconditionally."
|
|
399
|
+
//
|
|
400
|
+
// Hand-rolled YAML parser scoped to this exact shape. The frontmatter
|
|
401
|
+
// is otherwise opaque (review_mode is parsed elsewhere with a similar
|
|
402
|
+
// single-key regex), so pulling in a YAML dep would be overkill.
|
|
403
|
+
export function readAppliesTo(skillContent) {
|
|
404
|
+
if (typeof skillContent !== 'string') return null;
|
|
405
|
+
const fm = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
|
406
|
+
if (!fm) return null;
|
|
407
|
+
const block = fm[1];
|
|
408
|
+
// Anchor on `applies_to:` at start of line (the body of a SKILL.md
|
|
409
|
+
// could mention the term in prose; only the frontmatter key fires).
|
|
410
|
+
const head = block.match(/^applies_to:\s*$/m);
|
|
411
|
+
if (!head) return null;
|
|
412
|
+
// Slice from after the `applies_to:` line; the block ends at the
|
|
413
|
+
// next top-level key (a line starting with a word character + `:`)
|
|
414
|
+
// OR end-of-block.
|
|
415
|
+
const startIdx = head.index + head[0].length;
|
|
416
|
+
const rest = block.slice(startIdx);
|
|
417
|
+
const stop = rest.search(/^\w[\w-]*:/m);
|
|
418
|
+
const scoped = stop === -1 ? rest : rest.slice(0, stop);
|
|
419
|
+
const paths = parseYamlList(scoped, 'paths');
|
|
420
|
+
const extensions = parseYamlList(scoped, 'extensions');
|
|
421
|
+
if (paths.length === 0 && extensions.length === 0) return null;
|
|
422
|
+
return { paths, extensions };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Parse a YAML list under `<key>:`, handling both the inline-array
|
|
426
|
+
// form (`extensions: [".tsx", ".jsx"]`) and the block form
|
|
427
|
+
// (`paths:` followed by ` - "src/ui/**"` lines).
|
|
428
|
+
function parseYamlList(block, key) {
|
|
429
|
+
const inline = block.match(new RegExp(`^\\s{2}${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'));
|
|
430
|
+
if (inline) {
|
|
431
|
+
return inline[1]
|
|
432
|
+
.split(',')
|
|
433
|
+
.map((s) => s.trim().replace(/^["']|["']$/g, ''))
|
|
434
|
+
.filter(Boolean);
|
|
435
|
+
}
|
|
436
|
+
const headerRe = new RegExp(`^\\s{2}${key}:\\s*$`, 'm');
|
|
437
|
+
const head = block.match(headerRe);
|
|
438
|
+
if (!head) return [];
|
|
439
|
+
const after = block.slice(head.index + head[0].length);
|
|
440
|
+
const items = [];
|
|
441
|
+
for (const line of after.split('\n')) {
|
|
442
|
+
const item = line.match(/^\s{4,}-\s*(.+?)\s*$/);
|
|
443
|
+
if (item) {
|
|
444
|
+
items.push(item[1].replace(/^["']|["']$/g, ''));
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
// Anything that isn't a list item (or blank) ends the list.
|
|
448
|
+
if (line.trim() !== '' && !item) break;
|
|
449
|
+
}
|
|
450
|
+
return items;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 0.0.K: does `prPaths` contain at least one file matching the skill's
|
|
454
|
+
// applies_to? Skills without applies_to ALWAYS apply (back-compat).
|
|
455
|
+
//
|
|
456
|
+
// `prPaths` is the list of changed files in the PR (e.g. from
|
|
457
|
+
// `gh pr diff --name-only`). Match semantics:
|
|
458
|
+
// - paths: any glob in `paths` matches any of `prPaths`
|
|
459
|
+
// - extensions: any extension in `extensions` matches any of `prPaths`
|
|
460
|
+
// - paths OR extensions (NOT AND) — a single hit is enough
|
|
461
|
+
//
|
|
462
|
+
// Skill `paths` use the minimal glob set logmind already uses
|
|
463
|
+
// (`*` matches non-slash, `**` matches across slashes, `?` single
|
|
464
|
+
// char). Anything fancier would need a real glob lib.
|
|
465
|
+
export function appliesToPr(skillContent, prPaths) {
|
|
466
|
+
const rule = readAppliesTo(skillContent);
|
|
467
|
+
if (rule === null) return true; // back-compat: no rule → applies
|
|
468
|
+
if (!Array.isArray(prPaths)) return true; // be permissive on bad input
|
|
469
|
+
for (const path of prPaths) {
|
|
470
|
+
if (typeof path !== 'string') continue;
|
|
471
|
+
for (const ext of rule.extensions) {
|
|
472
|
+
if (path.endsWith(ext)) return true;
|
|
473
|
+
}
|
|
474
|
+
for (const glob of rule.paths) {
|
|
475
|
+
if (globMatch(glob, path)) return true;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Minimal glob → regex: `**` → `.*`, `*` → `[^/]*`, `?` → `.`,
|
|
482
|
+
// everything else escaped. Anchored full-string match.
|
|
483
|
+
function globMatch(glob, path) {
|
|
484
|
+
const escaped = glob
|
|
485
|
+
.replace(/([.+^${}()|[\]\\])/g, '\\$1')
|
|
486
|
+
.replace(/\*\*/g, '__DOUBLESTAR__')
|
|
487
|
+
.replace(/\*/g, '[^/]*')
|
|
488
|
+
.replace(/__DOUBLESTAR__/g, '.*')
|
|
489
|
+
.replace(/\?/g, '.');
|
|
490
|
+
return new RegExp(`^${escaped}$`).test(path);
|
|
491
|
+
}
|
|
492
|
+
|
|
386
493
|
// Partition a set of loaded skills into {shared, dedicated} buckets per
|
|
387
494
|
// each skill's review_mode frontmatter. Expects skills with a `content`
|
|
388
495
|
// field (SKILL.md text). Skills without content default to `shared`.
|
package/lib/update.js
CHANGED
|
@@ -45,6 +45,7 @@ export async function runUpdate({
|
|
|
45
45
|
// 1. Re-render review workflow with the latest template.
|
|
46
46
|
const signals = await detect(cwd);
|
|
47
47
|
const tmplName = pickTemplate(signals.languages);
|
|
48
|
+
// REVIEW_SCHEMA + CCA_VERSION + CLUD_BUG_VERSION come from render.js DEFAULTS.
|
|
48
49
|
const newReview = await renderFile(join(templatesDir, tmplName), {
|
|
49
50
|
REVIEW_PROMPT: reviewPrompt({
|
|
50
51
|
projectDescription: buildDescriptionLine(signals),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clud-bug",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.22",
|
|
4
4
|
"description": "Skill-driven Claude PR review. Ship a brand-voice skill, get brand reviews. Each finding cites the skill that motivated it. CLI installs the workflow + a baseline kit; add more from skills.sh.",
|
|
5
5
|
"homepage": "https://cludbug.dev",
|
|
6
6
|
"bugs": "https://github.com/thrillmade/clud-bug/issues",
|
|
@@ -108,7 +108,7 @@ jobs:
|
|
|
108
108
|
if $IS_FORK || $IS_BOT; then
|
|
109
109
|
REASON=$($IS_BOT && echo "bot author ($PR_AUTHOR)" || echo "fork ($HEAD_REPO)")
|
|
110
110
|
EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
|
|
111
|
-
--jq '[.[] | select(.user.login == "
|
|
111
|
+
--jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
|
|
112
112
|
if [ "${EXISTING:-0}" = "0" ]; then
|
|
113
113
|
BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
|
|
114
114
|
gh pr comment "$PR_NUMBER" --body "$BODY" || true
|
|
@@ -143,19 +143,56 @@ jobs:
|
|
|
143
143
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
144
144
|
track_progress: true
|
|
145
145
|
show_full_output: true
|
|
146
|
+
# v0.6.22 / 0.0.O: --json-schema captures the summary as
|
|
147
|
+
# structured output; post-step renders + posts. See workflow.yml.tmpl for design notes.
|
|
146
148
|
claude_args: |
|
|
147
149
|
--model ${{ needs.paths-check.outputs.model }}
|
|
148
150
|
--max-turns 15
|
|
149
|
-
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr
|
|
151
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
|
|
152
|
+
--json-schema '{{REVIEW_SCHEMA}}'
|
|
150
153
|
prompt: |
|
|
151
154
|
Review this pull request following the discipline in your
|
|
152
155
|
system prompt — every rule about skill routing, comment
|
|
153
156
|
format, the strict-mode header, the two-surface review
|
|
154
157
|
shape, and the FIX-PUSH FLOW applies.
|
|
158
|
+
id: clud-bug-review
|
|
159
|
+
|
|
160
|
+
# v0.6.22 / 0.0.O: render structured output → post via gh pr comment.
|
|
161
|
+
- name: Render + post structured review
|
|
162
|
+
if: success() && steps.clud-bug-review.outputs.structured_output != ''
|
|
163
|
+
env:
|
|
164
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
165
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
166
|
+
STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
|
|
167
|
+
run: |
|
|
168
|
+
set -euo pipefail
|
|
169
|
+
BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
|
|
170
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
171
|
+
|
|
172
|
+
# Fallback when structured_output is empty (max-retries hit).
|
|
173
|
+
- name: Fallback summary (structured_output empty)
|
|
174
|
+
if: success() && steps.clud-bug-review.outputs.structured_output == ''
|
|
175
|
+
env:
|
|
176
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
177
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
178
|
+
run: |
|
|
179
|
+
set -euo pipefail
|
|
180
|
+
gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
|
|
181
|
+
|
|
182
|
+
**This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
|
|
183
|
+
|
|
184
|
+
Found: 0 🔴 / 0 🟡 / 0 🟣
|
|
185
|
+
|
|
186
|
+
⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
|
|
187
|
+
|
|
188
|
+
Skills referenced: [none]"
|
|
155
189
|
|
|
156
190
|
# Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
|
|
157
191
|
- name: Strict mode — fail check on critical findings
|
|
158
192
|
if: success()
|
|
159
|
-
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.
|
|
193
|
+
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.22
|
|
160
194
|
with:
|
|
161
195
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
196
|
+
# v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
|
|
197
|
+
# See workflow.yml.tmpl for design notes.
|
|
198
|
+
bot-login: 'github-actions[bot]'
|
|
@@ -108,7 +108,7 @@ jobs:
|
|
|
108
108
|
if $IS_FORK || $IS_BOT; then
|
|
109
109
|
REASON=$($IS_BOT && echo "bot author ($PR_AUTHOR)" || echo "fork ($HEAD_REPO)")
|
|
110
110
|
EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
|
|
111
|
-
--jq '[.[] | select(.user.login == "
|
|
111
|
+
--jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
|
|
112
112
|
if [ "${EXISTING:-0}" = "0" ]; then
|
|
113
113
|
BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
|
|
114
114
|
gh pr comment "$PR_NUMBER" --body "$BODY" || true
|
|
@@ -143,19 +143,56 @@ jobs:
|
|
|
143
143
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
144
144
|
track_progress: true
|
|
145
145
|
show_full_output: true
|
|
146
|
+
# v0.6.22 / 0.0.O: --json-schema captures the summary as
|
|
147
|
+
# structured output; post-step renders + posts. See workflow.yml.tmpl for design notes.
|
|
146
148
|
claude_args: |
|
|
147
149
|
--model ${{ needs.paths-check.outputs.model }}
|
|
148
150
|
--max-turns 15
|
|
149
|
-
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr
|
|
151
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
|
|
152
|
+
--json-schema '{{REVIEW_SCHEMA}}'
|
|
150
153
|
prompt: |
|
|
151
154
|
Review this pull request following the discipline in your
|
|
152
155
|
system prompt — every rule about skill routing, comment
|
|
153
156
|
format, the strict-mode header, the two-surface review
|
|
154
157
|
shape, and the FIX-PUSH FLOW applies.
|
|
158
|
+
id: clud-bug-review
|
|
159
|
+
|
|
160
|
+
# v0.6.22 / 0.0.O: render structured output → post via gh pr comment.
|
|
161
|
+
- name: Render + post structured review
|
|
162
|
+
if: success() && steps.clud-bug-review.outputs.structured_output != ''
|
|
163
|
+
env:
|
|
164
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
165
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
166
|
+
STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
|
|
167
|
+
run: |
|
|
168
|
+
set -euo pipefail
|
|
169
|
+
BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
|
|
170
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
171
|
+
|
|
172
|
+
# Fallback when structured_output is empty (max-retries hit).
|
|
173
|
+
- name: Fallback summary (structured_output empty)
|
|
174
|
+
if: success() && steps.clud-bug-review.outputs.structured_output == ''
|
|
175
|
+
env:
|
|
176
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
177
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
178
|
+
run: |
|
|
179
|
+
set -euo pipefail
|
|
180
|
+
gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
|
|
181
|
+
|
|
182
|
+
**This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
|
|
183
|
+
|
|
184
|
+
Found: 0 🔴 / 0 🟡 / 0 🟣
|
|
185
|
+
|
|
186
|
+
⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
|
|
187
|
+
|
|
188
|
+
Skills referenced: [none]"
|
|
155
189
|
|
|
156
190
|
# Strict-mode gate — composite action; see workflow.yml.tmpl for design notes.
|
|
157
191
|
- name: Strict mode — fail check on critical findings
|
|
158
192
|
if: success()
|
|
159
|
-
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.
|
|
193
|
+
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.22
|
|
160
194
|
with:
|
|
161
195
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
196
|
+
# v0.6.22 / 0.0.O: summary now posted by github-actions[bot].
|
|
197
|
+
# See workflow.yml.tmpl for design notes.
|
|
198
|
+
bot-login: 'github-actions[bot]'
|
|
@@ -158,7 +158,7 @@ jobs:
|
|
|
158
158
|
# pull_request: synchronize on a long-running Dependabot PR would
|
|
159
159
|
# otherwise stack one identical comment per rebase.
|
|
160
160
|
EXISTING=$(gh api "repos/${BASE_REPO}/issues/${PR_NUMBER}/comments?per_page=100" \
|
|
161
|
-
--jq '[.[] | select(.user.login == "
|
|
161
|
+
--jq '[.[] | select(.user.login == "github-actions[bot]" and (.body | startswith("## 🐛 Clud Bug skipped")))] | length')
|
|
162
162
|
if [ "${EXISTING:-0}" = "0" ]; then
|
|
163
163
|
BODY=$(printf '## 🐛 Clud Bug skipped\n\nThis PR is from a %s. GitHub deliberately does not pass repository secrets to such workflows, so Clud Bug could not authenticate against Anthropic. Review the diff manually.' "$REASON")
|
|
164
164
|
gh pr comment "$PR_NUMBER" --body "$BODY" || true
|
|
@@ -222,15 +222,58 @@ jobs:
|
|
|
222
222
|
# the PR as trivial (Haiku) or default (Sonnet). Override
|
|
223
223
|
# per-repo by editing the rendered workflow if you want a
|
|
224
224
|
# specific model for a specific repo.
|
|
225
|
+
# v0.6.22 / 0.0.O: --json-schema captures the summary as
|
|
226
|
+
# structured output (action exposes outputs.structured_output).
|
|
227
|
+
# The model emits findings as schema fields; a post-step
|
|
228
|
+
# renders to markdown and posts. The schema is rendered into
|
|
229
|
+
# this template at `clud-bug init` time via {{REVIEW_SCHEMA}}.
|
|
225
230
|
claude_args: |
|
|
226
231
|
--model ${{ needs.paths-check.outputs.model }}
|
|
227
232
|
--max-turns 15
|
|
228
|
-
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr
|
|
233
|
+
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr diff:*),Bash(gh pr view:*),Bash(gh api graphql:*),Bash(gh api repos/:*),Bash(git show:*),Bash(git diff:*),Bash(git merge-base:*),Bash(cat .claude/skills/.clud-bug.json),Bash(cat .claude/skills/*/SKILL.md),Bash(head:*)"
|
|
234
|
+
--json-schema '{{REVIEW_SCHEMA}}'
|
|
229
235
|
prompt: |
|
|
230
236
|
Review this pull request following the discipline in your
|
|
231
237
|
system prompt — every rule about skill routing, comment
|
|
232
238
|
format, the strict-mode header, the two-surface review
|
|
233
239
|
shape, and the FIX-PUSH FLOW applies.
|
|
240
|
+
id: clud-bug-review
|
|
241
|
+
|
|
242
|
+
# v0.6.22 / 0.0.O: render the structured output to the summary
|
|
243
|
+
# comment shape, post via gh pr comment. Guarded so we only run
|
|
244
|
+
# when the action returned a non-empty structured payload
|
|
245
|
+
# (max-retries hit → empty → fall through to the next step).
|
|
246
|
+
- name: Render + post structured review
|
|
247
|
+
if: success() && steps.clud-bug-review.outputs.structured_output != ''
|
|
248
|
+
env:
|
|
249
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
250
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
251
|
+
STRUCTURED: ${{ steps.clud-bug-review.outputs.structured_output }}
|
|
252
|
+
run: |
|
|
253
|
+
set -euo pipefail
|
|
254
|
+
BODY=$(printf '%s\n' "$STRUCTURED" | npx --yes clud-bug@{{CLUD_BUG_VERSION}} render --stdin)
|
|
255
|
+
gh pr comment "$PR_NUMBER" --body "$BODY"
|
|
256
|
+
|
|
257
|
+
# Fallback comment when the model couldn't produce schema-valid
|
|
258
|
+
# output after max retries (structured_output is empty). Keeps a
|
|
259
|
+
# bare H2 header so the strict-mode gate sees a comment and falls
|
|
260
|
+
# open (advisory) rather than panicking on a missing summary.
|
|
261
|
+
- name: Fallback summary (structured_output empty)
|
|
262
|
+
if: success() && steps.clud-bug-review.outputs.structured_output == ''
|
|
263
|
+
env:
|
|
264
|
+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
265
|
+
PR_NUMBER: ${{ github.event.pull_request.number }}
|
|
266
|
+
run: |
|
|
267
|
+
set -euo pipefail
|
|
268
|
+
gh pr comment "$PR_NUMBER" --body "## 🐛 Clud Bug review
|
|
269
|
+
|
|
270
|
+
**This round:** 0 critical · 0 minor · 0 resolved from prior · 0 still open
|
|
271
|
+
|
|
272
|
+
Found: 0 🔴 / 0 🟡 / 0 🟣
|
|
273
|
+
|
|
274
|
+
⚠️ Structured output (\`--json-schema\`) returned empty — likely max-retries hit on schema validation. Investigate the action logs.
|
|
275
|
+
|
|
276
|
+
Skills referenced: [none]"
|
|
234
277
|
|
|
235
278
|
# Strict-mode gate. Fails the check when the BASE ref's manifest
|
|
236
279
|
# has { "strictMode": true } AND the latest clud-bug review's first
|
|
@@ -247,6 +290,13 @@ jobs:
|
|
|
247
290
|
# Letting the action's own failure fail the check is louder and right.
|
|
248
291
|
- name: Strict mode — fail check on critical findings
|
|
249
292
|
if: success()
|
|
250
|
-
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.
|
|
293
|
+
uses: thrillmade/clud-bug/.github/actions/strict-mode-gate@v0.6.22
|
|
251
294
|
with:
|
|
252
295
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
296
|
+
# v0.6.22 / 0.0.O: the summary is now posted by the workflow
|
|
297
|
+
# post-step under the github-actions[bot] identity (was
|
|
298
|
+
# claude[bot] when claude-code-action did the posting).
|
|
299
|
+
# Without this override the gate's default (claude[bot])
|
|
300
|
+
# never matches the new summary author → strict mode falls
|
|
301
|
+
# open advisory for every install that opted in.
|
|
302
|
+
bot-login: 'github-actions[bot]'
|