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.
- package/package.json +1 -1
- package/src/attribution/minimap.ts +160 -0
- package/src/cli.ts +1 -1
- package/src/commands/init.ts +33 -131
- package/src/commands/pr.ts +6 -3
- package/src/hooks/post-tool-use.ts +11 -2
- package/src/metrics/collect.ts +130 -37
- package/src/metrics/transcript.ts +67 -28
- package/src/setup/branch-protection.ts +432 -0
- package/src/setup/install.ts +89 -182
- package/src/setup/templates/pr-metrics-workflow.yml +4 -1
|
@@ -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
|
+
}
|