claude-attribution 1.2.4 → 1.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-attribution",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
4
4
  "description": "AI code attribution tracking for Claude Code sessions — checkpoint-based line diff approach",
5
5
  "type": "module",
6
6
  "bin": {
@@ -5,11 +5,21 @@
5
5
  *
6
6
  * If no path is given, installs into the current working directory.
7
7
  */
8
- import { readFile, writeFile, appendFile, mkdir } from "fs/promises";
8
+ import {
9
+ readFile,
10
+ writeFile,
11
+ appendFile,
12
+ mkdir,
13
+ mkdtemp,
14
+ unlink,
15
+ rmdir,
16
+ } from "fs/promises";
9
17
  import { existsSync } from "fs";
10
18
  import { execFile } from "child_process";
11
19
  import { promisify } from "util";
12
20
  import { resolve, join } from "path";
21
+ import { tmpdir } from "os";
22
+ import { createInterface } from "readline";
13
23
  import {
14
24
  ATTRIBUTION_ROOT,
15
25
  mergeHooks,
@@ -24,6 +34,208 @@ const execFileAsync = promisify(execFile);
24
34
 
25
35
  const CLI_BIN = resolve(ATTRIBUTION_ROOT, "bin", "claude-attribution");
26
36
 
37
+ /**
38
+ * The exact GitHub Actions job name written into our workflow template.
39
+ * Must match the `name:` field of the `metrics` job in pr-metrics-workflow.yml.
40
+ */
41
+ const WORKFLOW_CHECK_NAME = "Claude Code Attribution Metrics";
42
+
43
+ /** Extract "owner/repo" from an origin remote URL (SSH or HTTPS). */
44
+ function remoteUrlToSlug(url: string): string | null {
45
+ const m =
46
+ url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/) ??
47
+ url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
48
+ return m?.[1] ?? null;
49
+ }
50
+
51
+ /** Call `gh api <path>` and return parsed JSON, or null on any error. */
52
+ async function ghApiGet(path: string): Promise<unknown> {
53
+ try {
54
+ const { stdout } = (await execFileAsync("gh", [
55
+ "api",
56
+ path,
57
+ ])) as unknown as {
58
+ stdout: string;
59
+ };
60
+ return JSON.parse(stdout) as unknown;
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /** Prompt the user for a yes/no answer. Returns false in non-TTY contexts. */
67
+ async function promptYesNo(question: string): Promise<boolean> {
68
+ if (!process.stdin.isTTY) return false;
69
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
70
+ return new Promise((resolve) => {
71
+ rl.question(question, (answer) => {
72
+ rl.close();
73
+ resolve(answer.trim().toLowerCase() === "y");
74
+ });
75
+ });
76
+ }
77
+
78
+ function printRequiredCheckNote(branch: string): void {
79
+ console.log(
80
+ `\n ℹ️ To block merges when this workflow fails, add '${WORKFLOW_CHECK_NAME}'`,
81
+ );
82
+ console.log(
83
+ ` to required status checks for '${branch}' in Settings → Branches.`,
84
+ );
85
+ }
86
+
87
+ /**
88
+ * PATCH the required status checks for a branch via the GitHub API.
89
+ * Writes the JSON body to a temp file to avoid arg-length issues.
90
+ */
91
+ async function patchRequiredChecks(
92
+ slug: string,
93
+ branch: string,
94
+ body: unknown,
95
+ ): Promise<void> {
96
+ const tmpDir = await mkdtemp(join(tmpdir(), "claude-attribution-api-"));
97
+ const tmpFile = join(tmpDir, "body.json");
98
+ try {
99
+ await writeFile(tmpFile, JSON.stringify(body), { flag: "wx" });
100
+ await execFileAsync("gh", [
101
+ "api",
102
+ `repos/${slug}/branches/${branch}/protection/required_status_checks`,
103
+ "--method",
104
+ "PATCH",
105
+ "--input",
106
+ tmpFile,
107
+ ]);
108
+ } finally {
109
+ await unlink(tmpFile).catch(() => {});
110
+ await rmdir(tmpDir).catch(() => {});
111
+ }
112
+ }
113
+
114
+ /**
115
+ * After installing the workflow, check branch protection / rulesets and offer
116
+ * to add our workflow job as a required status check.
117
+ *
118
+ * - Classic branch protection: fully automatic (detect → prompt → PATCH)
119
+ * - Rulesets: detect only → informational note (ruleset API requires full PUT)
120
+ * - Any error or non-TTY: fall back to informational note
121
+ */
122
+ async function maybeAddRequiredCheck(repoRoot: string): Promise<void> {
123
+ try {
124
+ // Resolve the GitHub slug from the origin remote
125
+ const { stdout: remoteOut } = (await execFileAsync(
126
+ "git",
127
+ ["remote", "get-url", "origin"],
128
+ { cwd: repoRoot },
129
+ )) as unknown as { stdout: string };
130
+ const slug = remoteUrlToSlug(remoteOut.trim());
131
+ if (!slug) return;
132
+
133
+ // Get the default branch name
134
+ const repoData = await ghApiGet(`repos/${slug}`);
135
+ const branch = (repoData as { default_branch?: string } | null)
136
+ ?.default_branch;
137
+ if (!branch) return;
138
+
139
+ // --- Classic branch protection ---
140
+ const protection = await ghApiGet(
141
+ `repos/${slug}/branches/${branch}/protection`,
142
+ );
143
+
144
+ if (!protection) {
145
+ // No classic protection — check rulesets (detect-only)
146
+ const rulesets = await ghApiGet(`repos/${slug}/rulesets`);
147
+ if (Array.isArray(rulesets) && rulesets.length > 0) {
148
+ // Check if any ruleset already requires our check
149
+ const alreadyRequired = (
150
+ rulesets as Array<{
151
+ rules?: Array<{
152
+ type: string;
153
+ parameters?: {
154
+ required_status_checks?: Array<{ context: string }>;
155
+ };
156
+ }>;
157
+ }>
158
+ ).some((rs) =>
159
+ rs.rules?.some(
160
+ (r) =>
161
+ r.type === "required_status_checks" &&
162
+ r.parameters?.required_status_checks?.some(
163
+ (c) => c.context === WORKFLOW_CHECK_NAME,
164
+ ),
165
+ ),
166
+ );
167
+ if (alreadyRequired) {
168
+ console.log(
169
+ `✓ '${WORKFLOW_CHECK_NAME}' already a required status check`,
170
+ );
171
+ return;
172
+ }
173
+ console.log(
174
+ `\n ℹ️ Ruleset branch protection detected on '${branch}'.`,
175
+ );
176
+ console.log(
177
+ ` Add '${WORKFLOW_CHECK_NAME}' to required status checks`,
178
+ );
179
+ console.log(
180
+ ` in Settings → Rules to block merges on workflow failure.`,
181
+ );
182
+ }
183
+ return;
184
+ }
185
+
186
+ // Extract current required check names from classic protection
187
+ type Check = { context: string; app_id: number };
188
+ const prot = protection as {
189
+ required_status_checks?: {
190
+ strict: boolean;
191
+ contexts?: string[];
192
+ checks?: Check[];
193
+ };
194
+ };
195
+ const existingChecks: Check[] =
196
+ prot.required_status_checks?.checks ??
197
+ (prot.required_status_checks?.contexts ?? []).map((c) => ({
198
+ context: c,
199
+ app_id: -1,
200
+ }));
201
+ const strict = prot.required_status_checks?.strict ?? false;
202
+
203
+ if (existingChecks.some((c) => c.context === WORKFLOW_CHECK_NAME)) {
204
+ console.log(`✓ '${WORKFLOW_CHECK_NAME}' already a required status check`);
205
+ return;
206
+ }
207
+
208
+ // Prompt
209
+ console.log(`\n Branch protection is active on '${branch}'.`);
210
+ const yes = await promptYesNo(
211
+ ` Add '${WORKFLOW_CHECK_NAME}' as a required status check? [y/N] `,
212
+ );
213
+ if (!yes) {
214
+ printRequiredCheckNote(branch);
215
+ return;
216
+ }
217
+
218
+ await patchRequiredChecks(slug, branch, {
219
+ strict,
220
+ checks: [...existingChecks, { context: WORKFLOW_CHECK_NAME, app_id: -1 }],
221
+ });
222
+ console.log(
223
+ `✓ Added '${WORKFLOW_CHECK_NAME}' as a required status check on '${branch}'`,
224
+ );
225
+ } catch {
226
+ // Any failure — don't break install, just print the note
227
+ console.log(
228
+ `\n ℹ️ Could not configure required status checks automatically.`,
229
+ );
230
+ console.log(
231
+ ` To block merges on workflow failure, add '${WORKFLOW_CHECK_NAME}'`,
232
+ );
233
+ console.log(
234
+ ` to required status checks in your branch protection settings.`,
235
+ );
236
+ }
237
+ }
238
+
27
239
  async function main() {
28
240
  const args = process.argv.slice(2);
29
241
  const runnerFlagIdx = args.findIndex((a: string) => a === "--runner");
@@ -174,7 +386,10 @@ async function main() {
174
386
  `✓ Installed .github/workflows/claude-attribution-pr.yml — runner: ${runsOn}${detectedNote}`,
175
387
  );
176
388
 
177
- // 5. Record installed version for auto-upgrade tracking
389
+ // 5. Check branch protection and offer to add required status check
390
+ await maybeAddRequiredCheck(targetRepo);
391
+
392
+ // 6. Record installed version for auto-upgrade tracking
178
393
  const pkg = JSON.parse(
179
394
  await readFile(join(ATTRIBUTION_ROOT, "package.json"), "utf8"),
180
395
  ) as { version: string };