@prism-d1/cli 1.0.79 → 1.0.80
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/dist/assets/github-workflows/prism-ai-metrics.yml +83 -122
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +66 -0
- package/dist/assets/github-workflows/prism-eval-gate.yml +1 -1
- package/dist/assets/gitlab-workflows/.gitlab-ci.yml +0 -3
- package/dist/assets/gitlab-workflows/prism-ai-metrics.yml +53 -56
- package/dist/assets/infra/bin/app.ts +1 -0
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +4 -0
- package/dist/assets/infra/lib/dashboard-stack.ts +378 -1235
- package/dist/assets/infra/lib/lambda/event-schema.ts +144 -0
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +124 -36
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +249 -35
- package/dist/assets/infra/lib/lambda/security-remediation-tracker.ts +17 -2
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +1483 -0
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +110 -119
- package/dist/assets/infra/package.json +2 -1
- package/dist/assets/infra/tsconfig.json +9 -2
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts +8 -2
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +32 -32
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-github-workflows.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-github-workflows.js +7 -2
- package/dist/src/commands/bootstrapper/install-github-workflows.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.js +7 -1
- package/dist/src/commands/bootstrapper/install-gitlab-workflows.js.map +1 -1
- package/dist/src/utils/region.d.ts +50 -0
- package/dist/src/utils/region.d.ts.map +1 -0
- package/dist/src/utils/region.js +62 -0
- package/dist/src/utils/region.js.map +1 -0
- package/package.json +2 -2
- package/dist/assets/eval-harness/README.md +0 -144
- package/dist/assets/github-workflows/README.md +0 -110
- package/dist/assets/github-workflows/prism-dora-weekly.yml +0 -334
- package/dist/assets/gitlab-workflows/prism-dora-weekly.yml +0 -181
- package/dist/assets/infra/lib/lambda/defect-correlator.ts +0 -142
- package/dist/assets/infra/lib/lambda/spec-to-code-calculator.ts +0 -123
- package/dist/assets/metric-hooks/README.md +0 -107
|
@@ -444,14 +444,34 @@ async function writeSessionAttribution(user: string, s: ParsedAttributionSpan):
|
|
|
444
444
|
* This prevents redundant DDB stream MODIFY events that would double-count
|
|
445
445
|
* CloudWatch metrics in the attribution-metrics-publisher.
|
|
446
446
|
*
|
|
447
|
-
* AI-Origin
|
|
448
|
-
*
|
|
449
|
-
*
|
|
447
|
+
* AI-Origin is resolved HERE, at write time, and persisted on the item.
|
|
448
|
+
*
|
|
449
|
+
* It used to be inferred at query time by joining against usage spans. That was
|
|
450
|
+
* a latent data-corruption bug: commit facts live COMMIT_TTL_DAYS (365) but
|
|
451
|
+
* usage spans live SPAN_TTL_DAYS (90), so on day 91 the join found nothing and
|
|
452
|
+
* every aging AI commit silently reclassified as `human` — making AI adoption
|
|
453
|
+
* appear to decline over time, depressing the observed PRISM level's L2 gate,
|
|
454
|
+
* and skewing the CISO per-100-commits comparison on both sides.
|
|
455
|
+
*
|
|
456
|
+
* "Derive at read time" is only sound when the inputs outlive the output. They
|
|
457
|
+
* don't here, so the derivation is frozen at ingest while the spans are fresh.
|
|
458
|
+
* Usage spans for this batch are written before attribution spans (see
|
|
459
|
+
* handleTraces), so the trace map passed in already includes them.
|
|
450
460
|
*/
|
|
451
|
-
async function writeCommitAttribution(
|
|
461
|
+
async function writeCommitAttribution(
|
|
462
|
+
user: string,
|
|
463
|
+
s: ParsedAttributionSpan,
|
|
464
|
+
traceMap: Map<string, { tool: string; model: string }>,
|
|
465
|
+
): Promise<boolean> {
|
|
452
466
|
if (!s.repo || !s.sha) return false;
|
|
453
467
|
const ttl = Math.floor(Date.now() / 1000) + COMMIT_TTL_DAYS * 24 * 60 * 60;
|
|
454
468
|
|
|
469
|
+
// Resolve origin now, against spans that are guaranteed to still exist.
|
|
470
|
+
const usage = s.traceId ? traceMap.get(s.traceId) : undefined;
|
|
471
|
+
const aiOrigin = usage ? 'ai-generated' : 'human';
|
|
472
|
+
const aiTool = usage ? usage.tool : 'none';
|
|
473
|
+
const aiModel = usage ? usage.model : '';
|
|
474
|
+
|
|
455
475
|
try {
|
|
456
476
|
await dynamoClient.send(new UpdateItemCommand({
|
|
457
477
|
TableName: AI_USAGE_TABLE,
|
|
@@ -464,13 +484,19 @@ async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): P
|
|
|
464
484
|
'#proj = :proj, #usr = :usr, device_id = :did, ' +
|
|
465
485
|
'in_main = :im, was_reverted = :wr, ' +
|
|
466
486
|
'#ts = :ts, updated_at = :now, #ttl = :ttl, ' +
|
|
487
|
+
// Frozen origin verdict — see the note above.
|
|
488
|
+
'ai_origin = :ao, ai_tool = :at, ai_model = :am, origin_source = :os, ' +
|
|
467
489
|
// Sparse by-user GSI keys: per-developer commit queries (GET
|
|
468
490
|
// /v1/productivity) without scanning the table.
|
|
469
491
|
'gsi_user = :gu, gsi_user_sk = :gusk',
|
|
470
|
-
//
|
|
471
|
-
//
|
|
492
|
+
// Write if: item is new, OR inMain/wasReverted changed, OR we can now
|
|
493
|
+
// resolve an origin we previously could not. The last clause lets a
|
|
494
|
+
// commit whose usage spans arrived in a LATER push get upgraded from
|
|
495
|
+
// human to ai-generated, instead of being frozen wrong forever.
|
|
472
496
|
ConditionExpression:
|
|
473
|
-
'attribute_not_exists(pk) OR in_main <> :im OR was_reverted <> :wr'
|
|
497
|
+
'attribute_not_exists(pk) OR in_main <> :im OR was_reverted <> :wr ' +
|
|
498
|
+
'OR attribute_not_exists(ai_origin) ' +
|
|
499
|
+
'OR (ai_origin = :human AND :ao = :ai)',
|
|
474
500
|
ExpressionAttributeNames: {
|
|
475
501
|
// 'project', 'ttl', 'user', and 'timestamp' are all DynamoDB
|
|
476
502
|
// reserved keywords — bare use in an expression throws
|
|
@@ -492,6 +518,12 @@ async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): P
|
|
|
492
518
|
':ts': { S: s.timestamp },
|
|
493
519
|
':now': { S: new Date().toISOString() },
|
|
494
520
|
':ttl': { N: String(ttl) },
|
|
521
|
+
':ao': { S: aiOrigin },
|
|
522
|
+
':at': { S: aiTool },
|
|
523
|
+
':am': { S: aiModel },
|
|
524
|
+
':os': { S: 'write-time-join' },
|
|
525
|
+
':human': { S: 'human' },
|
|
526
|
+
':ai': { S: 'ai-generated' },
|
|
495
527
|
':gu': { S: `USER#${user}` },
|
|
496
528
|
':gusk': { S: `COMMIT#${s.timestamp}` },
|
|
497
529
|
},
|
|
@@ -507,11 +539,22 @@ async function writeCommitAttribution(user: string, s: ParsedAttributionSpan): P
|
|
|
507
539
|
async function processAttributionSpans(user: string, spans: ParsedAttributionSpan[]): Promise<number> {
|
|
508
540
|
const CONCURRENCY = 20;
|
|
509
541
|
let written = 0;
|
|
542
|
+
|
|
543
|
+
// Build the user's traceId → usage map ONCE for the whole batch. Commit
|
|
544
|
+
// origin is resolved at write time against this map (see
|
|
545
|
+
// writeCommitAttribution), so it must be built AFTER processSpans has
|
|
546
|
+
// persisted this batch's usage spans. Building it per commit would rescan
|
|
547
|
+
// every span for every commit in the push.
|
|
548
|
+
const needsTraceMap = spans.some(s => s.kind !== 'session');
|
|
549
|
+
const traceMap = needsTraceMap
|
|
550
|
+
? await buildUserTraceMap(user)
|
|
551
|
+
: new Map<string, { tool: string; model: string }>();
|
|
552
|
+
|
|
510
553
|
for (let i = 0; i < spans.length; i += CONCURRENCY) {
|
|
511
554
|
const chunk = spans.slice(i, i + CONCURRENCY);
|
|
512
555
|
const results = await Promise.all(chunk.map(async (s) => {
|
|
513
556
|
if (s.kind === 'session') return writeSessionAttribution(user, s);
|
|
514
|
-
return writeCommitAttribution(user, s);
|
|
557
|
+
return writeCommitAttribution(user, s, traceMap);
|
|
515
558
|
}));
|
|
516
559
|
written += results.filter(Boolean).length;
|
|
517
560
|
}
|
|
@@ -600,6 +643,12 @@ export interface CommitAttribution {
|
|
|
600
643
|
aiTool: string;
|
|
601
644
|
/** Model from correlated usage spans, or empty if human. */
|
|
602
645
|
aiModel: string;
|
|
646
|
+
/**
|
|
647
|
+
* 'stored' = verdict frozen at ingest (authoritative).
|
|
648
|
+
* 'joined' = re-derived from usage spans for a legacy item; unreliable once
|
|
649
|
+
* the spans have aged past SPAN_TTL_DAYS.
|
|
650
|
+
*/
|
|
651
|
+
originSource?: 'stored' | 'joined';
|
|
603
652
|
}
|
|
604
653
|
|
|
605
654
|
/**
|
|
@@ -629,19 +678,12 @@ export async function queryCommitAttribution(repo: string, sha: string): Promise
|
|
|
629
678
|
|
|
630
679
|
if (!traceId || !user) return null;
|
|
631
680
|
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
let aiModel = '';
|
|
639
|
-
|
|
640
|
-
if (usage) {
|
|
641
|
-
aiOrigin = 'ai-generated';
|
|
642
|
-
aiTool = usage.tool;
|
|
643
|
-
aiModel = usage.model;
|
|
644
|
-
}
|
|
681
|
+
// Prefer the origin frozen at ingest. Only fall back to a live span join for
|
|
682
|
+
// legacy items written before write-time resolution existed.
|
|
683
|
+
const stored = item.ai_origin?.S;
|
|
684
|
+
const { aiOrigin, aiTool, aiModel, originSource } = (stored === 'ai-generated' || stored === 'human')
|
|
685
|
+
? resolveStoredOrigin(item, null)
|
|
686
|
+
: resolveStoredOrigin(item, await buildUserTraceMap(user));
|
|
645
687
|
|
|
646
688
|
return {
|
|
647
689
|
sha,
|
|
@@ -656,6 +698,7 @@ export async function queryCommitAttribution(repo: string, sha: string): Promise
|
|
|
656
698
|
aiOrigin,
|
|
657
699
|
aiTool,
|
|
658
700
|
aiModel,
|
|
701
|
+
originSource,
|
|
659
702
|
};
|
|
660
703
|
}
|
|
661
704
|
|
|
@@ -689,19 +732,20 @@ export async function queryRepoCommits(repo: string, limit = 50): Promise<Commit
|
|
|
689
732
|
let aiOrigin: 'ai-generated' | 'human' = 'human';
|
|
690
733
|
let aiTool = 'none';
|
|
691
734
|
let aiModel = '';
|
|
692
|
-
|
|
693
|
-
|
|
735
|
+
let originSource: 'stored' | 'joined' = 'stored';
|
|
736
|
+
|
|
737
|
+
const storedOrigin = item.ai_origin?.S;
|
|
738
|
+
if (storedOrigin === 'ai-generated' || storedOrigin === 'human') {
|
|
739
|
+
// Frozen at ingest — authoritative regardless of span age.
|
|
740
|
+
({ aiOrigin, aiTool, aiModel, originSource } = resolveStoredOrigin(item, null));
|
|
741
|
+
} else if (traceId && user) {
|
|
742
|
+
// Legacy item: re-derive. Reliable only inside SPAN_TTL_DAYS.
|
|
694
743
|
let traceMap = traceMaps.get(user);
|
|
695
744
|
if (!traceMap) {
|
|
696
745
|
traceMap = await buildUserTraceMap(user);
|
|
697
746
|
traceMaps.set(user, traceMap);
|
|
698
747
|
}
|
|
699
|
-
|
|
700
|
-
if (usage) {
|
|
701
|
-
aiOrigin = 'ai-generated';
|
|
702
|
-
aiTool = usage.tool;
|
|
703
|
-
aiModel = usage.model;
|
|
704
|
-
}
|
|
748
|
+
({ aiOrigin, aiTool, aiModel, originSource } = resolveStoredOrigin(item, traceMap));
|
|
705
749
|
}
|
|
706
750
|
|
|
707
751
|
commits.push({
|
|
@@ -792,6 +836,36 @@ async function lookupTraceUsage(user: string, traceId: string): Promise<{ tool:
|
|
|
792
836
|
return map.get(traceId) ?? null;
|
|
793
837
|
}
|
|
794
838
|
|
|
839
|
+
/**
|
|
840
|
+
* Resolve a commit item's AI origin, preferring the verdict frozen at ingest.
|
|
841
|
+
*
|
|
842
|
+
* New items carry ai_origin/ai_tool/ai_model written by writeCommitAttribution
|
|
843
|
+
* while the usage spans were still alive. Items written before that change have
|
|
844
|
+
* no stored verdict, so they fall back to the live traceId join — which stays
|
|
845
|
+
* correct for commits under SPAN_TTL_DAYS old and degrades to `human` beyond
|
|
846
|
+
* that. `originSource` makes the distinction visible to callers rather than
|
|
847
|
+
* silently mixing frozen and re-derived values.
|
|
848
|
+
*/
|
|
849
|
+
function resolveStoredOrigin(
|
|
850
|
+
item: Record<string, import('@aws-sdk/client-dynamodb').AttributeValue>,
|
|
851
|
+
traceMap: Map<string, { tool: string; model: string }> | null,
|
|
852
|
+
): { aiOrigin: 'ai-generated' | 'human'; aiTool: string; aiModel: string; originSource: 'stored' | 'joined' } {
|
|
853
|
+
const stored = item.ai_origin?.S;
|
|
854
|
+
if (stored === 'ai-generated' || stored === 'human') {
|
|
855
|
+
return {
|
|
856
|
+
aiOrigin: stored,
|
|
857
|
+
aiTool: item.ai_tool?.S ?? (stored === 'human' ? 'none' : ''),
|
|
858
|
+
aiModel: item.ai_model?.S ?? '',
|
|
859
|
+
originSource: 'stored',
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
const traceId = item.trace_id?.S ?? '';
|
|
863
|
+
const usage = traceId && traceMap ? traceMap.get(traceId) : undefined;
|
|
864
|
+
return usage
|
|
865
|
+
? { aiOrigin: 'ai-generated', aiTool: usage.tool, aiModel: usage.model, originSource: 'joined' }
|
|
866
|
+
: { aiOrigin: 'human', aiTool: 'none', aiModel: '', originSource: 'joined' };
|
|
867
|
+
}
|
|
868
|
+
|
|
795
869
|
// ---- Route: productivity query ----
|
|
796
870
|
|
|
797
871
|
type UserProductivity = {
|
|
@@ -914,7 +988,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
914
988
|
}
|
|
915
989
|
|
|
916
990
|
// --- Commits ---
|
|
917
|
-
type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean };
|
|
991
|
+
type RawCommit = { user: string; traceId: string; inMain: boolean; wasReverted: boolean; storedOrigin?: string };
|
|
918
992
|
const rawCommits: RawCommit[] = [];
|
|
919
993
|
const commitFrom = `COMMIT#${from}`;
|
|
920
994
|
const commitTo = `COMMIT#${to}\uffff`;
|
|
@@ -938,6 +1012,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
938
1012
|
traceId: item.trace_id?.S ?? '',
|
|
939
1013
|
inMain: item.in_main?.BOOL ?? false,
|
|
940
1014
|
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
1015
|
+
storedOrigin: item.ai_origin?.S,
|
|
941
1016
|
});
|
|
942
1017
|
}
|
|
943
1018
|
lastKey = result.LastEvaluatedKey;
|
|
@@ -962,6 +1037,7 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
962
1037
|
traceId: item.trace_id?.S ?? '',
|
|
963
1038
|
inMain: item.in_main?.BOOL ?? false,
|
|
964
1039
|
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
1040
|
+
storedOrigin: item.ai_origin?.S,
|
|
965
1041
|
});
|
|
966
1042
|
}
|
|
967
1043
|
lastKey = result.LastEvaluatedKey;
|
|
@@ -975,12 +1051,19 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
975
1051
|
if (!c.user) continue;
|
|
976
1052
|
const u = getUser(c.user);
|
|
977
1053
|
u.commits.total++;
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1054
|
+
// Prefer the verdict frozen at ingest. Legacy items (no ai_origin) fall
|
|
1055
|
+
// back to a live span join, which is only reliable inside SPAN_TTL_DAYS.
|
|
1056
|
+
let isAi: boolean;
|
|
1057
|
+
if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
|
|
1058
|
+
isAi = c.storedOrigin === 'ai-generated';
|
|
1059
|
+
} else {
|
|
1060
|
+
let traceMap = traceMaps.get(c.user);
|
|
1061
|
+
if (!traceMap) {
|
|
1062
|
+
traceMap = await buildUserTraceMap(c.user);
|
|
1063
|
+
traceMaps.set(c.user, traceMap);
|
|
1064
|
+
}
|
|
1065
|
+
isAi = c.traceId !== '' && traceMap.has(c.traceId);
|
|
982
1066
|
}
|
|
983
|
-
const isAi = c.traceId !== '' && traceMap.has(c.traceId);
|
|
984
1067
|
if (isAi) {
|
|
985
1068
|
u.commits.ai++;
|
|
986
1069
|
if (c.inMain) u.commits.mergedAi++;
|
|
@@ -1028,6 +1111,134 @@ async function handleProductivityQuery(event: HttpApiEvent): Promise<HttpApiResp
|
|
|
1028
1111
|
});
|
|
1029
1112
|
}
|
|
1030
1113
|
|
|
1114
|
+
// ---- Route: per-repo attribution query ----
|
|
1115
|
+
|
|
1116
|
+
type RepoProductivity = {
|
|
1117
|
+
repo: string;
|
|
1118
|
+
commits: { total: number; ai: number; human: number; mergedAi: number; revertedAi: number };
|
|
1119
|
+
contributors: number;
|
|
1120
|
+
ratios: { aiSharePct: number | null; mergeRatePct: number | null; defectRatePct: number | null };
|
|
1121
|
+
lastActivity: string | null;
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* GET /v1/repos?from=YYYY-MM-DD&to=YYYY-MM-DD
|
|
1126
|
+
*
|
|
1127
|
+
* Aggregates commit outcomes by repository. The per-developer view
|
|
1128
|
+
* (/v1/productivity) answers "who", this answers "where" — which repos are
|
|
1129
|
+
* AI-heavy, which have low merge rates or high revert rates. Repo comes from
|
|
1130
|
+
* the commit item pk (REPO#<repo>); AI-origin classification uses the same
|
|
1131
|
+
* traceId-join rule as /v1/productivity (a commit is AI-generated only if
|
|
1132
|
+
* correlated usage spans exist for its trace).
|
|
1133
|
+
*
|
|
1134
|
+
* Spend is deliberately NOT included: usage rollups are keyed by user/day,
|
|
1135
|
+
* not by repo, so per-repo cost cannot be derived without double-counting
|
|
1136
|
+
* sessions that touch multiple repos.
|
|
1137
|
+
*/
|
|
1138
|
+
async function handleReposQuery(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
1139
|
+
const caller = resolveIdentity(event);
|
|
1140
|
+
if (!caller) return jsonResponse(401, { message: 'No resolvable identity claim in token' });
|
|
1141
|
+
|
|
1142
|
+
const url = new URL(event.rawPath + '?' + (event.rawQueryString ?? ''), 'http://localhost');
|
|
1143
|
+
const now = new Date();
|
|
1144
|
+
const from = url.searchParams.get('from') ?? new Date(now.getTime() - 30 * 86400_000).toISOString().slice(0, 10);
|
|
1145
|
+
const to = url.searchParams.get('to') ?? now.toISOString().slice(0, 10);
|
|
1146
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(from) || !/^\d{4}-\d{2}-\d{2}$/.test(to)) {
|
|
1147
|
+
return jsonResponse(400, { message: 'from/to must be YYYY-MM-DD' });
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
type RawCommit = { repo: string; user: string; traceId: string; inMain: boolean; wasReverted: boolean; ts: string; storedOrigin?: string };
|
|
1151
|
+
const rawCommits: RawCommit[] = [];
|
|
1152
|
+
let lastKey: import('@aws-sdk/client-dynamodb').ScanCommandOutput['LastEvaluatedKey'];
|
|
1153
|
+
do {
|
|
1154
|
+
const result = await dynamoClient.send(new ScanCommand({
|
|
1155
|
+
TableName: AI_USAGE_TABLE,
|
|
1156
|
+
FilterExpression: 'record_type = :rt AND #ts BETWEEN :lo AND :hi',
|
|
1157
|
+
ExpressionAttributeNames: { '#ts': 'timestamp' },
|
|
1158
|
+
ExpressionAttributeValues: {
|
|
1159
|
+
':rt': { S: 'OTEL_ATTR_COMMIT' },
|
|
1160
|
+
':lo': { S: from },
|
|
1161
|
+
':hi': { S: `${to}\uffff` },
|
|
1162
|
+
},
|
|
1163
|
+
...(lastKey ? { ExclusiveStartKey: lastKey } : {}),
|
|
1164
|
+
}));
|
|
1165
|
+
for (const item of result.Items ?? []) {
|
|
1166
|
+
rawCommits.push({
|
|
1167
|
+
// pk is REPO#<repo>; strip the prefix for display.
|
|
1168
|
+
repo: (item.pk?.S ?? '').replace(/^REPO#/, ''),
|
|
1169
|
+
user: item.user?.S ?? '',
|
|
1170
|
+
traceId: item.trace_id?.S ?? '',
|
|
1171
|
+
inMain: item.in_main?.BOOL ?? false,
|
|
1172
|
+
wasReverted: item.was_reverted?.BOOL ?? false,
|
|
1173
|
+
ts: item.timestamp?.S ?? '',
|
|
1174
|
+
storedOrigin: item.ai_origin?.S,
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
lastKey = result.LastEvaluatedKey;
|
|
1178
|
+
} while (lastKey);
|
|
1179
|
+
|
|
1180
|
+
// One trace map per user (cached across repos — a user's spans are the same
|
|
1181
|
+
// regardless of which repo the commit landed in).
|
|
1182
|
+
const traceMaps = new Map<string, Map<string, { tool: string; model: string }>>();
|
|
1183
|
+
const repos = new Map<string, RepoProductivity>();
|
|
1184
|
+
const contributors = new Map<string, Set<string>>();
|
|
1185
|
+
|
|
1186
|
+
for (const c of rawCommits) {
|
|
1187
|
+
if (!c.repo) continue;
|
|
1188
|
+
let r = repos.get(c.repo);
|
|
1189
|
+
if (!r) {
|
|
1190
|
+
r = {
|
|
1191
|
+
repo: c.repo,
|
|
1192
|
+
commits: { total: 0, ai: 0, human: 0, mergedAi: 0, revertedAi: 0 },
|
|
1193
|
+
contributors: 0,
|
|
1194
|
+
ratios: { aiSharePct: null, mergeRatePct: null, defectRatePct: null },
|
|
1195
|
+
lastActivity: null,
|
|
1196
|
+
};
|
|
1197
|
+
repos.set(c.repo, r);
|
|
1198
|
+
contributors.set(c.repo, new Set());
|
|
1199
|
+
}
|
|
1200
|
+
r.commits.total++;
|
|
1201
|
+
if (c.user) contributors.get(c.repo)!.add(c.user);
|
|
1202
|
+
if (!r.lastActivity || c.ts > r.lastActivity) r.lastActivity = c.ts;
|
|
1203
|
+
|
|
1204
|
+
let isAi: boolean;
|
|
1205
|
+
if (c.storedOrigin === 'ai-generated' || c.storedOrigin === 'human') {
|
|
1206
|
+
isAi = c.storedOrigin === 'ai-generated';
|
|
1207
|
+
} else {
|
|
1208
|
+
let traceMap = c.user ? traceMaps.get(c.user) : undefined;
|
|
1209
|
+
if (c.user && !traceMap) {
|
|
1210
|
+
traceMap = await buildUserTraceMap(c.user);
|
|
1211
|
+
traceMaps.set(c.user, traceMap);
|
|
1212
|
+
}
|
|
1213
|
+
isAi = c.traceId !== '' && !!traceMap?.has(c.traceId);
|
|
1214
|
+
}
|
|
1215
|
+
if (isAi) {
|
|
1216
|
+
r.commits.ai++;
|
|
1217
|
+
if (c.inMain) r.commits.mergedAi++;
|
|
1218
|
+
if (c.wasReverted) r.commits.revertedAi++;
|
|
1219
|
+
} else {
|
|
1220
|
+
r.commits.human++;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const pct = (num: number, den: number): number | null =>
|
|
1225
|
+
den > 0 ? Math.round((100 * num / den) * 10) / 10 : null;
|
|
1226
|
+
for (const r of repos.values()) {
|
|
1227
|
+
r.contributors = contributors.get(r.repo)?.size ?? 0;
|
|
1228
|
+
r.ratios = {
|
|
1229
|
+
aiSharePct: pct(r.commits.ai, r.commits.total),
|
|
1230
|
+
mergeRatePct: pct(r.commits.mergedAi, r.commits.ai),
|
|
1231
|
+
defectRatePct: pct(r.commits.revertedAi, r.commits.mergedAi),
|
|
1232
|
+
};
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
return jsonResponse(200, {
|
|
1236
|
+
range: { from, to },
|
|
1237
|
+
generatedAt: new Date().toISOString(),
|
|
1238
|
+
repos: [...repos.values()].sort((a, b) => b.commits.total - a.commits.total),
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1031
1242
|
function accumulateUsage(u: UserProductivity, item: Record<string, { S?: string; N?: string }>): void {
|
|
1032
1243
|
const tool = item.tool?.S ?? 'unknown';
|
|
1033
1244
|
const cost = Number(item.cost_usd?.N ?? 0);
|
|
@@ -1065,5 +1276,8 @@ export async function handler(event: HttpApiEvent): Promise<HttpApiResponse> {
|
|
|
1065
1276
|
if (method === 'GET' && path === '/v1/productivity') {
|
|
1066
1277
|
return handleProductivityQuery(event);
|
|
1067
1278
|
}
|
|
1279
|
+
if (method === 'GET' && path === '/v1/repos') {
|
|
1280
|
+
return handleReposQuery(event);
|
|
1281
|
+
}
|
|
1068
1282
|
return jsonResponse(404, { message: 'Not found' });
|
|
1069
1283
|
}
|
|
@@ -21,7 +21,7 @@ interface PrEvent {
|
|
|
21
21
|
repo: string;
|
|
22
22
|
timestamp: string;
|
|
23
23
|
ai_context?: { origin: string };
|
|
24
|
-
pr?: { merged_at?: string; number?: number };
|
|
24
|
+
pr?: { merged_at?: string; number?: number; commit_shas?: string[] };
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -90,7 +90,20 @@ export async function handler(event: PrEvent): Promise<void> {
|
|
|
90
90
|
finding_id: finding.finding_id,
|
|
91
91
|
severity: finding.severity,
|
|
92
92
|
remediation_time_hours: Math.round(remediationHours * 100) / 100,
|
|
93
|
+
// WHO FIXED IT — resolved by deferred attribution join at render
|
|
94
|
+
// time, not here. These are the FIX PR's commits (not the
|
|
95
|
+
// offending commits on the finding), which is the correct signal
|
|
96
|
+
// for "who remediated this". SHAs are immutable facts; a verdict
|
|
97
|
+
// computed now can be permanently wrong because `codeburn sync
|
|
98
|
+
// --attribution` runs on a ~12h schedule and has not necessarily
|
|
99
|
+
// covered these commits yet.
|
|
100
|
+
fix_commit_shas: detail.pr?.commit_shas ?? [],
|
|
101
|
+
// Legacy trailer-derived verdict, retained for the Phase 1
|
|
102
|
+
// parallel run. Reads 'human' for everything once git hooks are
|
|
103
|
+
// removed (ai_context.origin <- ai_ratio <- trailer counting), so
|
|
104
|
+
// dashboards prefer the join above and only fall back to this.
|
|
93
105
|
remediated_by_origin: detail.ai_context?.origin ?? 'unknown',
|
|
106
|
+
remediated_by_origin_source: 'git-trailers',
|
|
94
107
|
fix_pr_number: detail.pr?.number ?? null,
|
|
95
108
|
finding_phase: finding.phase,
|
|
96
109
|
},
|
|
@@ -110,7 +123,9 @@ export async function handler(event: PrEvent): Promise<void> {
|
|
|
110
123
|
);
|
|
111
124
|
|
|
112
125
|
console.log(
|
|
113
|
-
`Remediation event: finding ${finding.finding_id} (${finding.severity}) fixed in ${remediationHours.toFixed(1)}h
|
|
126
|
+
`Remediation event: finding ${finding.finding_id} (${finding.severity}) fixed in ${remediationHours.toFixed(1)}h ` +
|
|
127
|
+
`in PR #${detail.pr?.number ?? '?'} (${(detail.pr?.commit_shas ?? []).length} commit SHAs for deferred origin join; ` +
|
|
128
|
+
`trailer said ${detail.ai_context?.origin ?? 'unknown'})`,
|
|
114
129
|
);
|
|
115
130
|
}
|
|
116
131
|
}
|