clud-bug 0.5.4 → 0.5.5

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/README.md CHANGED
@@ -36,16 +36,22 @@ The naturalist arrives at your repo, surveys the habitat, and assembles a field
36
36
  4. **Writes** the chosen specimens to `.claude/skills/<name>/SKILL.md` (Claude Code auto-loads them in the GitHub Action).
37
37
  5. **Drafts the field kit** at `.github/workflows/clud-bug-review.yml` with your project description filled in and the right permissions/tool allowlist for `gh pr comment` to actually post.
38
38
  6. **Briefs other agents** by adding a `<!-- clud-bug-start -->` block to `AGENTS.md` (creating it if missing — it's the cross-tool canonical), and idempotently to `CLAUDE.md`, `GEMINI.md`, `.github/copilot-instructions.md`, `.cursorrules`, `.windsurfrules`, `.clinerules`, `.continuerules`, and `.cursor/rules/*.md` where they already exist. Re-runs replace the prior block in place. Files you didn't already have are left uncreated — no proliferating stubs.
39
+ 7. **Offers to enable `required_conversation_resolution`** on your default branch. Clud Bug auto-resolves its own review threads when fixes land — but that only gates merges when conversation-resolution is required. Init detects the current state via `gh`, prompts to enable (auto-yes with `--accept-all`), and degrades to an advisory message if you lack admin perms / `gh` isn't installed / the branch has no base protection rule. Pass `--no-set-protection` to skip the prompt entirely — for repos that manage branch protection via ruleset or org policy.
39
40
 
40
41
  ## CLI options
41
42
 
42
43
  ```
43
44
  npx clud-bug init [options]
44
45
 
45
- --offline Skip skills.sh; install only the bundled baseline skills.
46
- --accept-all,-y Accept the recommended skill set without prompting.
47
- --commit git add + commit the generated files when done.
48
- --help,-h Show help.
46
+ --offline Skip skills.sh; install only the bundled baseline skills.
47
+ --accept-all,-y Accept the recommended skill set (and the
48
+ branch-protection prompt) without prompting.
49
+ --no-set-protection Skip the prompt that offers to enable
50
+ required_conversation_resolution on the default
51
+ branch. For repos that manage branch protection
52
+ via ruleset or org policy.
53
+ --commit git add + commit the generated files when done.
54
+ --help,-h Show help.
49
55
  ```
50
56
 
51
57
  ## Staying up to date
package/bin/clud-bug.js CHANGED
@@ -16,6 +16,7 @@ import { computeAuditFileSet, renderAuditHeader } from '../lib/audit.js';
16
16
  import { runUpdate } from '../lib/update.js';
17
17
  import { getPendingWorkflowEdits, makeBranchName, git as gitCmd } from '../lib/edit-workflow.js';
18
18
  import { applyToRepo as applyAgentDocs } from '../lib/agents-md.js';
19
+ import { detectRepo, detectDefaultBranch, getProtectionState, enableConversationResolution } from '../lib/branch-protection.js';
19
20
 
20
21
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
21
22
  const TEMPLATES = join(PKG_ROOT, 'templates');
@@ -25,6 +26,7 @@ function parseArgs(argv) {
25
26
  const args = {
26
27
  _: [], offline: false, acceptAll: false, commit: false, help: false, version: false,
27
28
  since: null, changedIn: null, scopes: [], out: null,
29
+ setProtection: true,
28
30
  };
29
31
  for (let i = 0; i < argv.length; i++) {
30
32
  const a = argv[i];
@@ -37,6 +39,7 @@ function parseArgs(argv) {
37
39
  else if (a === '--changed-in') args.changedIn = argv[++i];
38
40
  else if (a === '--scope') args.scopes.push(argv[++i]);
39
41
  else if (a === '--out') args.out = argv[++i];
42
+ else if (a === '--no-set-protection') args.setProtection = false;
40
43
  else args._.push(a);
41
44
  }
42
45
  return args;
@@ -64,6 +67,10 @@ Options:
64
67
  --offline Skip skills.sh; pin only the bundled baseline specimens.
65
68
  --accept-all,-y Accept the recommended specimens without prompting.
66
69
  --commit git add + commit the generated kit when done (init only).
70
+ --no-set-protection Skip the prompt that offers to enable
71
+ required_conversation_resolution on the default
72
+ branch (init only). Use for repos that manage
73
+ branch protection via ruleset or org policy.
67
74
  --since <date> Audit only files changed in commits after <date> (git date string).
68
75
  --changed-in <dur> Audit only files changed in the past <dur>: 7d, 2w, 1mo, 1y. (audit only)
69
76
  --scope <glob> Limit audit to files matching <glob>; repeatable. (audit only)
@@ -236,6 +243,13 @@ async function runInit(args) {
236
243
  spawnSync('git', ['commit', '-m', 'Add clud-bug 🐛 — a field guide to specimens crawling your code'], { cwd, stdio: 'inherit' });
237
244
  }
238
245
 
246
+ // Offer to enable required_conversation_resolution on the default
247
+ // branch. clud-bug auto-resolves its own review threads when fixes
248
+ // land — without this setting, that doesn't gate merges. Skipped on
249
+ // --no-set-protection for repos that manage protection via ruleset
250
+ // or org policy.
251
+ await runInitBranchProtection(args);
252
+
239
253
  log('');
240
254
  log('Field kit assembled. Next:');
241
255
  log(' 1. Set ANTHROPIC_API_KEY in your repo secrets:');
@@ -278,6 +292,114 @@ async function promptForSkills(recommended) {
278
292
  }
279
293
  }
280
294
 
295
+ // Branch-protection setup step at the end of `clud-bug init`.
296
+ // Offers to enable required_conversation_resolution on the default
297
+ // branch via gh API. Skipped cleanly when --no-set-protection is
298
+ // passed. Failure modes (no admin perms, no base protection rule,
299
+ // network error) all degrade to advisory log messages — they never
300
+ // fail the init run.
301
+ //
302
+ // gh and prompt are injectable for tests (defaults to spawning real
303
+ // gh + reading from real stdin).
304
+ async function runInitBranchProtection(args, { gh, prompt } = {}) {
305
+ if (!args.setProtection) {
306
+ log('');
307
+ log('🐛 Branch protection: skipped (--no-set-protection).');
308
+ return;
309
+ }
310
+ log('');
311
+ log('🐛 Branch protection');
312
+
313
+ // Detect repo + default branch. If gh isn't installed or the local
314
+ // dir isn't a github repo, treat as advisory and move on.
315
+ let owner, repo, branch;
316
+ try {
317
+ ({ owner, repo } = await detectRepo({ gh }));
318
+ branch = await detectDefaultBranch({ owner, repo, gh });
319
+ } catch (err) {
320
+ log(` Could not detect repo/branch (${err.message.split('\n')[0]}). Skipping.`);
321
+ log(' You can enable it manually: gh api -X POST repos/<owner>/<repo>/branches/<default>/protection/required_conversation_resolution');
322
+ return;
323
+ }
324
+
325
+ log(` Default branch: ${branch}`);
326
+
327
+ // Inspect current state.
328
+ const current = await getProtectionState({ owner, repo, branch, gh });
329
+ if (current.state === 'enabled') {
330
+ log(' required_conversation_resolution: already on — your repo is all set.');
331
+ return;
332
+ }
333
+ if (current.state === 'forbidden') {
334
+ log(' Could not read branch protection (no admin perms). Ask the repo owner to enable required_conversation_resolution, or re-run with --no-set-protection to silence this prompt.');
335
+ return;
336
+ }
337
+ if (current.state === 'unknown') {
338
+ log(` Could not read branch protection (${current.reason}). Skipping.`);
339
+ return;
340
+ }
341
+
342
+ // Short-circuit on no-protection BEFORE prompting. The single-flag
343
+ // POST endpoint requires a base protection rule on the branch — if
344
+ // there's none, enableConversationResolution would just 404. Skip
345
+ // the prompt and go straight to the actionable guidance (set up
346
+ // basic protection first, then re-run).
347
+ if (current.state === 'no-protection') {
348
+ log(' required_conversation_resolution: not set (no base protection rule on this branch)');
349
+ log(' Cannot enable yet: this branch has no base protection rule.');
350
+ log(` Set one up first: Settings → Branches → Add rule for ${branch}`);
351
+ log(' Then re-run clud-bug init (or toggle the setting in the GUI).');
352
+ return;
353
+ }
354
+
355
+ // current.state is 'disabled'.
356
+ log(' required_conversation_resolution: not set');
357
+
358
+ // Decide whether to prompt.
359
+ let shouldEnable;
360
+ if (args.acceptAll) {
361
+ // --accept-all is a real side-effect flag here: it flips a
362
+ // merge-gating repo setting. Make that explicit in the log so
363
+ // CI users running `clud-bug init --accept-all` see exactly
364
+ // what's happening instead of silently noticing later.
365
+ log(' --accept-all: will enable required_conversation_resolution. Pass --no-set-protection to skip.');
366
+ shouldEnable = true;
367
+ } else {
368
+ const ask = prompt ?? (async (q) => {
369
+ const rl = createInterface({ input, output });
370
+ try { return await rl.question(q); } finally { rl.close(); }
371
+ });
372
+ log('');
373
+ log(' Clud Bug auto-resolves its own review threads when fixes land.');
374
+ log(' Without required_conversation_resolution, that doesn\'t actually gate merges.');
375
+ const answer = await ask(` Enable required_conversation_resolution on ${branch}? [Y/n] `);
376
+ shouldEnable = !['n', 'no'].includes(answer.trim().toLowerCase());
377
+ }
378
+
379
+ if (!shouldEnable) {
380
+ log(' Skipped. Re-run with --accept-all or set it manually anytime.');
381
+ return;
382
+ }
383
+
384
+ const result = await enableConversationResolution({ owner, repo, branch, gh });
385
+ if (result.ok) {
386
+ log(' ✓ Enabled required_conversation_resolution.');
387
+ return;
388
+ }
389
+ if (result.state === 'no-protection') {
390
+ log(' Cannot enable: this branch has no base protection rule. Set up basic branch protection first:');
391
+ log(` Settings → Branches → Add rule for ${branch}`);
392
+ log(' Then re-run clud-bug init (or just toggle the setting in the GUI).');
393
+ return;
394
+ }
395
+ if (result.state === 'forbidden') {
396
+ log(' Cannot enable: you do not have admin permissions on this repository.');
397
+ log(' Ask the repo owner to enable it, or re-run with --no-set-protection to silence this prompt.');
398
+ return;
399
+ }
400
+ log(` Cannot enable (${result.reason}). You can enable it manually anytime.`);
401
+ }
402
+
281
403
  async function runList(_args) {
282
404
  const skillsDir = join(process.cwd(), '.claude', 'skills');
283
405
  const groups = await listInstalled(skillsDir);
@@ -0,0 +1,113 @@
1
+ // Helpers for managing `required_conversation_resolution` on the default
2
+ // branch via the `gh` CLI. Factored out so `runInit` can call this without
3
+ // embedding `spawnSync` boilerplate, and so tests can swap a mock for the
4
+ // real `gh` invocation.
5
+ //
6
+ // Why `gh` rather than direct fetch(): clud-bug already depends on `gh`
7
+ // being installed and authenticated (workflows use `gh pr comment`, edit
8
+ // workflows use `gh pr create`). Reusing it inherits the user's auth
9
+ // instead of asking them to set up another token.
10
+ //
11
+ // API endpoints used:
12
+ // GET /repos/{owner}/{repo}
13
+ // → .default_branch
14
+ // GET /repos/{owner}/{repo}/branches/{branch}/protection
15
+ // → .required_conversation_resolution.enabled (true/false), OR 404 if
16
+ // the branch has no protection rule at all.
17
+ // POST /repos/{owner}/{repo}/branches/{branch}/protection/required_conversation_resolution
18
+ // → enables the single flag without touching other settings. This is
19
+ // a real single-flag endpoint; we don't have to GET-merge-PUT the
20
+ // full protection JSON.
21
+
22
+ import { spawn } from 'node:child_process';
23
+
24
+ // Default `gh` invoker: spawns `gh <args>` and resolves with
25
+ // { code, stdout, stderr }. Tests pass a function with the same shape.
26
+ function defaultGh(args, { stdin } = {}) {
27
+ return new Promise((resolve, reject) => {
28
+ const child = spawn('gh', args, { stdio: ['pipe', 'pipe', 'pipe'] });
29
+ let stdout = '';
30
+ let stderr = '';
31
+ child.stdout.on('data', (d) => { stdout += d; });
32
+ child.stderr.on('data', (d) => { stderr += d; });
33
+ child.on('error', reject);
34
+ child.on('close', (code) => resolve({ code, stdout, stderr }));
35
+ if (stdin) child.stdin.end(stdin);
36
+ else child.stdin.end();
37
+ });
38
+ }
39
+
40
+ // Returns { owner, repo } from the local git remote. Uses
41
+ // `gh repo view --json owner,name` so it doesn't depend on parsing URLs.
42
+ export async function detectRepo({ gh = defaultGh } = {}) {
43
+ const { code, stdout, stderr } = await gh(['repo', 'view', '--json', 'owner,name']);
44
+ if (code !== 0) {
45
+ throw new Error(`gh repo view failed (${code}): ${stderr.trim() || '(no stderr)'}`);
46
+ }
47
+ const parsed = JSON.parse(stdout);
48
+ return { owner: parsed.owner.login, repo: parsed.name };
49
+ }
50
+
51
+ // Returns the default branch name (e.g. "main", "master", "trunk").
52
+ export async function detectDefaultBranch({ owner, repo, gh = defaultGh } = {}) {
53
+ const { code, stdout, stderr } = await gh(['api', `repos/${owner}/${repo}`, '--jq', '.default_branch']);
54
+ if (code !== 0) {
55
+ throw new Error(`Could not read default_branch for ${owner}/${repo}: ${stderr.trim() || stdout.trim()}`);
56
+ }
57
+ return stdout.trim();
58
+ }
59
+
60
+ // Inspects the current required_conversation_resolution state. Returns
61
+ // one of:
62
+ // { state: 'enabled' }
63
+ // { state: 'disabled' }
64
+ // { state: 'no-protection' } // branch has no protection rule at all
65
+ // { state: 'forbidden' } // user lacks admin perms
66
+ // { state: 'unknown', reason } // any other failure mode
67
+ //
68
+ // The reason this returns a discriminated union rather than throwing is
69
+ // that runInit decides what to do based on the state: each value above
70
+ // has a different user-facing message and follow-up action.
71
+ export async function getProtectionState({ owner, repo, branch, gh = defaultGh } = {}) {
72
+ const { code, stdout, stderr } = await gh([
73
+ 'api',
74
+ `repos/${owner}/${repo}/branches/${branch}/protection`,
75
+ '--jq', '.required_conversation_resolution.enabled // false',
76
+ ]);
77
+ if (code === 0) {
78
+ return { state: stdout.trim() === 'true' ? 'enabled' : 'disabled' };
79
+ }
80
+ // gh prints HTTP details to stderr. Look for the markers we recognize.
81
+ // We deliberately key on 403 / 'Forbidden' / 'Resource not accessible'
82
+ // rather than the bare word 'admin' — gh's error vocabulary can mention
83
+ // 'admin' in unrelated contexts (administrator@…, admin api endpoint,
84
+ // future error copy) and we don't want to misclassify those as
85
+ // permission failures.
86
+ const blob = `${stdout}\n${stderr}`;
87
+ if (/404|Branch not protected|Not Found/i.test(blob)) return { state: 'no-protection' };
88
+ if (/403|Forbidden|Resource not accessible/i.test(blob)) return { state: 'forbidden' };
89
+ return { state: 'unknown', reason: stderr.trim() || stdout.trim() || `gh exited ${code}` };
90
+ }
91
+
92
+ // Enables the single flag via the dedicated endpoint. Doesn't touch any
93
+ // other protection settings. Returns { ok: true } on success or
94
+ // { ok: false, state, reason } using the same state taxonomy as
95
+ // getProtectionState() so callers can produce a consistent message.
96
+ export async function enableConversationResolution({ owner, repo, branch, gh = defaultGh } = {}) {
97
+ const { code, stdout, stderr } = await gh([
98
+ 'api', '-X', 'POST',
99
+ `repos/${owner}/${repo}/branches/${branch}/protection/required_conversation_resolution`,
100
+ ]);
101
+ if (code === 0) return { ok: true };
102
+ // Match the same precise alternatives as getProtectionState — no bare
103
+ // 'admin' fallback to avoid misclassifying unrelated error messages
104
+ // that happen to contain the word.
105
+ const blob = `${stdout}\n${stderr}`;
106
+ if (/404|Branch not protected|Not Found/i.test(blob)) {
107
+ return { ok: false, state: 'no-protection', reason: 'Branch has no base protection rule; enable basic branch protection first.' };
108
+ }
109
+ if (/403|Forbidden|Resource not accessible/i.test(blob)) {
110
+ return { ok: false, state: 'forbidden', reason: 'You do not have admin permissions on this repository.' };
111
+ }
112
+ return { ok: false, state: 'unknown', reason: stderr.trim() || stdout.trim() || `gh exited ${code}` };
113
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clud-bug",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "Claude PR review with project-aware skills. CLI installs a working GitHub Actions workflow and curates skills from skills.sh.",
5
5
  "homepage": "https://cludbug.dev",
6
6
  "bugs": "https://github.com/thrillmot/clud-bug/issues",