claude-attribution 1.2.9 → 1.3.0
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 +118 -1
- package/package.json +1 -1
- package/src/attribution/git-notes.ts +131 -1
- package/src/cli.ts +4 -0
- package/src/commands/note-ai-commit.ts +69 -0
- package/src/metrics/collect.ts +28 -0
- package/src/setup/branch-protection.ts +7 -11
- package/src/setup/install.ts +77 -1
- package/src/setup/templates/claude-attribution-gha.yml +38 -0
- package/src/setup/templates/pr-metrics-workflow.yml +2 -0
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
> ```
|
|
12
12
|
> From then on, just work normally. After each `git commit` you'll see a one-line attribution summary in your terminal. When you're ready to open a PR, run `/pr` in Claude Code (or `claude-attribution pr "feat: your title"`) — it fills in the metrics automatically, no copy-paste needed.
|
|
13
13
|
>
|
|
14
|
-
> **Using Copilot?**
|
|
14
|
+
> **Using Copilot or @claude (claude-code-action)?** Commits made by AI bots (Copilot coding agent, `@claude` via claude-code-action) are automatically detected and attributed as 100% AI in metrics — no extra steps needed. See [AI Actor Attribution](#ai-actor-attribution-copilot-bot-claude-gha) for details. For Copilot-specific org-level stats, use the GitHub Copilot usage dashboard. Both tools' data flows into the VP Datadog dashboard automatically on every PR merge.
|
|
15
15
|
>
|
|
16
16
|
> **Requirements:** [Bun](https://bun.sh) (preferred) or Node 18+, and `gh` (GitHub CLI) authenticated for the `/pr` command.
|
|
17
17
|
|
|
@@ -21,6 +21,42 @@ AI code attribution tracking for Claude Code. Measures which lines in a commit w
|
|
|
21
21
|
|
|
22
22
|
---
|
|
23
23
|
|
|
24
|
+
## GitHub Actions Requirements
|
|
25
|
+
|
|
26
|
+
Three workflows are installed into repos that use this tool. Each requires specific GitHub Actions to be allowed in your org. If your org enforces an action allowlist, you must approve the third-party action before the workflows will run.
|
|
27
|
+
|
|
28
|
+
### Workflows and the actions they use
|
|
29
|
+
|
|
30
|
+
| Workflow file | Trigger | GitHub-owned actions | Third-party actions |
|
|
31
|
+
|---|---|---|---|
|
|
32
|
+
| `claude-attribution-pr.yml` | PR opened / pushed | `actions/checkout@v4`, `actions/github-script@v7` | `oven-sh/setup-bun@v2` |
|
|
33
|
+
| `claude-attribution.yml` | PR merged | `actions/checkout@v4` | `oven-sh/setup-bun@v2` |
|
|
34
|
+
| `claude-attribution-gha.yml` | Every push (optional) | `actions/checkout@v4` | `oven-sh/setup-bun@v2` |
|
|
35
|
+
|
|
36
|
+
**GitHub-owned actions** (`actions/*`) are pre-approved in all orgs by default.
|
|
37
|
+
|
|
38
|
+
**`oven-sh/setup-bun@v2`** is a third-party action from the [Bun](https://bun.sh) team. It installs the Bun runtime so the `claude-attribution` CLI can execute without falling back to `npx tsx`. If your org requires explicit action approvals, add `oven-sh/setup-bun` to the allowlist at:
|
|
39
|
+
|
|
40
|
+
> Settings → Actions → General → Allow actions and reusable workflows → add `oven-sh/setup-bun@*`
|
|
41
|
+
|
|
42
|
+
### Workflow permissions
|
|
43
|
+
|
|
44
|
+
| Workflow | `contents` | `pull-requests` | Why |
|
|
45
|
+
|---|---|---|---|
|
|
46
|
+
| `claude-attribution-pr.yml` | read | write | Reads git history; writes metrics into the PR body |
|
|
47
|
+
| `claude-attribution.yml` | read | — | Reads git notes and pushes Datadog metrics |
|
|
48
|
+
| `claude-attribution-gha.yml` | write | — | Pushes attribution git notes back to origin |
|
|
49
|
+
|
|
50
|
+
### Required secrets
|
|
51
|
+
|
|
52
|
+
| Secret / Variable | Workflow | Required | Notes |
|
|
53
|
+
|---|---|---|---|
|
|
54
|
+
| `GITHUB_TOKEN` | All | Automatic | Provided by GitHub Actions; no setup needed |
|
|
55
|
+
| `DATADOG_API_KEY` | `claude-attribution.yml` | Yes | Org-level secret; needed to push metrics to Datadog |
|
|
56
|
+
| `DATADOG_SITE` | `claude-attribution.yml` | No | Org-level variable; defaults to `datadoghq.com` |
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
24
60
|
## For Repo Maintainers: Installing Into a Repo
|
|
25
61
|
|
|
26
62
|
### Prerequisites
|
|
@@ -103,6 +139,8 @@ The installer makes the following changes to the target repo:
|
|
|
103
139
|
|
|
104
140
|
**`.github/workflows/claude-attribution-pr.yml`** — GitHub Actions workflow that fires on every PR open and push. Injects metrics into the PR body automatically for PRs created outside Claude (Copilot, manual `gh pr create`, GitHub UI). Skips injection if the local `post-bash` hook already injected metrics on `opened`; always updates on `synchronize` (new commits).
|
|
105
141
|
|
|
142
|
+
**`.github/workflows/claude-attribution-gha.yml`** *(optional — installed when claude-code-action is detected)* — fires on every push. Writes a 100% AI git note for commits made by known AI actors (e.g. `@claude` via claude-code-action, Copilot coding agent). Silent no-op for human commits. See [AI Actor Attribution](#ai-actor-attribution-copilot-bot-claude-gha).
|
|
143
|
+
|
|
106
144
|
**`.claude/commands/`** — installs two slash commands:
|
|
107
145
|
- `/metrics` — generate a PR metrics report
|
|
108
146
|
- `/start` — mark the start of a new Jira ticket session
|
|
@@ -461,6 +499,85 @@ All metrics are tagged `repo:`, `pr:`, `branch:`, `author:`, `tool:` — enablin
|
|
|
461
499
|
|
|
462
500
|
---
|
|
463
501
|
|
|
502
|
+
## AI Actor Attribution (Copilot Bot, @claude GHA)
|
|
503
|
+
|
|
504
|
+
The local post-commit hook only runs when code is committed on a developer's machine. Commits made server-side by AI bots — such as `@claude` via [claude-code-action](https://github.com/anthropics/claude-code-action) or the Copilot coding agent — bypass the local hook entirely and would otherwise appear as 100% human in metrics.
|
|
505
|
+
|
|
506
|
+
claude-attribution handles these two ways:
|
|
507
|
+
|
|
508
|
+
### 1. Auto-detection in metrics (no setup required)
|
|
509
|
+
|
|
510
|
+
When generating PR metrics, branch commits that have no git note are checked for known AI actor signals:
|
|
511
|
+
|
|
512
|
+
| Signal | Example |
|
|
513
|
+
|---|---|
|
|
514
|
+
| Bot author email/name | `github-actions[bot]`, `copilot[bot]` |
|
|
515
|
+
| `Co-authored-by:` trailer | `Co-authored-by: Claude <...>` |
|
|
516
|
+
| `Co-authored-by:` trailer | `Co-authored-by: GitHub Copilot <...>` |
|
|
517
|
+
|
|
518
|
+
If detected, all non-blank committed lines are counted as AI in the metrics output. No git note is written — this is a metrics-time synthesis only.
|
|
519
|
+
|
|
520
|
+
**Not detectable:** Copilot "Commit suggestion" (the button on PR review comments). Those commits are authored as the human who clicked the button — there is no metadata distinguishing them from a human edit. They will count as HUMAN.
|
|
521
|
+
|
|
522
|
+
### 2. `note-ai-commit` command (writes a permanent git note)
|
|
523
|
+
|
|
524
|
+
For `@claude` via claude-code-action, you can write a permanent git note at CI time so attribution is durable (survives metrics regeneration, appears in Datadog dashboard):
|
|
525
|
+
|
|
526
|
+
```bash
|
|
527
|
+
# Write a 100% AI note for HEAD (then push the note):
|
|
528
|
+
claude-attribution note-ai-commit --push
|
|
529
|
+
|
|
530
|
+
# Only write the note if the commit looks like an AI actor commit (safe on every push):
|
|
531
|
+
claude-attribution note-ai-commit --if-ai-actor --push
|
|
532
|
+
|
|
533
|
+
# Write a note for a specific SHA:
|
|
534
|
+
claude-attribution note-ai-commit abc1234 --push
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
#### Setting up for claude-code-action
|
|
538
|
+
|
|
539
|
+
If your repo uses [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action), run `claude-attribution install` — it detects the action and offers to install `claude-attribution-gha.yml`, which records attribution automatically on every AI actor push.
|
|
540
|
+
|
|
541
|
+
To install the workflow manually, add `.github/workflows/claude-attribution-gha.yml`:
|
|
542
|
+
|
|
543
|
+
```yaml
|
|
544
|
+
name: Claude Attribution — AI Actor Commits
|
|
545
|
+
|
|
546
|
+
on:
|
|
547
|
+
push:
|
|
548
|
+
branches: ["**"]
|
|
549
|
+
|
|
550
|
+
permissions:
|
|
551
|
+
contents: write
|
|
552
|
+
|
|
553
|
+
jobs:
|
|
554
|
+
note-ai-commit:
|
|
555
|
+
name: Record AI actor commit attribution
|
|
556
|
+
runs-on: ubuntu-latest
|
|
557
|
+
steps:
|
|
558
|
+
- uses: actions/checkout@v4
|
|
559
|
+
with:
|
|
560
|
+
fetch-depth: 0
|
|
561
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
562
|
+
|
|
563
|
+
- name: Fetch attribution notes
|
|
564
|
+
run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
|
|
565
|
+
|
|
566
|
+
- uses: oven-sh/setup-bun@v2
|
|
567
|
+
|
|
568
|
+
- name: Install claude-attribution
|
|
569
|
+
run: |
|
|
570
|
+
npm install -g --prefix "${HOME}/.npm-global" claude-attribution
|
|
571
|
+
echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
|
|
572
|
+
|
|
573
|
+
- name: Record attribution if AI actor commit
|
|
574
|
+
run: claude-attribution note-ai-commit --if-ai-actor --push
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
The `--if-ai-actor` flag makes this a silent no-op for human commits — it only fires when the commit author or message matches a known AI actor pattern.
|
|
578
|
+
|
|
579
|
+
---
|
|
580
|
+
|
|
464
581
|
## OTel Traces (optional)
|
|
465
582
|
|
|
466
583
|
claude-attribution can export OpenTelemetry traces to any OTLP-compatible backend (Datadog APM, Jaeger, Tempo, etc.). This is **opt-in** — set one env var to enable it.
|
package/package.json
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { execFile } from "child_process";
|
|
15
15
|
import { promisify } from "util";
|
|
16
|
-
import
|
|
16
|
+
import {
|
|
17
|
+
aggregateTotals,
|
|
18
|
+
type AttributionResult,
|
|
19
|
+
type FileAttribution,
|
|
20
|
+
} from "./differ.ts";
|
|
17
21
|
|
|
18
22
|
const execFileAsync = promisify(execFile);
|
|
19
23
|
const NOTES_REF = "refs/notes/claude-attribution";
|
|
@@ -183,3 +187,129 @@ export async function committedContent(
|
|
|
183
187
|
return null;
|
|
184
188
|
}
|
|
185
189
|
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read the committed content of a file at any commit SHA.
|
|
193
|
+
* Returns null for deleted files or binary files should not be processed.
|
|
194
|
+
*/
|
|
195
|
+
export async function committedContentAt(
|
|
196
|
+
repoRoot: string,
|
|
197
|
+
sha: string,
|
|
198
|
+
relPath: string,
|
|
199
|
+
): Promise<string | null> {
|
|
200
|
+
try {
|
|
201
|
+
return await runRaw("git", ["show", `${sha}:${relPath}`], repoRoot);
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* List relative paths of files changed in any commit (not just HEAD).
|
|
209
|
+
*/
|
|
210
|
+
export async function filesInCommitAt(
|
|
211
|
+
repoRoot: string,
|
|
212
|
+
sha: string,
|
|
213
|
+
): Promise<string[]> {
|
|
214
|
+
const output = await run(
|
|
215
|
+
"git",
|
|
216
|
+
["diff-tree", "--no-commit-id", "-r", "--name-only", sha],
|
|
217
|
+
repoRoot,
|
|
218
|
+
);
|
|
219
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface CommitMeta {
|
|
223
|
+
authorName: string;
|
|
224
|
+
authorEmail: string;
|
|
225
|
+
/** Full commit message body. */
|
|
226
|
+
message: string;
|
|
227
|
+
/** Committer date in ISO 8601 format. */
|
|
228
|
+
timestamp: string;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get author, email, message, and timestamp for any commit SHA.
|
|
233
|
+
*/
|
|
234
|
+
export async function getCommitMeta(
|
|
235
|
+
repoRoot: string,
|
|
236
|
+
sha: string,
|
|
237
|
+
): Promise<CommitMeta> {
|
|
238
|
+
const nameEmail = await run(
|
|
239
|
+
"git",
|
|
240
|
+
["log", "-1", "--format=%an\n%ae", sha],
|
|
241
|
+
repoRoot,
|
|
242
|
+
);
|
|
243
|
+
const [authorName = "", authorEmail = ""] = nameEmail.split("\n");
|
|
244
|
+
const message = await run("git", ["log", "-1", "--format=%B", sha], repoRoot);
|
|
245
|
+
const timestamp = await run(
|
|
246
|
+
"git",
|
|
247
|
+
["log", "-1", "--format=%cI", sha],
|
|
248
|
+
repoRoot,
|
|
249
|
+
);
|
|
250
|
+
return { authorName, authorEmail, message, timestamp };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Return true if the commit was made by a known AI actor:
|
|
255
|
+
* - Bot authors: github-actions[bot], copilot[bot], etc.
|
|
256
|
+
* - Co-authored-by trailers referencing Claude or Copilot.
|
|
257
|
+
*
|
|
258
|
+
* Used to synthesize 100% AI attribution for commits that bypass the local
|
|
259
|
+
* post-commit hook (e.g. @claude via claude-code-action, Copilot coding agent).
|
|
260
|
+
*/
|
|
261
|
+
export function isKnownAiActorCommit(meta: CommitMeta): boolean {
|
|
262
|
+
const { authorName, authorEmail, message } = meta;
|
|
263
|
+
if (authorEmail.includes("[bot]") || authorName.includes("[bot]"))
|
|
264
|
+
return true;
|
|
265
|
+
if (/co-authored-by:.*claude/i.test(message)) return true;
|
|
266
|
+
if (/co-authored-by:.*copilot/i.test(message)) return true;
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build a 100% AI AttributionResult for a commit without running the
|
|
272
|
+
* checkpoint-based differ. All non-blank committed lines are marked AI.
|
|
273
|
+
*
|
|
274
|
+
* Used by `note-ai-commit` (to write git notes in GHA) and by `collect.ts`
|
|
275
|
+
* (to synthesize attribution at metrics time for unattributed AI actor commits).
|
|
276
|
+
*/
|
|
277
|
+
export async function buildAllAiResult(
|
|
278
|
+
repoRoot: string,
|
|
279
|
+
sha: string,
|
|
280
|
+
): Promise<AttributionResult> {
|
|
281
|
+
const [changedFiles, meta] = await Promise.all([
|
|
282
|
+
filesInCommitAt(repoRoot, sha),
|
|
283
|
+
getCommitMeta(repoRoot, sha),
|
|
284
|
+
]);
|
|
285
|
+
|
|
286
|
+
const fileResults: FileAttribution[] = (
|
|
287
|
+
await Promise.all(
|
|
288
|
+
changedFiles.map(async (relPath): Promise<FileAttribution | null> => {
|
|
289
|
+
const content = await committedContentAt(repoRoot, sha, relPath);
|
|
290
|
+
if (content === null || content.includes("\0")) return null;
|
|
291
|
+
const lines = content.split("\n");
|
|
292
|
+
const ai = lines.filter((l) => l.trim().length > 0).length;
|
|
293
|
+
const human = lines.length - ai;
|
|
294
|
+
const total = lines.length;
|
|
295
|
+
return {
|
|
296
|
+
path: relPath,
|
|
297
|
+
ai,
|
|
298
|
+
human,
|
|
299
|
+
mixed: 0,
|
|
300
|
+
total,
|
|
301
|
+
pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
|
|
302
|
+
};
|
|
303
|
+
}),
|
|
304
|
+
)
|
|
305
|
+
).filter((f): f is FileAttribution => f !== null);
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
commit: sha,
|
|
309
|
+
session: null,
|
|
310
|
+
branch: null,
|
|
311
|
+
timestamp: meta.timestamp,
|
|
312
|
+
files: fileResults,
|
|
313
|
+
totals: aggregateTotals(fileResults),
|
|
314
|
+
};
|
|
315
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -30,6 +30,9 @@ switch (cmd) {
|
|
|
30
30
|
case "pr":
|
|
31
31
|
await import("./commands/pr.ts");
|
|
32
32
|
break;
|
|
33
|
+
case "note-ai-commit":
|
|
34
|
+
await import("./commands/note-ai-commit.ts");
|
|
35
|
+
break;
|
|
33
36
|
case "init":
|
|
34
37
|
await import("./commands/init.ts");
|
|
35
38
|
break;
|
|
@@ -93,6 +96,7 @@ Commands:
|
|
|
93
96
|
metrics [id] Generate PR metrics report
|
|
94
97
|
pr [title] Create PR with metrics embedded (--draft, --base <branch>)
|
|
95
98
|
init [--ai | --ai-since <YYYY-MM-DD>] Set attribution baseline in the cumulative minimap
|
|
99
|
+
note-ai-commit [sha] [--push] [--if-ai-actor] Write 100% AI git note for a commit (GHA use)
|
|
96
100
|
start Mark session start for per-ticket scoping
|
|
97
101
|
hook <name> Run an internal hook (used by installed git hooks)
|
|
98
102
|
version Print version
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* note-ai-commit — write a 100% AI attribution git note for a commit.
|
|
3
|
+
*
|
|
4
|
+
* Usage: claude-attribution note-ai-commit [sha] [--push] [--if-ai-actor]
|
|
5
|
+
*
|
|
6
|
+
* Used as a step in claude-code-action GHA workflows to record that all
|
|
7
|
+
* lines committed by @claude are AI-authored. Defaults to HEAD.
|
|
8
|
+
*
|
|
9
|
+
* Options:
|
|
10
|
+
* --push Push the note to origin after writing.
|
|
11
|
+
* --if-ai-actor Only write the note if the commit looks like it was made
|
|
12
|
+
* by a known AI actor (bot author or Co-authored-by trailer).
|
|
13
|
+
* Silent no-op otherwise — safe to run on every push.
|
|
14
|
+
*
|
|
15
|
+
* All non-blank lines in the committed files are attributed as AI. Blank
|
|
16
|
+
* lines are always HUMAN per the attribution algorithm in differ.ts.
|
|
17
|
+
*/
|
|
18
|
+
import { resolve } from "path";
|
|
19
|
+
import { execFile } from "child_process";
|
|
20
|
+
import { promisify } from "util";
|
|
21
|
+
import {
|
|
22
|
+
writeNote,
|
|
23
|
+
headSha,
|
|
24
|
+
getCommitMeta,
|
|
25
|
+
isKnownAiActorCommit,
|
|
26
|
+
buildAllAiResult,
|
|
27
|
+
} from "../attribution/git-notes.ts";
|
|
28
|
+
|
|
29
|
+
const execFileAsync = promisify(execFile);
|
|
30
|
+
const NOTES_REF = "refs/notes/claude-attribution";
|
|
31
|
+
|
|
32
|
+
async function main() {
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const pushFlag = args.includes("--push");
|
|
35
|
+
const ifAiActorFlag = args.includes("--if-ai-actor");
|
|
36
|
+
const sha = args.find((a) => !a.startsWith("--")) ?? null;
|
|
37
|
+
|
|
38
|
+
const repoRoot = resolve(process.cwd());
|
|
39
|
+
const commitSha = sha ?? (await headSha(repoRoot));
|
|
40
|
+
|
|
41
|
+
if (ifAiActorFlag) {
|
|
42
|
+
const meta = await getCommitMeta(repoRoot, commitSha);
|
|
43
|
+
if (!isKnownAiActorCommit(meta)) return; // silent no-op
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const result = await buildAllAiResult(repoRoot, commitSha);
|
|
47
|
+
await writeNote(result, repoRoot, commitSha);
|
|
48
|
+
|
|
49
|
+
const { totals } = result;
|
|
50
|
+
console.log(
|
|
51
|
+
`[claude-attribution] ${commitSha.slice(0, 7)} — ${totals.ai} AI / ${totals.human} human / 0 mixed lines (${totals.pctAi}% AI) [ai-actor]`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (pushFlag) {
|
|
55
|
+
await execFileAsync(
|
|
56
|
+
"git",
|
|
57
|
+
["push", "origin", `${NOTES_REF}:${NOTES_REF}`],
|
|
58
|
+
{
|
|
59
|
+
cwd: repoRoot,
|
|
60
|
+
},
|
|
61
|
+
);
|
|
62
|
+
console.log("[claude-attribution] pushed attribution note");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch((err) => {
|
|
67
|
+
console.error("[claude-attribution] note-ai-commit error:", err);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
package/src/metrics/collect.ts
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
listNotes,
|
|
16
16
|
readNote,
|
|
17
17
|
getBranchCommitShas,
|
|
18
|
+
getCommitMeta,
|
|
19
|
+
isKnownAiActorCommit,
|
|
20
|
+
buildAllAiResult,
|
|
18
21
|
} from "../attribution/git-notes.ts";
|
|
19
22
|
import {
|
|
20
23
|
aggregateTotals,
|
|
@@ -173,6 +176,7 @@ async function getBranchAttribution(
|
|
|
173
176
|
listNotes(repoRoot),
|
|
174
177
|
getBranchCommitShas(repoRoot),
|
|
175
178
|
]);
|
|
179
|
+
const notedSet = new Set(allShas);
|
|
176
180
|
const branchSet = new Set(branchShas);
|
|
177
181
|
const shasToRead =
|
|
178
182
|
branchShas.length > 0
|
|
@@ -189,6 +193,30 @@ async function getBranchAttribution(
|
|
|
189
193
|
notes.push(...batchResults);
|
|
190
194
|
}
|
|
191
195
|
const results = notes.filter((n): n is AttributionResult => n !== null);
|
|
196
|
+
|
|
197
|
+
// Synthesize attribution for branch commits with no note that look like
|
|
198
|
+
// AI actor commits (e.g. @claude via claude-code-action, Copilot coding agent).
|
|
199
|
+
// These bypass the local post-commit hook, so no note is ever written for them.
|
|
200
|
+
const unnotedBranchShas = branchShas.filter((sha) => !notedSet.has(sha));
|
|
201
|
+
if (unnotedBranchShas.length > 0) {
|
|
202
|
+
const synthetic = (
|
|
203
|
+
await Promise.all(
|
|
204
|
+
unnotedBranchShas.map(
|
|
205
|
+
async (sha): Promise<AttributionResult | null> => {
|
|
206
|
+
try {
|
|
207
|
+
const meta = await getCommitMeta(repoRoot, sha);
|
|
208
|
+
if (!isKnownAiActorCommit(meta)) return null;
|
|
209
|
+
return buildAllAiResult(repoRoot, sha);
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
),
|
|
215
|
+
)
|
|
216
|
+
).filter((r): r is AttributionResult => r !== null);
|
|
217
|
+
results.push(...synthetic);
|
|
218
|
+
}
|
|
219
|
+
|
|
192
220
|
if (sessionStart) {
|
|
193
221
|
return results.filter((r) => new Date(r.timestamp) >= sessionStart);
|
|
194
222
|
}
|
|
@@ -333,20 +333,16 @@ export async function configureRequiredCheck(repoRoot: string): Promise<void> {
|
|
|
333
333
|
getRulesetStatuses(slug),
|
|
334
334
|
]);
|
|
335
335
|
|
|
336
|
-
//
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
}
|
|
336
|
+
// Already configured on at least one target — don't prompt again
|
|
337
|
+
if (classic?.hasOurCheck || rulesets.some((rs) => rs.hasOurCheck)) {
|
|
338
|
+
console.log(`✓ '${WORKFLOW_CHECK_NAME}' already a required status check`);
|
|
347
339
|
return;
|
|
348
340
|
}
|
|
349
341
|
|
|
342
|
+
// Determine what needs to be added
|
|
343
|
+
const classicNeeded = classic !== null;
|
|
344
|
+
const rulesetsNeeded = rulesets;
|
|
345
|
+
|
|
350
346
|
// Build the list of targets that need our check
|
|
351
347
|
const targets: Array<
|
|
352
348
|
| { kind: "classic"; classic: ClassicStatus }
|
package/src/setup/install.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
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 { readFile, writeFile, appendFile, mkdir, readdir } from "fs/promises";
|
|
9
9
|
import { existsSync } from "fs";
|
|
10
10
|
import { execFile } from "child_process";
|
|
11
11
|
import { promisify } from "util";
|
|
@@ -140,6 +140,79 @@ async function promptBaselineInit(
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/** Return true if any workflow in the repo references claude-code-action. */
|
|
144
|
+
async function detectClaudeCodeAction(repoRoot: string): Promise<boolean> {
|
|
145
|
+
const workflowsDir = join(repoRoot, ".github", "workflows");
|
|
146
|
+
if (!existsSync(workflowsDir)) return false;
|
|
147
|
+
try {
|
|
148
|
+
const files = await readdir(workflowsDir);
|
|
149
|
+
for (const file of files) {
|
|
150
|
+
if (!file.endsWith(".yml") && !file.endsWith(".yaml")) continue;
|
|
151
|
+
if (file.startsWith("claude-attribution")) continue;
|
|
152
|
+
const content = await readFile(join(workflowsDir, file), "utf8");
|
|
153
|
+
if (content.includes("anthropics/claude-code-action")) return true;
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Install the AI actor attribution workflow when claude-code-action is present.
|
|
163
|
+
* Always overwrites if already installed (idempotent upgrade). Prompts on first install.
|
|
164
|
+
*/
|
|
165
|
+
async function installGhaWorkflow(repoRoot: string): Promise<void> {
|
|
166
|
+
const destPath = join(
|
|
167
|
+
repoRoot,
|
|
168
|
+
".github",
|
|
169
|
+
"workflows",
|
|
170
|
+
"claude-attribution-gha.yml",
|
|
171
|
+
);
|
|
172
|
+
const templatePath = join(
|
|
173
|
+
ATTRIBUTION_ROOT,
|
|
174
|
+
"src",
|
|
175
|
+
"setup",
|
|
176
|
+
"templates",
|
|
177
|
+
"claude-attribution-gha.yml",
|
|
178
|
+
);
|
|
179
|
+
const template = await readFile(templatePath, "utf8");
|
|
180
|
+
|
|
181
|
+
// Always update if already installed
|
|
182
|
+
if (existsSync(destPath)) {
|
|
183
|
+
await writeFile(destPath, template);
|
|
184
|
+
console.log("✓ Updated .github/workflows/claude-attribution-gha.yml");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// First install — only if claude-code-action is detected
|
|
189
|
+
const hasClaudeAction = await detectClaudeCodeAction(repoRoot);
|
|
190
|
+
if (!hasClaudeAction) return;
|
|
191
|
+
|
|
192
|
+
if (!process.stdin.isTTY) {
|
|
193
|
+
console.log(
|
|
194
|
+
" ℹ️ claude-code-action detected — run install interactively to add AI actor attribution workflow.",
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
200
|
+
const yes = await new Promise<boolean>((resolve) => {
|
|
201
|
+
rl.question(
|
|
202
|
+
"\n claude-code-action detected. Install AI actor attribution workflow? [y/N] ",
|
|
203
|
+
(answer) => {
|
|
204
|
+
rl.close();
|
|
205
|
+
resolve(answer.trim().toLowerCase() === "y");
|
|
206
|
+
},
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (yes) {
|
|
211
|
+
await writeFile(destPath, template);
|
|
212
|
+
console.log("✓ Installed .github/workflows/claude-attribution-gha.yml");
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
143
216
|
async function main() {
|
|
144
217
|
const args = process.argv.slice(2);
|
|
145
218
|
const runnerFlagIdx = args.findIndex((a: string) => a === "--runner");
|
|
@@ -290,6 +363,9 @@ async function main() {
|
|
|
290
363
|
`✓ Installed .github/workflows/claude-attribution-pr.yml — runner: ${runsOn}${detectedNote}`,
|
|
291
364
|
);
|
|
292
365
|
|
|
366
|
+
// 4b. Install GHA attribution workflow if claude-code-action is present
|
|
367
|
+
await installGhaWorkflow(targetRepo);
|
|
368
|
+
|
|
293
369
|
// 5. Check branch protection and offer to add required status check
|
|
294
370
|
await configureRequiredCheck(targetRepo);
|
|
295
371
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
name: Claude Attribution — AI Actor Commits
|
|
2
|
+
|
|
3
|
+
# Records attribution for commits made by AI actors that bypass the local
|
|
4
|
+
# post-commit hook (e.g. @claude via claude-code-action, Copilot coding agent).
|
|
5
|
+
#
|
|
6
|
+
# Uses --if-ai-actor so this is a no-op for regular human pushes — it only
|
|
7
|
+
# writes and pushes a git note when the commit author or Co-authored-by trailer
|
|
8
|
+
# identifies the commit as AI-authored.
|
|
9
|
+
|
|
10
|
+
on:
|
|
11
|
+
push:
|
|
12
|
+
branches: ["**"]
|
|
13
|
+
|
|
14
|
+
permissions:
|
|
15
|
+
contents: write
|
|
16
|
+
|
|
17
|
+
jobs:
|
|
18
|
+
note-ai-commit:
|
|
19
|
+
name: Record AI actor commit attribution
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
with:
|
|
24
|
+
fetch-depth: 0
|
|
25
|
+
token: ${{ secrets.GITHUB_TOKEN }}
|
|
26
|
+
|
|
27
|
+
- name: Fetch attribution notes
|
|
28
|
+
run: git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
|
|
29
|
+
|
|
30
|
+
- uses: oven-sh/setup-bun@v2
|
|
31
|
+
|
|
32
|
+
- name: Install claude-attribution
|
|
33
|
+
run: |
|
|
34
|
+
npm install -g --prefix "${HOME}/.npm-global" claude-attribution
|
|
35
|
+
echo "${HOME}/.npm-global/bin" >> "$GITHUB_PATH"
|
|
36
|
+
|
|
37
|
+
- name: Record attribution if AI actor commit
|
|
38
|
+
run: claude-attribution note-ai-commit --if-ai-actor --push
|
|
@@ -22,6 +22,8 @@ jobs:
|
|
|
22
22
|
git fetch origin refs/notes/claude-attribution:refs/notes/claude-attribution || true
|
|
23
23
|
git fetch origin refs/notes/claude-attribution-map:refs/notes/claude-attribution-map || true
|
|
24
24
|
|
|
25
|
+
- uses: oven-sh/setup-bun@v2
|
|
26
|
+
|
|
25
27
|
- name: Install claude-attribution
|
|
26
28
|
run: |
|
|
27
29
|
npm install -g --prefix "${HOME}/.npm-global" claude-attribution
|