claude-attribution 1.2.5 → 1.2.8

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.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Branch protection utilities for claude-attribution install.
3
+ *
4
+ * Handles both classic branch protection rules and GitHub rulesets.
5
+ * After installing the workflow, detects what protection is active on the
6
+ * default branch and interactively offers to add our workflow job as a
7
+ * required status check.
8
+ *
9
+ * Design:
10
+ * - Classic protection: PATCH .../required_status_checks (preserves existing)
11
+ * - Rulesets: PUT .../rulesets/{id} with updated rules (preserves all other rules)
12
+ * - Both present: numbered prompt so user chooses where to add
13
+ * - Already configured: silent skip for that protection type
14
+ * - Any failure: graceful fallback to informational note, never breaks install
15
+ */
16
+ import { execFile } from "child_process";
17
+ import { promisify } from "util";
18
+ import { writeFile, unlink, rmdir, mkdtemp } from "fs/promises";
19
+ import { tmpdir } from "os";
20
+ import { join } from "path";
21
+ import { createInterface } from "readline";
22
+
23
+ const execFileAsync = promisify(execFile);
24
+
25
+ /** The GitHub Actions job name — must match `jobs.metrics.name` in the workflow template. */
26
+ export const WORKFLOW_CHECK_NAME = "Claude Code Attribution Metrics";
27
+
28
+ /** GitHub Actions app ID — used so the check shows "GitHub Actions" rather than "any source". */
29
+ const GITHUB_ACTIONS_APP_ID = 15368;
30
+
31
+ type Check = { context: string; app_id: number };
32
+
33
+ interface ClassicStatus {
34
+ branch: string;
35
+ strict: boolean;
36
+ checks: Check[];
37
+ hasOurCheck: boolean;
38
+ }
39
+
40
+ interface RulesetStatus {
41
+ id: number;
42
+ name: string;
43
+ hasOurCheck: boolean;
44
+ /** Full raw ruleset object — re-submitted on PUT to preserve all fields. */
45
+ raw: RawRuleset;
46
+ }
47
+
48
+ interface RawRuleset {
49
+ name: string;
50
+ target?: string;
51
+ enforcement?: string;
52
+ conditions?: unknown;
53
+ bypass_actors?: unknown[];
54
+ rules?: RawRule[];
55
+ }
56
+
57
+ interface RawRule {
58
+ type: string;
59
+ parameters?: {
60
+ strict_required_status_checks_policy?: boolean;
61
+ required_status_checks?: Array<{
62
+ context: string;
63
+ integration_id?: number;
64
+ }>;
65
+ [key: string]: unknown;
66
+ };
67
+ }
68
+
69
+ // ─── GitHub API helpers ───────────────────────────────────────────────────────
70
+
71
+ async function ghGet(path: string): Promise<unknown> {
72
+ try {
73
+ const { stdout } = (await execFileAsync("gh", [
74
+ "api",
75
+ path,
76
+ ])) as unknown as { stdout: string };
77
+ return JSON.parse(stdout) as unknown;
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ async function ghPut(path: string, body: unknown): Promise<void> {
84
+ const tmpDir = await mkdtemp(join(tmpdir(), "claude-attribution-api-"));
85
+ const tmpFile = join(tmpDir, "body.json");
86
+ try {
87
+ await writeFile(tmpFile, JSON.stringify(body), { flag: "wx" });
88
+ await execFileAsync("gh", [
89
+ "api",
90
+ path,
91
+ "--method",
92
+ "PUT",
93
+ "--input",
94
+ tmpFile,
95
+ ]);
96
+ } finally {
97
+ await unlink(tmpFile).catch(() => {});
98
+ await rmdir(tmpDir).catch(() => {});
99
+ }
100
+ }
101
+
102
+ async function ghPatch(path: string, body: unknown): Promise<void> {
103
+ const tmpDir = await mkdtemp(join(tmpdir(), "claude-attribution-api-"));
104
+ const tmpFile = join(tmpDir, "body.json");
105
+ try {
106
+ await writeFile(tmpFile, JSON.stringify(body), { flag: "wx" });
107
+ await execFileAsync("gh", [
108
+ "api",
109
+ path,
110
+ "--method",
111
+ "PATCH",
112
+ "--input",
113
+ tmpFile,
114
+ ]);
115
+ } finally {
116
+ await unlink(tmpFile).catch(() => {});
117
+ await rmdir(tmpDir).catch(() => {});
118
+ }
119
+ }
120
+
121
+ // ─── Detection ────────────────────────────────────────────────────────────────
122
+
123
+ async function getClassicStatus(
124
+ slug: string,
125
+ branch: string,
126
+ ): Promise<ClassicStatus | null> {
127
+ const data = await ghGet(`repos/${slug}/branches/${branch}/protection`);
128
+ if (!data) return null;
129
+
130
+ const prot = data as {
131
+ required_status_checks?: {
132
+ strict: boolean;
133
+ contexts?: string[];
134
+ checks?: Check[];
135
+ };
136
+ };
137
+
138
+ const rsc = prot.required_status_checks;
139
+ if (!rsc) {
140
+ // Protection exists but no required status checks configured yet
141
+ return {
142
+ branch,
143
+ strict: false,
144
+ checks: [],
145
+ hasOurCheck: false,
146
+ };
147
+ }
148
+
149
+ const checks: Check[] =
150
+ rsc.checks && rsc.checks.length > 0
151
+ ? rsc.checks
152
+ : (rsc.contexts ?? []).map((c) => ({ context: c, app_id: -1 }));
153
+
154
+ return {
155
+ branch,
156
+ strict: rsc.strict,
157
+ checks,
158
+ hasOurCheck: checks.some((c) => c.context === WORKFLOW_CHECK_NAME),
159
+ };
160
+ }
161
+
162
+ async function getRulesetStatuses(slug: string): Promise<RulesetStatus[]> {
163
+ const list = await ghGet(`repos/${slug}/rulesets`);
164
+ if (!Array.isArray(list) || list.length === 0) return [];
165
+
166
+ // Fetch full details for each ruleset in parallel (rules not in list response)
167
+ const results = await Promise.all(
168
+ (list as Array<{ id: number }>).map(async (rs) => {
169
+ const full = (await ghGet(
170
+ `repos/${slug}/rulesets/${rs.id}`,
171
+ )) as RawRuleset | null;
172
+ if (!full) return null;
173
+
174
+ const statusCheckRule = full.rules?.find(
175
+ (r) => r.type === "required_status_checks",
176
+ );
177
+ const hasOurCheck =
178
+ statusCheckRule?.parameters?.required_status_checks?.some(
179
+ (c) => c.context === WORKFLOW_CHECK_NAME,
180
+ ) ?? false;
181
+
182
+ return {
183
+ id: rs.id,
184
+ name: full.name,
185
+ hasOurCheck,
186
+ raw: full,
187
+ } satisfies RulesetStatus;
188
+ }),
189
+ );
190
+
191
+ return results.filter((r): r is RulesetStatus => r !== null);
192
+ }
193
+
194
+ /** Extract "owner/repo" from SSH or HTTPS origin remote URL. */
195
+ export function remoteUrlToSlug(url: string): string | null {
196
+ const m =
197
+ url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ??
198
+ url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
199
+ return m?.[1] ?? null;
200
+ }
201
+
202
+ // ─── Modification ─────────────────────────────────────────────────────────────
203
+
204
+ async function addToClassic(
205
+ classic: ClassicStatus,
206
+ slug: string,
207
+ ): Promise<void> {
208
+ await ghPatch(
209
+ `repos/${slug}/branches/${classic.branch}/protection/required_status_checks`,
210
+ {
211
+ strict: classic.strict,
212
+ checks: [
213
+ ...classic.checks,
214
+ { context: WORKFLOW_CHECK_NAME, app_id: GITHUB_ACTIONS_APP_ID },
215
+ ],
216
+ },
217
+ );
218
+ }
219
+
220
+ async function addToRuleset(rs: RulesetStatus, slug: string): Promise<void> {
221
+ const rules = rs.raw.rules ?? [];
222
+ const existingRule = rules.find((r) => r.type === "required_status_checks");
223
+
224
+ let updatedRules: RawRule[];
225
+ if (existingRule) {
226
+ const existingChecks =
227
+ existingRule.parameters?.required_status_checks ?? [];
228
+ updatedRules = rules.map((r) =>
229
+ r.type === "required_status_checks"
230
+ ? {
231
+ ...r,
232
+ parameters: {
233
+ ...r.parameters,
234
+ required_status_checks: [
235
+ ...existingChecks,
236
+ {
237
+ context: WORKFLOW_CHECK_NAME,
238
+ integration_id: GITHUB_ACTIONS_APP_ID,
239
+ },
240
+ ],
241
+ },
242
+ }
243
+ : r,
244
+ );
245
+ } else {
246
+ // No required_status_checks rule yet — add one
247
+ updatedRules = [
248
+ ...rules,
249
+ {
250
+ type: "required_status_checks",
251
+ parameters: {
252
+ strict_required_status_checks_policy: false,
253
+ required_status_checks: [
254
+ {
255
+ context: WORKFLOW_CHECK_NAME,
256
+ integration_id: GITHUB_ACTIONS_APP_ID,
257
+ },
258
+ ],
259
+ },
260
+ },
261
+ ];
262
+ }
263
+
264
+ await ghPut(`repos/${slug}/rulesets/${rs.id}`, {
265
+ ...rs.raw,
266
+ rules: updatedRules,
267
+ });
268
+ }
269
+
270
+ // ─── Prompts ──────────────────────────────────────────────────────────────────
271
+
272
+ async function promptYesNo(question: string): Promise<boolean> {
273
+ if (!process.stdin.isTTY) return false;
274
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
275
+ return new Promise((resolve) => {
276
+ rl.question(question, (answer) => {
277
+ rl.close();
278
+ resolve(answer.trim().toLowerCase() === "y");
279
+ });
280
+ });
281
+ }
282
+
283
+ async function promptChoice(
284
+ question: string,
285
+ options: string[],
286
+ ): Promise<number> {
287
+ if (!process.stdin.isTTY) return -1;
288
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
289
+ return new Promise((resolve) => {
290
+ const numbered = options.map((o, i) => ` [${i + 1}] ${o}`).join("\n");
291
+ rl.question(`${question}\n${numbered}\n Choice [1]: `, (answer) => {
292
+ rl.close();
293
+ const n = parseInt(answer.trim() || "1", 10);
294
+ resolve(n >= 1 && n <= options.length ? n - 1 : -1);
295
+ });
296
+ });
297
+ }
298
+
299
+ function printNote(branch: string): void {
300
+ console.log(
301
+ `\n ℹ️ To block merges when this workflow fails, add '${WORKFLOW_CHECK_NAME}'`,
302
+ );
303
+ console.log(
304
+ ` to required status checks for '${branch}' in Settings → Branches or Rules.`,
305
+ );
306
+ }
307
+
308
+ // ─── Main entry point ─────────────────────────────────────────────────────────
309
+
310
+ /**
311
+ * Detect branch protection on the repo's default branch and offer to add
312
+ * the workflow job as a required status check. Called from install.ts after
313
+ * the workflow file is written. Never throws — all errors fall back to a note.
314
+ */
315
+ export async function configureRequiredCheck(repoRoot: string): Promise<void> {
316
+ try {
317
+ const { stdout: remoteOut } = (await execFileAsync(
318
+ "git",
319
+ ["remote", "get-url", "origin"],
320
+ { cwd: repoRoot },
321
+ )) as unknown as { stdout: string };
322
+ const slug = remoteUrlToSlug(remoteOut.trim());
323
+ if (!slug) return;
324
+
325
+ const repoData = await ghGet(`repos/${slug}`);
326
+ const branch = (repoData as { default_branch?: string } | null)
327
+ ?.default_branch;
328
+ if (!branch) return;
329
+
330
+ // Detect both protection types in parallel
331
+ const [classic, rulesets] = await Promise.all([
332
+ getClassicStatus(slug, branch),
333
+ getRulesetStatuses(slug),
334
+ ]);
335
+
336
+ // Determine what needs to be added
337
+ const classicNeeded = classic !== null && !classic.hasOurCheck;
338
+ const rulesetsNeeded = rulesets.filter((rs) => !rs.hasOurCheck);
339
+
340
+ // Already fully configured
341
+ if (!classicNeeded && rulesetsNeeded.length === 0) {
342
+ if (classic?.hasOurCheck || rulesets.some((rs) => rs.hasOurCheck)) {
343
+ console.log(
344
+ `✓ '${WORKFLOW_CHECK_NAME}' already a required status check`,
345
+ );
346
+ }
347
+ return;
348
+ }
349
+
350
+ // Build the list of targets that need our check
351
+ const targets: Array<
352
+ | { kind: "classic"; classic: ClassicStatus }
353
+ | { kind: "ruleset"; rs: RulesetStatus }
354
+ > = [];
355
+ if (classicNeeded) targets.push({ kind: "classic", classic });
356
+ for (const rs of rulesetsNeeded) targets.push({ kind: "ruleset", rs });
357
+
358
+ let chosen: typeof targets;
359
+
360
+ if (targets.length === 1) {
361
+ // Single target — simple yes/no
362
+ const target = targets[0]!;
363
+ const label =
364
+ target.kind === "classic"
365
+ ? `branch protection rule on '${branch}'`
366
+ : `ruleset '${target.rs.name}'`;
367
+ console.log(`\n Branch protection is active on '${branch}'.`);
368
+ const yes = await promptYesNo(
369
+ ` Add '${WORKFLOW_CHECK_NAME}' to ${label}? [y/N] `,
370
+ );
371
+ if (!yes) {
372
+ printNote(branch);
373
+ return;
374
+ }
375
+ chosen = targets;
376
+ } else {
377
+ // Multiple targets — numbered choice
378
+ const options = [
379
+ ...targets.map((t) =>
380
+ t.kind === "classic"
381
+ ? `Branch protection rule on '${branch}'`
382
+ : `Ruleset: '${t.rs.name}'`,
383
+ ),
384
+ "Both",
385
+ "Skip",
386
+ ];
387
+ console.log(
388
+ `\n Multiple branch protection rules active on '${branch}'.`,
389
+ );
390
+ console.log(
391
+ ` Where should '${WORKFLOW_CHECK_NAME}' be added as a required check?`,
392
+ );
393
+ const idx = await promptChoice("", options);
394
+ if (idx === -1 || idx === options.length - 1) {
395
+ // Skip
396
+ printNote(branch);
397
+ return;
398
+ }
399
+ if (idx === options.length - 2) {
400
+ // Both
401
+ chosen = targets;
402
+ } else {
403
+ chosen = [targets[idx]!];
404
+ }
405
+ }
406
+
407
+ // Apply changes
408
+ for (const target of chosen) {
409
+ if (target.kind === "classic") {
410
+ await addToClassic(target.classic, slug);
411
+ console.log(
412
+ `✓ Added '${WORKFLOW_CHECK_NAME}' to branch protection on '${branch}'`,
413
+ );
414
+ } else {
415
+ await addToRuleset(target.rs, slug);
416
+ console.log(
417
+ `✓ Added '${WORKFLOW_CHECK_NAME}' to ruleset '${target.rs.name}'`,
418
+ );
419
+ }
420
+ }
421
+ } catch {
422
+ console.log(
423
+ `\n ℹ️ Could not configure required status checks automatically.`,
424
+ );
425
+ console.log(
426
+ ` To block merges on workflow failure, add '${WORKFLOW_CHECK_NAME}'`,
427
+ );
428
+ console.log(
429
+ ` to required status checks in your branch protection settings.`,
430
+ );
431
+ }
432
+ }