@prism-d1/cli 1.0.98 → 1.0.99
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 +172 -1
- package/dist/assets/infra/lib/lambda/event-schema.ts +11 -1
- package/dist/assets/infra/lib/lambda/metrics-processor.ts +10 -5
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +26 -10
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +1 -0
- package/package.json +1 -1
|
@@ -32,6 +32,8 @@ on:
|
|
|
32
32
|
pull_request:
|
|
33
33
|
types: [closed]
|
|
34
34
|
branches: [main, master]
|
|
35
|
+
push:
|
|
36
|
+
branches: [main, master]
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
env:
|
|
@@ -45,7 +47,7 @@ permissions:
|
|
|
45
47
|
|
|
46
48
|
jobs:
|
|
47
49
|
emit-metrics:
|
|
48
|
-
if: github.event.pull_request.merged == true
|
|
50
|
+
if: github.event_name == 'pull_request' && github.event.pull_request.merged == true
|
|
49
51
|
runs-on: ubuntu-latest
|
|
50
52
|
steps:
|
|
51
53
|
- name: Checkout
|
|
@@ -245,3 +247,172 @@ jobs:
|
|
|
245
247
|
\"EventBusName\": \"prism-d1-metrics\",
|
|
246
248
|
\"Detail\": $(echo "${DEPLOY_EVENT}" | jq -c '.' | jq -Rs '.')
|
|
247
249
|
}]"
|
|
250
|
+
|
|
251
|
+
# ---------------------------------------------------------------------------
|
|
252
|
+
# Census-only path: commits that reached the default branch WITHOUT a PR.
|
|
253
|
+
#
|
|
254
|
+
# WHY THIS EXISTS. Attribution Coverage is attributed_commits / ci_commits,
|
|
255
|
+
# and ci_commits was sourced solely from merged-PR events. A direct push to
|
|
256
|
+
# main therefore shrank the denominator while codeburn still attributed the
|
|
257
|
+
# commits, so coverage rose toward a confident 100% precisely as the census
|
|
258
|
+
# became less complete -- the one number meant to flag untrustworthy AI
|
|
259
|
+
# metrics failed in the reassuring direction. See velocity-widget.ts →
|
|
260
|
+
# attributionCoverage().
|
|
261
|
+
#
|
|
262
|
+
# WHAT IT DELIBERATELY DOES NOT DO. It emits no `dora` block and no
|
|
263
|
+
# prism.d1.deploy event, and it uses its own detail type rather than
|
|
264
|
+
# prism.d1.pr. Three reasons, each load-bearing:
|
|
265
|
+
# - Change Failure Rate is failure_fixes / prs.length. Pushing census
|
|
266
|
+
# events into prism.d1.pr would inflate that denominator and make the
|
|
267
|
+
# revert rate look better, reintroducing the same class of lie.
|
|
268
|
+
# - A push has no created_at → merged_at pair, so any lead time would be
|
|
269
|
+
# fabricated.
|
|
270
|
+
# - A push has no title convention to test for revert/hotfix/rollback.
|
|
271
|
+
# Deployment Frequency therefore still counts merges only. That is the
|
|
272
|
+
# accepted trade: this job fixes the census, it does not invent DORA data.
|
|
273
|
+
#
|
|
274
|
+
# NOT prism.d1.commit, which already exists: that type is per-commit and
|
|
275
|
+
# carries ai_context.{tool,model,origin} from the deprecated git hook. Its
|
|
276
|
+
# two remaining consumers (api-handler.ts, security-agent-processor.ts)
|
|
277
|
+
# expect that shape, and AI origin is deliberately resolved at render time
|
|
278
|
+
# here rather than at CI time.
|
|
279
|
+
emit-push-census:
|
|
280
|
+
# A PR merge fires BOTH pull_request:closed AND push, so this job must not
|
|
281
|
+
# double-count it. The head-commit-has-no-PR check below is the guard; this
|
|
282
|
+
# condition only keeps push-triggered runs.
|
|
283
|
+
if: github.event_name == 'push'
|
|
284
|
+
runs-on: ubuntu-latest
|
|
285
|
+
steps:
|
|
286
|
+
- name: Checkout
|
|
287
|
+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
|
288
|
+
with:
|
|
289
|
+
# Full history: `git rev-list before..after` needs both endpoints.
|
|
290
|
+
fetch-depth: 0
|
|
291
|
+
|
|
292
|
+
- name: Skip if this push came from a PR merge
|
|
293
|
+
id: gate
|
|
294
|
+
env:
|
|
295
|
+
GH_TOKEN: ${{ github.token }}
|
|
296
|
+
run: |
|
|
297
|
+
set -uo pipefail
|
|
298
|
+
SHA="${{ github.event.after }}"
|
|
299
|
+
REPO="${{ github.repository }}"
|
|
300
|
+
|
|
301
|
+
# Covers all three merge strategies in one check. A merge commit, a
|
|
302
|
+
# squashed commit, and a rebased commit are all associated with their
|
|
303
|
+
# PR by this endpoint, whereas a hand-pushed commit is associated with
|
|
304
|
+
# none. Testing parent count instead would only catch merge commits
|
|
305
|
+
# and would double-count every squash merge.
|
|
306
|
+
PR_COUNT=$(gh api "repos/${REPO}/commits/${SHA}/pulls" --jq 'length' 2>/dev/null || echo "unknown")
|
|
307
|
+
|
|
308
|
+
if [[ "${PR_COUNT}" == "unknown" ]]; then
|
|
309
|
+
# Fail closed: emitting on an inconclusive check risks double-counting
|
|
310
|
+
# the census, which corrupts coverage in the direction this job exists
|
|
311
|
+
# to prevent. Under-counting is the safer error.
|
|
312
|
+
echo "Could not determine PR association for ${SHA}; skipping to avoid double-count."
|
|
313
|
+
echo "emit=false" >> "$GITHUB_OUTPUT"
|
|
314
|
+
elif [[ "${PR_COUNT}" != "0" ]]; then
|
|
315
|
+
echo "${SHA} belongs to ${PR_COUNT} PR(s); the emit-metrics job owns this merge."
|
|
316
|
+
echo "emit=false" >> "$GITHUB_OUTPUT"
|
|
317
|
+
else
|
|
318
|
+
echo "${SHA} has no associated PR — direct push, emitting census."
|
|
319
|
+
echo "emit=true" >> "$GITHUB_OUTPUT"
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
- name: Configure AWS credentials (OIDC)
|
|
323
|
+
if: steps.gate.outputs.emit == 'true'
|
|
324
|
+
uses: aws-actions/configure-aws-credentials@b47578312673ae6fa5b5096b330d9fbac3d116df # v4.2.1
|
|
325
|
+
with:
|
|
326
|
+
role-to-assume: ${{ secrets.PRISM_METRICS_ROLE_ARN }}
|
|
327
|
+
aws-region: ${{ env.PRISM_AWS_REGION }}
|
|
328
|
+
|
|
329
|
+
- name: Read team ID from .prism/config.json
|
|
330
|
+
if: steps.gate.outputs.emit == 'true'
|
|
331
|
+
id: prism-config
|
|
332
|
+
run: |
|
|
333
|
+
if [[ -f .prism/config.json ]]; then
|
|
334
|
+
TEAM_ID=$(jq -r '.team_id // ""' .prism/config.json 2>/dev/null || echo "")
|
|
335
|
+
else
|
|
336
|
+
TEAM_ID=""
|
|
337
|
+
fi
|
|
338
|
+
if [[ -z "${TEAM_ID}" ]]; then
|
|
339
|
+
TEAM_ID="no_team"
|
|
340
|
+
fi
|
|
341
|
+
echo "team_id=${TEAM_ID}" >> "$GITHUB_OUTPUT"
|
|
342
|
+
|
|
343
|
+
- name: Collect commit census
|
|
344
|
+
if: steps.gate.outputs.emit == 'true'
|
|
345
|
+
id: census
|
|
346
|
+
run: |
|
|
347
|
+
set -uo pipefail
|
|
348
|
+
BEFORE="${{ github.event.before }}"
|
|
349
|
+
AFTER="${{ github.event.after }}"
|
|
350
|
+
|
|
351
|
+
# A new branch (or the first push) reports an all-zero `before`, and
|
|
352
|
+
# a force-push reports a SHA that is no longer an ancestor. Both make
|
|
353
|
+
# `before..after` invalid or unboundedly large, so fall back to the
|
|
354
|
+
# single head commit rather than emitting a bogus count.
|
|
355
|
+
if [[ "${BEFORE}" =~ ^0+$ ]] || ! git cat-file -e "${BEFORE}^{commit}" 2>/dev/null; then
|
|
356
|
+
echo "Unusable before-SHA (${BEFORE}); counting the head commit only."
|
|
357
|
+
RANGE="${AFTER} -1"
|
|
358
|
+
else
|
|
359
|
+
RANGE="${BEFORE}..${AFTER}"
|
|
360
|
+
fi
|
|
361
|
+
|
|
362
|
+
# --no-merges and the 50-item cap match the PR path exactly, so both
|
|
363
|
+
# sides of the coverage ratio are counted on the same basis. A merge
|
|
364
|
+
# commit introduces no authored lines and can never appear in the
|
|
365
|
+
# attribution store, so counting one would put a structural ceiling
|
|
366
|
+
# on coverage.
|
|
367
|
+
COMMIT_SHAS=$(git rev-list --no-merges ${RANGE} 2>/dev/null | head -50 | jq -R . | jq -sc . || echo '[]')
|
|
368
|
+
TOTAL_COMMITS=$(git rev-list --no-merges --count ${RANGE} 2>/dev/null || echo 0)
|
|
369
|
+
COMMIT_AUTHORS=$(git rev-list --no-merges ${RANGE} 2>/dev/null | head -50 | while read sha; do git log -1 --format='%ae' "$sha"; done | jq -R . | jq -sc . || echo '[]')
|
|
370
|
+
|
|
371
|
+
echo "commit_shas=${COMMIT_SHAS}" >> "$GITHUB_OUTPUT"
|
|
372
|
+
echo "commit_authors=${COMMIT_AUTHORS}" >> "$GITHUB_OUTPUT"
|
|
373
|
+
echo "total_commits=${TOTAL_COMMITS}" >> "$GITHUB_OUTPUT"
|
|
374
|
+
|
|
375
|
+
- name: Emit prism.d1.push event
|
|
376
|
+
if: steps.gate.outputs.emit == 'true' && steps.census.outputs.total_commits != '0'
|
|
377
|
+
run: |
|
|
378
|
+
TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
379
|
+
|
|
380
|
+
PUSH_EVENT=$(jq -n \
|
|
381
|
+
--arg team_id "${{ steps.prism-config.outputs.team_id }}" \
|
|
382
|
+
--arg repo "${PRISM_REPO}" \
|
|
383
|
+
--arg timestamp "${TIMESTAMP}" \
|
|
384
|
+
--arg sha "${{ github.event.after }}" \
|
|
385
|
+
--arg pusher "${{ github.event.pusher.name }}" \
|
|
386
|
+
--argjson total_commits "${{ steps.census.outputs.total_commits }}" \
|
|
387
|
+
--argjson commit_shas '${{ steps.census.outputs.commit_shas }}' \
|
|
388
|
+
--argjson commit_authors '${{ steps.census.outputs.commit_authors }}' \
|
|
389
|
+
'{
|
|
390
|
+
"team_id": $team_id,
|
|
391
|
+
"repo": $repo,
|
|
392
|
+
"timestamp": $timestamp,
|
|
393
|
+
"prism_level": 2,
|
|
394
|
+
"metric": {
|
|
395
|
+
"name": "push_commits",
|
|
396
|
+
"value": $total_commits,
|
|
397
|
+
"unit": "count"
|
|
398
|
+
},
|
|
399
|
+
"push": {
|
|
400
|
+
"sha": $sha,
|
|
401
|
+
"pusher": $pusher,
|
|
402
|
+
"total_commits": $total_commits,
|
|
403
|
+
"commit_shas": $commit_shas,
|
|
404
|
+
"commit_authors": $commit_authors
|
|
405
|
+
}
|
|
406
|
+
}')
|
|
407
|
+
|
|
408
|
+
aws events put-events \
|
|
409
|
+
--region $PRISM_AWS_REGION \
|
|
410
|
+
--entries "[{
|
|
411
|
+
\"Source\": \"prism.d1.velocity\",
|
|
412
|
+
\"DetailType\": \"prism.d1.push\",
|
|
413
|
+
\"EventBusName\": \"prism-d1-metrics\",
|
|
414
|
+
\"Detail\": $(echo "${PUSH_EVENT}" | jq -c '.' | jq -Rs '.')
|
|
415
|
+
}]"
|
|
416
|
+
|
|
417
|
+
echo "Emitted prism.d1.push for ${{ github.event.after }}"
|
|
418
|
+
echo " commits in census: ${{ steps.census.outputs.total_commits }}"
|
|
@@ -36,6 +36,7 @@ export const PERSISTED_SECTIONS = [
|
|
|
36
36
|
'security_agent_finding',
|
|
37
37
|
'security_remediation',
|
|
38
38
|
'pr',
|
|
39
|
+
'push',
|
|
39
40
|
] as const;
|
|
40
41
|
|
|
41
42
|
export type PersistedSection = (typeof PERSISTED_SECTIONS)[number];
|
|
@@ -76,7 +77,16 @@ export const SECTION_FIELDS: Record<PersistedSection, readonly string[]> = {
|
|
|
76
77
|
],
|
|
77
78
|
pr: [
|
|
78
79
|
'number', 'author', 'reviews_approved', 'reviews_changes_requested',
|
|
79
|
-
'total_commits', 'commit_shas',
|
|
80
|
+
'total_commits', 'commit_shas', 'commit_authors',
|
|
81
|
+
],
|
|
82
|
+
// Census-only counterpart to `pr`, emitted by prism-ai-metrics.yml when
|
|
83
|
+
// commits reach the default branch without a PR. Deliberately carries no
|
|
84
|
+
// lead-time or failure-fix field: a push has neither a created_at→merged_at
|
|
85
|
+
// pair nor a title convention, so any such value would be fabricated. Kept a
|
|
86
|
+
// separate section (rather than reusing `pr`) because Change Failure Rate
|
|
87
|
+
// divides by the count of prism.d1.pr events.
|
|
88
|
+
push: [
|
|
89
|
+
'sha', 'pusher', 'total_commits', 'commit_shas', 'commit_authors',
|
|
80
90
|
],
|
|
81
91
|
};
|
|
82
92
|
|
|
@@ -892,15 +892,20 @@ function mapUnit(unit: string): StandardUnit {
|
|
|
892
892
|
* processor already handles both forms for point lookups.
|
|
893
893
|
*/
|
|
894
894
|
async function seedCommitAttribution(detailType: string, detail: any): Promise<void> {
|
|
895
|
-
//
|
|
896
|
-
|
|
895
|
+
// prism.d1.pr and prism.d1.push both report commits that reached the default
|
|
896
|
+
// branch, so both seed in_main=true. A direct push is as much "shipped" as a
|
|
897
|
+
// merge; excluding it would undercount shipped commits and inflate the
|
|
898
|
+
// cost-per-shipped-commit KPI, while still charging the tokens that produced
|
|
899
|
+
// them. The census fields are identically shaped on the two events, only
|
|
900
|
+
// nested under a different key.
|
|
901
|
+
if (detailType !== 'prism.d1.pr' && detailType !== 'prism.d1.push') return;
|
|
897
902
|
if (!AI_USAGE_TABLE) return;
|
|
898
903
|
|
|
899
|
-
const
|
|
900
|
-
const shas =
|
|
904
|
+
const census = (detailType === 'prism.d1.pr' ? detail.pr : detail.push) as PrDetail | undefined;
|
|
905
|
+
const shas = census?.commit_shas;
|
|
901
906
|
if (!shas || shas.length === 0) return;
|
|
902
907
|
|
|
903
|
-
const authors: string[] = (
|
|
908
|
+
const authors: string[] = (census as any)?.commit_authors ?? [];
|
|
904
909
|
const repo = detail.repo as string;
|
|
905
910
|
// CI workflows now emit fully-qualified repo names (github.com/owner/repo
|
|
906
911
|
// or gitlab.com/group/project) matching codeburn's convention. No
|
|
@@ -193,7 +193,7 @@ async function renderDora(fromIso: string, toIso: string, days: number, p: Palet
|
|
|
193
193
|
]);
|
|
194
194
|
|
|
195
195
|
if (deploys.length === 0 && prs.length === 0) {
|
|
196
|
-
return emptyState('deploy / PR', 'Populated by prism-ai-metrics.yml on PR merge. Install the workflow and set PRISM_METRICS_ROLE_ARN.', p);
|
|
196
|
+
return emptyState('deploy / PR', 'Populated by prism-ai-metrics.yml on PR merge (and on direct pushes to the default branch, census only). Install the workflow and set PRISM_METRICS_ROLE_ARN.', p);
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
const mergeFreq = days > 0 ? deploys.length / days : deploys.length;
|
|
@@ -246,18 +246,32 @@ function median(values: number[]): number | null {
|
|
|
246
246
|
* of who authored it and reports pr.total_commits. Attribution gives us the
|
|
247
247
|
* sample. The ratio is the coverage.
|
|
248
248
|
*
|
|
249
|
+
* `pushes` carries the other half of that census. Commits pushed straight to
|
|
250
|
+
* the default branch produce no PR event, so before they were counted they
|
|
251
|
+
* shrank this denominator while codeburn still attributed them -- coverage rose
|
|
252
|
+
* toward 100% exactly as the census got less complete, and the Math.min below
|
|
253
|
+
* hid the overshoot that would otherwise have exposed it. Both event types are
|
|
254
|
+
* summed here; they are kept separate from `prs` everywhere else, because
|
|
255
|
+
* Change Failure Rate divides by prs.length and a push has no title to test.
|
|
256
|
+
*
|
|
257
|
+
* The emitter guarantees these do not overlap: emit-push-census skips any push
|
|
258
|
+
* whose head commit is associated with a PR, which covers merge, squash and
|
|
259
|
+
* rebase strategies alike.
|
|
260
|
+
*
|
|
249
261
|
* Returns null when CI events are absent, since coverage is unknowable then —
|
|
250
262
|
* deliberately not 0%, which would imply "measured and found nothing".
|
|
251
263
|
*/
|
|
252
|
-
function attributionCoverage(prs: PrismEvent[], attributedCommits: number | null): {
|
|
264
|
+
function attributionCoverage(prs: PrismEvent[], pushes: PrismEvent[], attributedCommits: number | null): {
|
|
253
265
|
pct: number | null;
|
|
254
266
|
ciCommits: number;
|
|
255
267
|
attributed: number | null;
|
|
256
268
|
} {
|
|
257
|
-
const
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
269
|
+
const countCommits = (events: PrismEvent[], key: 'pr' | 'push') =>
|
|
270
|
+
events.reduce((sum, e) => {
|
|
271
|
+
const n = (e.data as any)[key]?.total_commits;
|
|
272
|
+
return sum + (typeof n === 'number' && n > 0 ? n : 0);
|
|
273
|
+
}, 0);
|
|
274
|
+
const ciCommits = countCommits(prs, 'pr') + countCommits(pushes, 'push');
|
|
261
275
|
if (ciCommits === 0 || attributedCommits === null) {
|
|
262
276
|
return { pct: null, ciCommits, attributed: attributedCommits };
|
|
263
277
|
}
|
|
@@ -345,17 +359,18 @@ async function renderRepos(fromIso: string, toIso: string, p: Palette): Promise<
|
|
|
345
359
|
}
|
|
346
360
|
|
|
347
361
|
async function renderAidora(fromIso: string, toIso: string, p: Palette): Promise<string> {
|
|
348
|
-
const [report, evals, prs] = await Promise.all([
|
|
362
|
+
const [report, evals, prs, pushes] = await Promise.all([
|
|
349
363
|
fetchProductivity(fromIso, toIso).catch(() => null),
|
|
350
364
|
queryEvents('prism.d1.eval', fromIso, toIso),
|
|
351
365
|
queryEvents('prism.d1.pr', fromIso, toIso),
|
|
366
|
+
queryEvents('prism.d1.push', fromIso, toIso),
|
|
352
367
|
]);
|
|
353
368
|
|
|
354
369
|
const t = report?.totals;
|
|
355
370
|
const passRate = evals.length > 0
|
|
356
371
|
? (evals.filter(e => e.data.eval?.result === 'PASS').length / evals.length) * 100
|
|
357
372
|
: null;
|
|
358
|
-
const cov = attributionCoverage(prs, t?.commits?.total ?? null);
|
|
373
|
+
const cov = attributionCoverage(prs, pushes, t?.commits?.total ?? null);
|
|
359
374
|
|
|
360
375
|
const cells = [
|
|
361
376
|
{ label: 'AI Share of Commits', value: pct(t?.ratios?.aiSharePct ?? null), note: 'attribution store', color: (t?.ratios?.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
|
|
@@ -458,13 +473,14 @@ function computeObservedLevel(input: {
|
|
|
458
473
|
// ---- View: exec (business KPIs + delivery proxies + observed level) ----
|
|
459
474
|
|
|
460
475
|
async function renderExec(fromIso: string, toIso: string, days: number, p: Palette): Promise<string> {
|
|
461
|
-
const [report, evals, mcp, guardrails, deploys, prs] = await Promise.all([
|
|
476
|
+
const [report, evals, mcp, guardrails, deploys, prs, pushes] = await Promise.all([
|
|
462
477
|
fetchProductivity(fromIso, toIso).catch(() => null),
|
|
463
478
|
queryEvents('prism.d1.eval', fromIso, toIso),
|
|
464
479
|
queryEvents('prism.d1.mcp.tool_call', fromIso, toIso),
|
|
465
480
|
queryEvents('prism.d1.guardrail', fromIso, toIso),
|
|
466
481
|
queryEvents('prism.d1.deploy', fromIso, toIso),
|
|
467
482
|
queryEvents('prism.d1.pr', fromIso, toIso),
|
|
483
|
+
queryEvents('prism.d1.push', fromIso, toIso),
|
|
468
484
|
]);
|
|
469
485
|
|
|
470
486
|
const t = report?.totals;
|
|
@@ -506,7 +522,7 @@ async function renderExec(fromIso: string, toIso: string, days: number, p: Palet
|
|
|
506
522
|
]), p, 1);
|
|
507
523
|
|
|
508
524
|
// --- Business KPIs (attribution store) ---
|
|
509
|
-
const cov = attributionCoverage(prs, t?.commits?.total ?? null);
|
|
525
|
+
const cov = attributionCoverage(prs, pushes, t?.commits?.total ?? null);
|
|
510
526
|
const businessKpis = kpiRow([
|
|
511
527
|
{ label: 'AI Share of Commits', value: pct(r.aiSharePct ?? null), note: 'L2 >= 30%', color: (r.aiSharePct ?? 0) >= 30 ? p.ok : undefined },
|
|
512
528
|
{ label: 'AI Merge Rate', value: pct(r.mergeRatePct ?? null), note: 'L2 >= 20% · L4 >= 45%', color: (r.mergeRatePct ?? 0) >= 45 ? p.ok : (r.mergeRatePct ?? 0) >= 20 ? p.warn : undefined },
|