claude-attribution 1.9.4 → 1.9.6
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/__tests__/copilot-session.test.ts +128 -0
- package/src/__tests__/differ.test.ts +18 -4
- package/src/__tests__/git-notes.test.ts +13 -8
- package/src/__tests__/integration.test.ts +269 -5
- package/src/__tests__/minimap-bulk.test.ts +25 -1
- package/src/attribution/commit.ts +23 -5
- package/src/attribution/differ.ts +3 -4
- package/src/attribution/git-notes.ts +12 -5
- package/src/attribution/minimap.ts +102 -1
- package/src/commands/note-ai-commit.ts +2 -2
- package/src/export/merge-pr-artifacts.ts +271 -0
- package/src/export/pr-summary.ts +23 -1
- package/src/metrics/collect.ts +57 -18
- package/src/metrics/copilot-session.ts +35 -1
- package/src/metrics/local-session.ts +5 -2
- package/src/metrics/transcript.ts +18 -3
- package/src/setup/templates/claude-attribution-export.yml +7 -2
- package/src/setup/templates/pr-metrics-workflow.yml +8 -6
|
@@ -22,10 +22,14 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const execFileAsync = promisify(execFile);
|
|
24
24
|
export const NOTES_REF = "refs/notes/claude-attribution";
|
|
25
|
+
const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
25
26
|
|
|
26
27
|
/** Run a shell command and return trimmed stdout. Throws on non-zero exit. */
|
|
27
28
|
async function run(cmd: string, args: string[], cwd?: string): Promise<string> {
|
|
28
|
-
const { stdout } = await execFileAsync(cmd, args, {
|
|
29
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
30
|
+
cwd,
|
|
31
|
+
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
32
|
+
});
|
|
29
33
|
return stdout.trim();
|
|
30
34
|
}
|
|
31
35
|
|
|
@@ -35,7 +39,10 @@ async function runRaw(
|
|
|
35
39
|
args: string[],
|
|
36
40
|
cwd?: string,
|
|
37
41
|
): Promise<string> {
|
|
38
|
-
const { stdout } = await execFileAsync(cmd, args, {
|
|
42
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
43
|
+
cwd,
|
|
44
|
+
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
45
|
+
});
|
|
39
46
|
return stdout;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -323,7 +330,7 @@ function inferAiActorRuntime(meta: CommitMeta): AssistantRuntimeInfo | undefined
|
|
|
323
330
|
|
|
324
331
|
/**
|
|
325
332
|
* Build a 100% AI AttributionResult for a commit without running the
|
|
326
|
-
* checkpoint-based differ. All
|
|
333
|
+
* checkpoint-based differ. All committed lines are marked AI.
|
|
327
334
|
*
|
|
328
335
|
* Used by `note-ai-commit` (to write git notes in GHA) and by `collect.ts`
|
|
329
336
|
* (to synthesize attribution at metrics time for unattributed AI actor commits).
|
|
@@ -343,8 +350,8 @@ export async function buildAllAiResult(
|
|
|
343
350
|
const content = await committedContentAt(repoRoot, sha, relPath);
|
|
344
351
|
if (content === null || content.includes("\0")) return null;
|
|
345
352
|
const lines = content.split("\n");
|
|
346
|
-
const ai = lines.
|
|
347
|
-
const human =
|
|
353
|
+
const ai = lines.length;
|
|
354
|
+
const human = 0;
|
|
348
355
|
const total = lines.length;
|
|
349
356
|
return {
|
|
350
357
|
path: relPath,
|
|
@@ -23,9 +23,11 @@ import { writeFile, unlink, mkdtemp, rmdir } from "fs/promises";
|
|
|
23
23
|
import { tmpdir } from "os";
|
|
24
24
|
import { join } from "path";
|
|
25
25
|
import { hashLine } from "./differ.ts";
|
|
26
|
+
import { committedContentAt } from "./git-notes.ts";
|
|
26
27
|
|
|
27
28
|
const execFileAsync = promisify(execFile);
|
|
28
29
|
const BLANK_LINE_HASH = hashLine("");
|
|
30
|
+
const GIT_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024;
|
|
29
31
|
|
|
30
32
|
export const MINIMAP_NOTES_REF = "refs/notes/claude-attribution-map";
|
|
31
33
|
|
|
@@ -47,7 +49,10 @@ export interface MinimapResult {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
async function run(cmd: string, args: string[], cwd?: string): Promise<string> {
|
|
50
|
-
const { stdout } = await execFileAsync(cmd, args, {
|
|
52
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
53
|
+
cwd,
|
|
54
|
+
maxBuffer: GIT_OUTPUT_MAX_BUFFER,
|
|
55
|
+
});
|
|
51
56
|
return stdout.trim();
|
|
52
57
|
}
|
|
53
58
|
|
|
@@ -169,6 +174,102 @@ export async function readMinimap(
|
|
|
169
174
|
}
|
|
170
175
|
}
|
|
171
176
|
|
|
177
|
+
async function commitParents(
|
|
178
|
+
repoRoot: string,
|
|
179
|
+
commitSha: string,
|
|
180
|
+
): Promise<string[]> {
|
|
181
|
+
const output = await run(
|
|
182
|
+
"git",
|
|
183
|
+
["rev-list", "--parents", "-n", "1", commitSha],
|
|
184
|
+
repoRoot,
|
|
185
|
+
).catch(() => "");
|
|
186
|
+
const parts = output.split(" ").filter(Boolean);
|
|
187
|
+
return parts.slice(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function trackedFilesAt(
|
|
191
|
+
repoRoot: string,
|
|
192
|
+
commitSha: string,
|
|
193
|
+
): Promise<string[]> {
|
|
194
|
+
const output = await run(
|
|
195
|
+
"git",
|
|
196
|
+
["ls-tree", "-r", "--name-only", commitSha],
|
|
197
|
+
repoRoot,
|
|
198
|
+
).catch(() => "");
|
|
199
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function resolveMinimap(
|
|
203
|
+
repoRoot: string,
|
|
204
|
+
commitSha = "HEAD",
|
|
205
|
+
cache = new Map<string, Promise<MinimapResult | null>>(),
|
|
206
|
+
): Promise<MinimapResult | null> {
|
|
207
|
+
const cached = cache.get(commitSha);
|
|
208
|
+
if (cached) return cached;
|
|
209
|
+
|
|
210
|
+
const pending = (async (): Promise<MinimapResult | null> => {
|
|
211
|
+
const direct = await readMinimap(repoRoot, commitSha);
|
|
212
|
+
if (direct) return direct;
|
|
213
|
+
|
|
214
|
+
const parents = await commitParents(repoRoot, commitSha);
|
|
215
|
+
if (parents.length === 0) return null;
|
|
216
|
+
|
|
217
|
+
const parentMinimaps = (
|
|
218
|
+
await Promise.all(parents.map((parent) => resolveMinimap(repoRoot, parent, cache)))
|
|
219
|
+
).filter((result): result is MinimapResult => result !== null);
|
|
220
|
+
if (parentMinimaps.length === 0) return null;
|
|
221
|
+
|
|
222
|
+
const inheritedAiByPath = new Map<string, Set<string>>();
|
|
223
|
+
for (const parent of parentMinimaps) {
|
|
224
|
+
for (const file of parent.files) {
|
|
225
|
+
let combined = inheritedAiByPath.get(file.path);
|
|
226
|
+
if (!combined) {
|
|
227
|
+
combined = new Set<string>();
|
|
228
|
+
inheritedAiByPath.set(file.path, combined);
|
|
229
|
+
}
|
|
230
|
+
for (const hash of hashSetFromString(file.ai_hashes)) {
|
|
231
|
+
combined.add(hash);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const allFiles = await trackedFilesAt(repoRoot, commitSha);
|
|
237
|
+
const minimapFiles: MinimapFileState[] = [];
|
|
238
|
+
for (let i = 0; i < allFiles.length; i += CONCURRENCY) {
|
|
239
|
+
const batch = allFiles.slice(i, i + CONCURRENCY);
|
|
240
|
+
const results = await Promise.all(
|
|
241
|
+
batch.map(async (relPath): Promise<MinimapFileState> => {
|
|
242
|
+
const committed = await committedContentAt(repoRoot, commitSha, relPath).catch(
|
|
243
|
+
() => null,
|
|
244
|
+
);
|
|
245
|
+
if (!committed || committed.includes("\0")) {
|
|
246
|
+
return {
|
|
247
|
+
path: relPath,
|
|
248
|
+
ai_hashes: "",
|
|
249
|
+
ai: 0,
|
|
250
|
+
human: 0,
|
|
251
|
+
total: 0,
|
|
252
|
+
pctAi: 0,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return computeMinimapFile(
|
|
256
|
+
relPath,
|
|
257
|
+
committed.split("\n"),
|
|
258
|
+
new Set<string>(),
|
|
259
|
+
inheritedAiByPath.get(relPath) ?? new Set<string>(),
|
|
260
|
+
);
|
|
261
|
+
}),
|
|
262
|
+
);
|
|
263
|
+
minimapFiles.push(...results);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return buildResult(commitSha, minimapFiles);
|
|
267
|
+
})();
|
|
268
|
+
|
|
269
|
+
cache.set(commitSha, pending);
|
|
270
|
+
return pending;
|
|
271
|
+
}
|
|
272
|
+
|
|
172
273
|
export async function listMinimapNotes(repoRoot: string): Promise<string[]> {
|
|
173
274
|
try {
|
|
174
275
|
const output = await run(
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* by a known AI actor (bot author or Co-authored-by trailer).
|
|
13
13
|
* Silent no-op otherwise — safe to run on every push.
|
|
14
14
|
*
|
|
15
|
-
* All
|
|
16
|
-
* lines
|
|
15
|
+
* All committed lines in the changed files are attributed as AI, including
|
|
16
|
+
* blank lines.
|
|
17
17
|
*/
|
|
18
18
|
import { resolve } from "path";
|
|
19
19
|
import {
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import {
|
|
4
|
+
aggregateTotals,
|
|
5
|
+
type AssistantRuntimeInfo,
|
|
6
|
+
type AttributionResult,
|
|
7
|
+
type CommitModelUsage,
|
|
8
|
+
type CommitSessionMetrics,
|
|
9
|
+
} from "../attribution/differ.ts";
|
|
10
|
+
import {
|
|
11
|
+
NOTES_REF,
|
|
12
|
+
committedContentAt,
|
|
13
|
+
renamedFilesInCommit,
|
|
14
|
+
writeNote,
|
|
15
|
+
} from "../attribution/git-notes.ts";
|
|
16
|
+
import {
|
|
17
|
+
MINIMAP_NOTES_REF,
|
|
18
|
+
computeMinimapFile,
|
|
19
|
+
hashSetFromString,
|
|
20
|
+
resolveMinimap,
|
|
21
|
+
type MinimapResult,
|
|
22
|
+
type MinimapFileState,
|
|
23
|
+
writeMinimap,
|
|
24
|
+
} from "../attribution/minimap.ts";
|
|
25
|
+
import { pushNotesRefs } from "../attribution/notes-sync.ts";
|
|
26
|
+
|
|
27
|
+
const execFileAsync = promisify(execFile);
|
|
28
|
+
|
|
29
|
+
interface WriteMergedPrArtifactsOptions {
|
|
30
|
+
repoRoot: string;
|
|
31
|
+
mergeCommitSha: string;
|
|
32
|
+
prHeadSha: string;
|
|
33
|
+
results: AttributionResult[];
|
|
34
|
+
baseRef?: string | null;
|
|
35
|
+
push?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function runGit(repoRoot: string, args: string[]): Promise<string> {
|
|
39
|
+
const { stdout } = await execFileAsync("git", args, { cwd: repoRoot });
|
|
40
|
+
return stdout.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function trackedFilesAt(repoRoot: string, commitSha: string): Promise<string[]> {
|
|
44
|
+
const output = await runGit(repoRoot, ["ls-tree", "-r", "--name-only", commitSha]).catch(
|
|
45
|
+
() => "",
|
|
46
|
+
);
|
|
47
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function changedFilesInCommit(
|
|
51
|
+
repoRoot: string,
|
|
52
|
+
commitSha: string,
|
|
53
|
+
): Promise<Set<string>> {
|
|
54
|
+
const output = await runGit(repoRoot, [
|
|
55
|
+
"diff-tree",
|
|
56
|
+
"--no-commit-id",
|
|
57
|
+
"-r",
|
|
58
|
+
"--name-only",
|
|
59
|
+
commitSha,
|
|
60
|
+
]).catch(() => "");
|
|
61
|
+
return new Set(output.split("\n").filter(Boolean));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function firstParent(repoRoot: string, commitSha: string): Promise<string | null> {
|
|
65
|
+
const output = await runGit(repoRoot, ["rev-list", "--parents", "-n", "1", commitSha]).catch(
|
|
66
|
+
() => "",
|
|
67
|
+
);
|
|
68
|
+
const [, parent] = output.split(" ");
|
|
69
|
+
return parent ?? null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function aggregateModelUsage(results: AttributionResult[]): CommitModelUsage[] {
|
|
73
|
+
const byModel = new Map<string, CommitModelUsage>();
|
|
74
|
+
for (const result of results) {
|
|
75
|
+
for (const model of result.modelUsage ?? []) {
|
|
76
|
+
const key = `${model.modelFull}|${model.modelShort}`;
|
|
77
|
+
const existing = byModel.get(key);
|
|
78
|
+
if (existing) {
|
|
79
|
+
existing.calls += model.calls;
|
|
80
|
+
existing.inputTokens += model.inputTokens;
|
|
81
|
+
existing.outputTokens += model.outputTokens;
|
|
82
|
+
existing.cacheCreationTokens += model.cacheCreationTokens;
|
|
83
|
+
existing.cacheReadTokens += model.cacheReadTokens;
|
|
84
|
+
} else {
|
|
85
|
+
byModel.set(key, { ...model });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return [...byModel.values()].sort((a, b) =>
|
|
90
|
+
a.modelFull.localeCompare(b.modelFull),
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function aggregateSessionMetrics(
|
|
95
|
+
results: AttributionResult[],
|
|
96
|
+
): CommitSessionMetrics | undefined {
|
|
97
|
+
const toolCounts: Record<string, number> = {};
|
|
98
|
+
const agentCounts: Record<string, number> = {};
|
|
99
|
+
const skillNames = new Set<string>();
|
|
100
|
+
let humanPromptCount = 0;
|
|
101
|
+
let activeMinutes = 0;
|
|
102
|
+
let aiMinutes = 0;
|
|
103
|
+
let humanMinutes = 0;
|
|
104
|
+
let hasAny = false;
|
|
105
|
+
|
|
106
|
+
for (const result of results) {
|
|
107
|
+
const metrics = result.sessionMetrics;
|
|
108
|
+
if (!metrics) continue;
|
|
109
|
+
hasAny = true;
|
|
110
|
+
for (const [tool, count] of Object.entries(metrics.toolCounts ?? {})) {
|
|
111
|
+
toolCounts[tool] = (toolCounts[tool] ?? 0) + count;
|
|
112
|
+
}
|
|
113
|
+
for (const [agent, count] of Object.entries(metrics.agentCounts ?? {})) {
|
|
114
|
+
agentCounts[agent] = (agentCounts[agent] ?? 0) + count;
|
|
115
|
+
}
|
|
116
|
+
for (const skill of metrics.skillNames ?? []) {
|
|
117
|
+
skillNames.add(skill);
|
|
118
|
+
}
|
|
119
|
+
humanPromptCount += metrics.humanPromptCount ?? 0;
|
|
120
|
+
activeMinutes += metrics.activeMinutes ?? 0;
|
|
121
|
+
aiMinutes += metrics.aiMinutes ?? 0;
|
|
122
|
+
humanMinutes += metrics.humanMinutes ?? 0;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!hasAny) return undefined;
|
|
126
|
+
return {
|
|
127
|
+
toolCounts: Object.keys(toolCounts).length > 0 ? toolCounts : undefined,
|
|
128
|
+
agentCounts: Object.keys(agentCounts).length > 0 ? agentCounts : undefined,
|
|
129
|
+
skillNames: skillNames.size > 0 ? [...skillNames].sort() : undefined,
|
|
130
|
+
humanPromptCount,
|
|
131
|
+
activeMinutes,
|
|
132
|
+
aiMinutes,
|
|
133
|
+
humanMinutes,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function latestAssistantRuntime(
|
|
138
|
+
results: AttributionResult[],
|
|
139
|
+
): AssistantRuntimeInfo | undefined {
|
|
140
|
+
for (let i = results.length - 1; i >= 0; i--) {
|
|
141
|
+
const runtime = results[i]?.assistantRuntime;
|
|
142
|
+
if (runtime) return runtime;
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function buildMinimapResult(
|
|
148
|
+
commitSha: string,
|
|
149
|
+
files: MinimapFileState[],
|
|
150
|
+
): MinimapResult {
|
|
151
|
+
const totals = files.reduce(
|
|
152
|
+
(acc, file) => ({
|
|
153
|
+
ai: acc.ai + file.ai,
|
|
154
|
+
human: acc.human + file.human,
|
|
155
|
+
total: acc.total + file.total,
|
|
156
|
+
}),
|
|
157
|
+
{ ai: 0, human: 0, total: 0 },
|
|
158
|
+
);
|
|
159
|
+
return {
|
|
160
|
+
commit: commitSha,
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
files,
|
|
163
|
+
totals: {
|
|
164
|
+
...totals,
|
|
165
|
+
pctAi: totals.total > 0 ? Math.round((totals.ai / totals.total) * 100) : 0,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function writeMergedPrArtifacts({
|
|
171
|
+
repoRoot,
|
|
172
|
+
mergeCommitSha,
|
|
173
|
+
prHeadSha,
|
|
174
|
+
results,
|
|
175
|
+
baseRef,
|
|
176
|
+
push = false,
|
|
177
|
+
}: WriteMergedPrArtifactsOptions): Promise<{
|
|
178
|
+
note: AttributionResult;
|
|
179
|
+
minimap: MinimapResult | null;
|
|
180
|
+
}> {
|
|
181
|
+
const sorted = [...results].sort(
|
|
182
|
+
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
|
183
|
+
);
|
|
184
|
+
const lastSeenByFile = new Map<string, AttributionResult["files"][number]>();
|
|
185
|
+
for (const result of sorted) {
|
|
186
|
+
for (const file of result.files) {
|
|
187
|
+
lastSeenByFile.set(file.path, file);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const files = [...lastSeenByFile.values()].sort((a, b) =>
|
|
192
|
+
a.path.localeCompare(b.path),
|
|
193
|
+
);
|
|
194
|
+
const note: AttributionResult = {
|
|
195
|
+
commit: mergeCommitSha,
|
|
196
|
+
session: sorted.at(-1)?.session ?? null,
|
|
197
|
+
branch: baseRef ?? sorted.at(-1)?.branch ?? null,
|
|
198
|
+
timestamp: new Date().toISOString(),
|
|
199
|
+
files,
|
|
200
|
+
totals: aggregateTotals(files),
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const modelUsage = aggregateModelUsage(sorted);
|
|
204
|
+
if (modelUsage.length > 0) note.modelUsage = modelUsage;
|
|
205
|
+
const sessionMetrics = aggregateSessionMetrics(sorted);
|
|
206
|
+
if (sessionMetrics) note.sessionMetrics = sessionMetrics;
|
|
207
|
+
const assistantRuntime = latestAssistantRuntime(sorted);
|
|
208
|
+
if (assistantRuntime) note.assistantRuntime = assistantRuntime;
|
|
209
|
+
|
|
210
|
+
await writeNote(note, repoRoot, mergeCommitSha);
|
|
211
|
+
|
|
212
|
+
const parentSha = await firstParent(repoRoot, mergeCommitSha);
|
|
213
|
+
const [
|
|
214
|
+
baseMinimap,
|
|
215
|
+
prHeadMinimap,
|
|
216
|
+
renamedBasePathByCurrentPath,
|
|
217
|
+
trackedFiles,
|
|
218
|
+
changedFiles,
|
|
219
|
+
] = await Promise.all([
|
|
220
|
+
parentSha ? resolveMinimap(repoRoot, parentSha) : Promise.resolve(null),
|
|
221
|
+
resolveMinimap(repoRoot, prHeadSha).catch(() => null),
|
|
222
|
+
renamedFilesInCommit(repoRoot, mergeCommitSha),
|
|
223
|
+
trackedFilesAt(repoRoot, mergeCommitSha),
|
|
224
|
+
changedFilesInCommit(repoRoot, mergeCommitSha),
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
let minimap: MinimapResult | null = null;
|
|
228
|
+
if (parentSha && baseMinimap && prHeadMinimap) {
|
|
229
|
+
const baseAiByPath = new Map(
|
|
230
|
+
baseMinimap.files.map((file) => [file.path, hashSetFromString(file.ai_hashes)]),
|
|
231
|
+
);
|
|
232
|
+
const prHeadAiByPath = new Map(
|
|
233
|
+
prHeadMinimap.files.map((file) => [file.path, hashSetFromString(file.ai_hashes)]),
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const minimapFiles = await Promise.all(
|
|
237
|
+
trackedFiles.map(async (path): Promise<MinimapFileState> => {
|
|
238
|
+
const committed = await committedContentAt(repoRoot, mergeCommitSha, path).catch(
|
|
239
|
+
() => null,
|
|
240
|
+
);
|
|
241
|
+
if (!committed || committed.includes("\0")) {
|
|
242
|
+
return {
|
|
243
|
+
path,
|
|
244
|
+
ai_hashes: "",
|
|
245
|
+
ai: 0,
|
|
246
|
+
human: 0,
|
|
247
|
+
total: 0,
|
|
248
|
+
pctAi: 0,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
const basePath = renamedBasePathByCurrentPath.get(path) ?? path;
|
|
252
|
+
return computeMinimapFile(
|
|
253
|
+
path,
|
|
254
|
+
committed.split("\n"),
|
|
255
|
+
changedFiles.has(path)
|
|
256
|
+
? (prHeadAiByPath.get(path) ?? new Set<string>())
|
|
257
|
+
: new Set<string>(),
|
|
258
|
+
baseAiByPath.get(basePath) ?? new Set<string>(),
|
|
259
|
+
);
|
|
260
|
+
}),
|
|
261
|
+
);
|
|
262
|
+
minimap = buildMinimapResult(mergeCommitSha, minimapFiles);
|
|
263
|
+
await writeMinimap(minimap, repoRoot, mergeCommitSha);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (push) {
|
|
267
|
+
await pushNotesRefs(repoRoot, [NOTES_REF, MINIMAP_NOTES_REF]);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { note, minimap };
|
|
271
|
+
}
|
package/src/export/pr-summary.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
} from "../attribution/differ.ts";
|
|
54
54
|
import { readMinimap } from "../attribution/minimap.ts";
|
|
55
55
|
import { calculateCostUsd } from "../pricing.ts";
|
|
56
|
+
import { writeMergedPrArtifacts } from "./merge-pr-artifacts.ts";
|
|
56
57
|
|
|
57
58
|
// ---------------------------------------------------------------------------
|
|
58
59
|
// OTLP/HTTP JSON types (subset needed for gauge metrics)
|
|
@@ -180,6 +181,8 @@ async function main() {
|
|
|
180
181
|
const repo = process.env.GITHUB_REPOSITORY ?? "";
|
|
181
182
|
const author = process.env.PR_AUTHOR ?? "";
|
|
182
183
|
const baseRef = process.env.GITHUB_BASE_REF ?? "main";
|
|
184
|
+
const prHeadSha = process.env.PR_HEAD_SHA ?? "";
|
|
185
|
+
const mergeCommitSha = process.env.MERGE_COMMIT_SHA ?? "";
|
|
183
186
|
const webhookUrl = process.env.METRICS_WEBHOOK_URL ?? "";
|
|
184
187
|
|
|
185
188
|
// Resolve OTLP endpoint + headers — explicit vars take precedence over Datadog shortcut
|
|
@@ -271,7 +274,26 @@ async function main() {
|
|
|
271
274
|
// Use branch from the most recent note; fall back to env var
|
|
272
275
|
const branch = results[results.length - 1]?.branch ?? baseRef;
|
|
273
276
|
|
|
274
|
-
const
|
|
277
|
+
const mergedArtifacts =
|
|
278
|
+
mergeCommitSha && prHeadSha
|
|
279
|
+
? await writeMergedPrArtifacts({
|
|
280
|
+
repoRoot,
|
|
281
|
+
mergeCommitSha,
|
|
282
|
+
prHeadSha,
|
|
283
|
+
results,
|
|
284
|
+
baseRef,
|
|
285
|
+
push: true,
|
|
286
|
+
}).catch((error) => {
|
|
287
|
+
console.warn(
|
|
288
|
+
`[pr-summary] Warning: failed to write merge commit notes for ${mergeCommitSha}:`,
|
|
289
|
+
error,
|
|
290
|
+
);
|
|
291
|
+
return null;
|
|
292
|
+
})
|
|
293
|
+
: null;
|
|
294
|
+
|
|
295
|
+
const headMinimap =
|
|
296
|
+
mergedArtifacts?.minimap ?? (await readMinimap(repoRoot, "HEAD").catch(() => null));
|
|
275
297
|
|
|
276
298
|
// OTLP data point attributes (shared across all metrics)
|
|
277
299
|
const timeUnixNano = String(Date.now() * 1_000_000);
|
package/src/metrics/collect.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { SESSION_ID_RE } from "../attribution/checkpoint.ts";
|
|
|
37
37
|
import {
|
|
38
38
|
hashSetFromString,
|
|
39
39
|
readMinimap,
|
|
40
|
+
resolveMinimap,
|
|
40
41
|
listMinimapNotes,
|
|
41
42
|
} from "../attribution/minimap.ts";
|
|
42
43
|
import { detectAssistantRuntime } from "../attribution/runtime.ts";
|
|
@@ -72,6 +73,12 @@ export interface MetricsData {
|
|
|
72
73
|
ai: number;
|
|
73
74
|
total: number;
|
|
74
75
|
pctAi: number;
|
|
76
|
+
files: Array<{
|
|
77
|
+
path: string;
|
|
78
|
+
ai: number;
|
|
79
|
+
total: number;
|
|
80
|
+
pctAi: number;
|
|
81
|
+
}>;
|
|
75
82
|
} | null;
|
|
76
83
|
}
|
|
77
84
|
|
|
@@ -154,14 +161,19 @@ function parseChangedLineNumbers(diff: string): {
|
|
|
154
161
|
|
|
155
162
|
async function getBranchDiffStats(
|
|
156
163
|
repoRoot: string,
|
|
157
|
-
): Promise<{
|
|
164
|
+
): Promise<{
|
|
165
|
+
ai: number;
|
|
166
|
+
total: number;
|
|
167
|
+
pctAi: number;
|
|
168
|
+
files: Array<{ path: string; ai: number; total: number; pctAi: number }>;
|
|
169
|
+
} | null> {
|
|
158
170
|
const baseSha = await getBranchBaseSha(repoRoot);
|
|
159
171
|
if (!baseSha) return null;
|
|
160
172
|
|
|
161
173
|
const [headMinimap, baseMinimap, changedFilesOutput, renameStatusOutput] =
|
|
162
174
|
await Promise.all([
|
|
163
|
-
|
|
164
|
-
|
|
175
|
+
resolveMinimap(repoRoot, "HEAD"),
|
|
176
|
+
resolveMinimap(repoRoot, baseSha).catch(() => null),
|
|
165
177
|
execFileAsync(
|
|
166
178
|
"git",
|
|
167
179
|
["diff", "--name-only", "--find-renames", `${baseSha}..HEAD`],
|
|
@@ -197,7 +209,7 @@ async function getBranchDiffStats(
|
|
|
197
209
|
|
|
198
210
|
const changedFiles = changedFilesOutput.split("\n").filter(Boolean);
|
|
199
211
|
if (changedFiles.length === 0) {
|
|
200
|
-
return { ai: 0, total: 0, pctAi: 0 };
|
|
212
|
+
return { ai: 0, total: 0, pctAi: 0, files: [] };
|
|
201
213
|
}
|
|
202
214
|
|
|
203
215
|
const fileStats = await Promise.all(
|
|
@@ -238,18 +250,20 @@ async function getBranchDiffStats(
|
|
|
238
250
|
]);
|
|
239
251
|
|
|
240
252
|
const numstatLine = numstatResult.split("\n").find(Boolean);
|
|
241
|
-
if (!numstatLine) return { ai: 0, total: 0 };
|
|
253
|
+
if (!numstatLine) return { path, ai: 0, total: 0, pctAi: 0 };
|
|
242
254
|
|
|
243
255
|
const [additionsRaw, deletionsRaw] = numstatLine.split("\t");
|
|
244
|
-
if (!additionsRaw || !deletionsRaw)
|
|
256
|
+
if (!additionsRaw || !deletionsRaw) {
|
|
257
|
+
return { path, ai: 0, total: 0, pctAi: 0 };
|
|
258
|
+
}
|
|
245
259
|
if (additionsRaw === "-" || deletionsRaw === "-") {
|
|
246
|
-
return { ai: 0, total: 0 };
|
|
260
|
+
return { path, ai: 0, total: 0, pctAi: 0 };
|
|
247
261
|
}
|
|
248
262
|
|
|
249
263
|
const additions = parseInt(additionsRaw, 10);
|
|
250
264
|
const deletions = parseInt(deletionsRaw, 10);
|
|
251
265
|
const total = additions + deletions;
|
|
252
|
-
if (total === 0) return { ai: 0, total: 0 };
|
|
266
|
+
if (total === 0) return { path, ai: 0, total: 0, pctAi: 0 };
|
|
253
267
|
|
|
254
268
|
const { added, removed } = parseChangedLineNumbers(diffResult);
|
|
255
269
|
const headLines = headContent?.split("\n") ?? [];
|
|
@@ -271,7 +285,12 @@ async function getBranchDiffStats(
|
|
|
271
285
|
}
|
|
272
286
|
}
|
|
273
287
|
|
|
274
|
-
return {
|
|
288
|
+
return {
|
|
289
|
+
path,
|
|
290
|
+
ai,
|
|
291
|
+
total,
|
|
292
|
+
pctAi: total > 0 ? Math.round((ai / total) * 100) : 0,
|
|
293
|
+
};
|
|
275
294
|
}),
|
|
276
295
|
);
|
|
277
296
|
|
|
@@ -286,6 +305,7 @@ async function getBranchDiffStats(
|
|
|
286
305
|
ai: totals.ai,
|
|
287
306
|
total: totals.total,
|
|
288
307
|
pctAi: totals.total > 0 ? Math.round((totals.ai / totals.total) * 100) : 0,
|
|
308
|
+
files: fileStats.filter((file) => file.total > 0),
|
|
289
309
|
};
|
|
290
310
|
}
|
|
291
311
|
|
|
@@ -472,6 +492,11 @@ function transcriptFromAttribution(
|
|
|
472
492
|
agentCounts: sessionMetrics?.agentCounts ?? {},
|
|
473
493
|
provider:
|
|
474
494
|
result.assistantRuntime?.vendor === "copilot" ? "copilot" : "claude",
|
|
495
|
+
signalSource: "git-note",
|
|
496
|
+
signalSourceLabel:
|
|
497
|
+
result.assistantRuntime?.vendor === "copilot"
|
|
498
|
+
? "Git note snapshot (Copilot-derived)"
|
|
499
|
+
: "Git note snapshot (Claude-derived)",
|
|
475
500
|
costMode:
|
|
476
501
|
result.assistantRuntime?.vendor === "copilot" ? "unavailable" : "estimated",
|
|
477
502
|
costDescription:
|
|
@@ -488,8 +513,8 @@ export function kFormat(n: number): string {
|
|
|
488
513
|
async function getMinimapTotals(
|
|
489
514
|
repoRoot: string,
|
|
490
515
|
): Promise<{ ai: number; human: number; total: number; pctAi: number } | null> {
|
|
491
|
-
// Try HEAD first (written by post-commit hook)
|
|
492
|
-
const head = await
|
|
516
|
+
// Try HEAD first (written by post-commit hook), then synthesize from ancestors.
|
|
517
|
+
const head = await resolveMinimap(repoRoot, "HEAD");
|
|
493
518
|
if (head) return head.totals;
|
|
494
519
|
|
|
495
520
|
// Fall back: find the most recent minimap note on the current branch
|
|
@@ -567,7 +592,7 @@ export async function collectMetrics(
|
|
|
567
592
|
id,
|
|
568
593
|
sessionStart ?? undefined,
|
|
569
594
|
).catch(() => []),
|
|
570
|
-
parseLocalSession(id, root).catch(() => null),
|
|
595
|
+
parseLocalSession(id, root, sessionStart).catch(() => null),
|
|
571
596
|
]);
|
|
572
597
|
return {
|
|
573
598
|
sessionId: id,
|
|
@@ -785,6 +810,10 @@ export function renderMetrics(data: MetricsData): string {
|
|
|
785
810
|
out(`**Session:** ${sessionLine}`);
|
|
786
811
|
out();
|
|
787
812
|
}
|
|
813
|
+
if (transcript.signalSourceLabel) {
|
|
814
|
+
out(`**Session/model source:** ${transcript.signalSourceLabel}`);
|
|
815
|
+
out();
|
|
816
|
+
}
|
|
788
817
|
}
|
|
789
818
|
|
|
790
819
|
if (runtimeSummaries.length > 0) {
|
|
@@ -871,13 +900,14 @@ export function renderMetrics(data: MetricsData): string {
|
|
|
871
900
|
}
|
|
872
901
|
|
|
873
902
|
// <details> block — skills, agents, notable tools, per-file breakdown
|
|
903
|
+
const prFiles = (prDiffStats?.files ?? []).filter((file) => file.total > 0);
|
|
874
904
|
const claudeFiles = [...lastSeenByFile.entries()].filter(
|
|
875
905
|
([, stats]) => stats.ai > 0 || stats.mixed > 0,
|
|
876
906
|
);
|
|
877
907
|
const hasSkills = skillNames.length > 0;
|
|
878
908
|
const hasAgents = agentCounts.size > 0;
|
|
879
909
|
const hasNotableTools = toolCounts.size > 0;
|
|
880
|
-
const hasFiles = claudeFiles.length > 0;
|
|
910
|
+
const hasFiles = prFiles.length > 0 || claudeFiles.length > 0;
|
|
881
911
|
|
|
882
912
|
const summaryParts: string[] = [];
|
|
883
913
|
if (hasSkills) summaryParts.push("Skills");
|
|
@@ -923,11 +953,20 @@ export function renderMetrics(data: MetricsData): string {
|
|
|
923
953
|
if (hasFiles) {
|
|
924
954
|
out("#### Files");
|
|
925
955
|
out();
|
|
926
|
-
|
|
927
|
-
const
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
956
|
+
if (prFiles.length > 0) {
|
|
957
|
+
for (const stats of [...prFiles].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
958
|
+
const relPath = relative(repoRoot, join(repoRoot, stats.path));
|
|
959
|
+
out(
|
|
960
|
+
`- \`${relPath}\` — ${stats.pctAi}% AI (${stats.ai} / ${stats.total} changed lines)`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
} else {
|
|
964
|
+
for (const [path, stats] of claudeFiles.sort()) {
|
|
965
|
+
const filePct =
|
|
966
|
+
stats.total > 0 ? Math.round((stats.ai / stats.total) * 100) : 0;
|
|
967
|
+
const relPath = relative(repoRoot, join(repoRoot, path));
|
|
968
|
+
out(`- \`${relPath}\` — ${filePct}% AI (${stats.ai} lines)`);
|
|
969
|
+
}
|
|
931
970
|
}
|
|
932
971
|
out();
|
|
933
972
|
} else if (!hasAttribution) {
|