Skip to content

Commit 8eeeb5a

Browse files
authored
Merge pull request #50 from trick77/feat/noergler-pr-rollup
feat(noergler): per-PR rollup event with merged/declined/deleted outcome
2 parents 52d2789 + 543973b commit 8eeeb5a

11 files changed

Lines changed: 384 additions & 83 deletions

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ the data captured in v1.
8181
| **Untracked-work rate** | `COUNT(*) WHERE jira_keys = '{}'` over merged PRs — process-compliance signal. |
8282
| **Per-ticket flow** | `WHERE 'ABC-1234' = ANY(jira_keys)` returns every event for a ticket across Bitbucket / pipeline / Argo (joined via commit_sha). |
8383
| **Human vs automated split** | `WHERE NOT is_automated` (Renovate / Dependabot / Snyk / Mend / generic-bot detection runs at write time and tags `automation_source`). Default dashboards exclude bots; bot velocity is a separate CI-health view. |
84-
| **AI reviewer precision** *(noergler)* | `1 - count(noergler_events WHERE event_type='feedback' AND verdict='disagreed') / count(noergler_events WHERE event_type='completed')` per repo × week. Higher = the AI review is more useful. |
84+
| **AI reviewer precision** *(noergler)* | `1 - count(noergler_events WHERE event_type='feedback' AND verdict='disagreed') / count(noergler_events WHERE event_type='pr_completed')` per repo × week. Higher = the AI review is more useful. Filter on `outcome='merged'` to score precision only on PRs that shipped. |
8585

8686
### FinOps signals
8787

@@ -92,7 +92,9 @@ events arrive pre-priced in USD.
9292

9393
| Signal | How it's computed |
9494
|---|---|
95-
| **LLM review spend per model / team** *(noergler)* | `SUM(noergler_events.cost_usd), SUM(prompt_tokens + completion_tokens) GROUP BY model, team` over `event_type = 'completed'`. Pre-priced — no multiplier needed. |
95+
| **LLM review spend per PR / team** *(noergler)* | `SUM(cost_usd), SUM(prompt_tokens + completion_tokens) GROUP BY team` over `event_type = 'pr_completed'`. Each row is a per-PR rollup (one event per merged / declined / deleted PR). Filter on `outcome='merged'` for "spend that actually shipped"; keep all outcomes for total LLM-review spend including abandoned PRs. Pre-priced — no multiplier needed. |
96+
| **LLM review cost per KLOC** *(noergler)* | `SUM(cost_usd) / NULLIF(SUM(lines_added + lines_removed), 0) * 1000 GROUP BY team, outcome` over `noergler_events WHERE event_type='pr_completed'`. Diff-size normalised cost — fair comparison across small fixes and large refactors. |
97+
| **Wasted LLM review** *(noergler)* | `SUM(cost_usd) FROM noergler_events WHERE event_type='pr_completed' AND outcome IN ('declined','deleted')`. Review effort spent on code that never shipped. |
9698
| **CI compute time per pipeline / team** | `SUM(pipeline_events.duration_seconds) GROUP BY pipeline_name, team`. The unit metric for CI cost attribution. |
9799
| **Wasted CI** | `SUM(duration_seconds) WHERE status IN ('FAILURE','Failed')` — failed builds × time. Quantifies the cost of flakes / broken tests. |
98100
| **Bot-driven pipeline churn** | `pipeline_events` joined to `bitbucket_events` via `commit_sha` filtered on `is_automated = true`. Renovate / Dependabot can drive 40–70% of pipeline runs in many orgs; useful input for batching policies. |
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"""noergler events: switch from per-run 'completed' to per-PR 'pr_completed' rollup
2+
3+
Adds outcome / lines_added / lines_removed / files_changed / total_runs /
4+
merge_commit_sha / models_used / first_review_at columns to noergler_events.
5+
The pre-existing prompt_tokens / completion_tokens / elapsed_ms / findings_count /
6+
cost_usd columns are reused as aggregated totals — no rename, just a
7+
semantic shift.
8+
9+
Revision ID: 0002
10+
Revises: 0001
11+
Create Date: 2026-05-17
12+
13+
"""
14+
15+
from collections.abc import Sequence
16+
17+
import sqlalchemy as sa
18+
from alembic import op
19+
from sqlalchemy.dialects.postgresql import ARRAY
20+
21+
revision: str = "0002"
22+
down_revision: str | None = "0001"
23+
branch_labels: str | Sequence[str] | None = None
24+
depends_on: str | Sequence[str] | None = None
25+
26+
27+
def upgrade() -> None:
28+
op.add_column("noergler_events", sa.Column("outcome", sa.String, nullable=True))
29+
op.add_column("noergler_events", sa.Column("merge_commit_sha", sa.String, nullable=True))
30+
op.add_column("noergler_events", sa.Column("lines_added", sa.Integer, nullable=True))
31+
op.add_column("noergler_events", sa.Column("lines_removed", sa.Integer, nullable=True))
32+
op.add_column("noergler_events", sa.Column("files_changed", sa.Integer, nullable=True))
33+
op.add_column("noergler_events", sa.Column("total_runs", sa.Integer, nullable=True))
34+
op.add_column(
35+
"noergler_events",
36+
sa.Column("models_used", ARRAY(sa.String), nullable=True),
37+
)
38+
op.add_column(
39+
"noergler_events",
40+
sa.Column("first_review_at", sa.DateTime(timezone=True), nullable=True),
41+
)
42+
43+
op.create_index("ix_noergler_events_outcome", "noergler_events", ["outcome"])
44+
# The 'model' (singular) index made sense when each row was a single
45+
# review run. Per-PR rollups carry models_used (an array) instead, so
46+
# the singular-model index no longer matches the query patterns.
47+
op.drop_index("ix_noergler_events_model", table_name="noergler_events")
48+
49+
# run_id was the per-run idempotency key for the old 'completed' event.
50+
# The rollup uses (pr_key, outcome) — run_id is dead. 'model' (singular)
51+
# is superseded by models_used; the original value is still recoverable
52+
# from the payload JSONB on historical rows if anyone ever needs it.
53+
op.drop_column("noergler_events", "run_id")
54+
op.drop_column("noergler_events", "model")
55+
56+
57+
def downgrade() -> None:
58+
op.add_column("noergler_events", sa.Column("model", sa.String, nullable=True))
59+
op.add_column("noergler_events", sa.Column("run_id", sa.String, nullable=True))
60+
op.create_index("ix_noergler_events_model", "noergler_events", ["model"])
61+
op.drop_index("ix_noergler_events_outcome", table_name="noergler_events")
62+
63+
op.drop_column("noergler_events", "first_review_at")
64+
op.drop_column("noergler_events", "models_used")
65+
op.drop_column("noergler_events", "total_runs")
66+
op.drop_column("noergler_events", "files_changed")
67+
op.drop_column("noergler_events", "lines_removed")
68+
op.drop_column("noergler_events", "lines_added")
69+
op.drop_column("noergler_events", "merge_commit_sha")
70+
op.drop_column("noergler_events", "outcome")

src/riptide_collector/models.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -115,22 +115,34 @@ class NoerglerEvent(Base):
115115
Index("ix_noergler_events_event_type", "event_type"),
116116
Index("ix_noergler_events_pr_key", "pr_key"),
117117
Index("ix_noergler_events_commit_sha", "commit_sha"),
118-
Index("ix_noergler_events_model", "model"),
118+
Index("ix_noergler_events_outcome", "outcome"),
119119
)
120120

121121
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
122122
delivery_id: Mapped[str] = mapped_column(String, nullable=False)
123+
# 'pr_completed' | 'feedback' — historical rows may carry the deprecated
124+
# 'completed' value before the per-run → per-PR rollup switch.
123125
event_type: Mapped[str] = mapped_column(String, nullable=False)
124126
pr_key: Mapped[str | None] = mapped_column(String, nullable=True)
125127
repo: Mapped[str | None] = mapped_column(String, nullable=True)
128+
# pr_completed: source-branch HEAD at close time. feedback: optional join key.
126129
commit_sha: Mapped[str | None] = mapped_column(String, nullable=True)
127-
run_id: Mapped[str | None] = mapped_column(String, nullable=True)
128-
model: Mapped[str | None] = mapped_column(String, nullable=True)
130+
# pr_completed-only:
131+
outcome: Mapped[str | None] = mapped_column(String, nullable=True)
132+
merge_commit_sha: Mapped[str | None] = mapped_column(String, nullable=True)
133+
lines_added: Mapped[int | None] = mapped_column(Integer, nullable=True)
134+
lines_removed: Mapped[int | None] = mapped_column(Integer, nullable=True)
135+
files_changed: Mapped[int | None] = mapped_column(Integer, nullable=True)
136+
total_runs: Mapped[int | None] = mapped_column(Integer, nullable=True)
137+
models_used: Mapped[list[str] | None] = mapped_column(ARRAY(String), nullable=True)
138+
first_review_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
139+
# Aggregated finops totals across all review runs that fed into this PR rollup.
129140
prompt_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
130141
completion_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
131142
elapsed_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
132143
findings_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
133144
cost_usd: Mapped[Decimal | None] = mapped_column(Numeric(12, 6), nullable=True)
145+
# feedback-only:
134146
finding_id: Mapped[str | None] = mapped_column(String, nullable=True)
135147
verdict: Mapped[str | None] = mapped_column(String, nullable=True)
136148
actor: Mapped[str | None] = mapped_column(String, nullable=True)

src/riptide_collector/routers/noergler.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
from riptide_collector.models import NoerglerEvent
99
from riptide_collector.parsers import lower
1010
from riptide_collector.schemas.noergler import (
11-
NoerglerCompleted,
1211
NoerglerFeedback,
12+
NoerglerPrCompleted,
1313
NoerglerWebhook,
1414
)
1515

@@ -25,16 +25,16 @@ def make_router(
2525
@router.post(
2626
"/noergler",
2727
status_code=status.HTTP_202_ACCEPTED,
28-
summary="Noergler PR-review webhook sink (cost + reviewer-precision)",
28+
summary="Noergler PR-review webhook sink (per-PR rollup + reviewer-precision)",
2929
)
3030
async def noergler_webhook( # pyright: ignore[reportUnusedFunction]
3131
event: NoerglerWebhook,
3232
caller_team: str = Depends(auth_dep),
3333
) -> dict[str, str]:
3434
raw = event.model_dump(mode="json")
3535

36-
if isinstance(event, NoerglerCompleted):
37-
values = _values_completed(event, caller_team, raw)
36+
if isinstance(event, NoerglerPrCompleted):
37+
values = _values_pr_completed(event, caller_team, raw)
3838
else:
3939
values = _values_feedback(event, caller_team, raw)
4040

@@ -70,27 +70,36 @@ async def noergler_webhook( # pyright: ignore[reportUnusedFunction]
7070
return router
7171

7272

73-
def _values_completed(
74-
event: NoerglerCompleted, caller_team: str, raw: dict[str, Any]
73+
def _values_pr_completed(
74+
event: NoerglerPrCompleted, caller_team: str, raw: dict[str, Any]
7575
) -> dict[str, Any]:
76-
# run_id is unique per noergler review run, so it alone is a stable
77-
# idempotency key. Prefix with event_type so a future event re-using a
78-
# run_id can't collide.
79-
delivery_id = f"completed#{event.run_id}"
76+
# A PR has at most one terminal outcome, but Bitbucket can redeliver the
77+
# lifecycle webhook (e.g. retries). Idempotency keyed on (pr_key, outcome)
78+
# — including outcome in the key lets a deleted-after-decline edge case
79+
# land two distinct rows if the operator wires both events. pr_key is
80+
# lowercased to match the stored column so a casing-flipped redelivery
81+
# (PROJ/... vs proj/...) still dedupes.
82+
delivery_id = f"pr_completed#{lower(event.pr_key)}#{event.outcome}"
8083
return {
8184
"delivery_id": delivery_id,
82-
"event_type": "completed",
85+
"event_type": "pr_completed",
86+
"outcome": event.outcome,
8387
"pr_key": lower(event.pr_key),
8488
"repo": lower(event.repo),
85-
"commit_sha": lower(event.commit_sha),
86-
"run_id": event.run_id,
87-
"model": event.model,
88-
"prompt_tokens": event.prompt_tokens,
89-
"completion_tokens": event.completion_tokens,
90-
"elapsed_ms": event.elapsed_ms,
91-
"findings_count": event.findings_count,
92-
"cost_usd": event.cost_usd,
93-
"occurred_at": event.finished_at,
89+
"commit_sha": lower(event.source_commit_sha),
90+
"merge_commit_sha": lower(event.merge_commit_sha) if event.merge_commit_sha else None,
91+
"lines_added": event.lines_added,
92+
"lines_removed": event.lines_removed,
93+
"files_changed": event.files_changed,
94+
"total_runs": event.total_runs,
95+
"models_used": event.models_used,
96+
"prompt_tokens": event.total_prompt_tokens,
97+
"completion_tokens": event.total_completion_tokens,
98+
"elapsed_ms": event.total_elapsed_ms,
99+
"findings_count": event.total_findings_count,
100+
"cost_usd": event.total_cost_usd,
101+
"first_review_at": event.first_review_at,
102+
"occurred_at": event.closed_at,
94103
"team": caller_team,
95104
"payload": raw,
96105
}

src/riptide_collector/schemas/noergler.py

Lines changed: 68 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
"""Noergler PR-review payloads.
22
3-
Two event types, both keyed off review activity (not PR lifecycle — PR
4-
lifecycle already comes in via Bitbucket):
3+
Two event types:
54
6-
- `completed`: emitted after an LLM review run finishes. Carries the finops
7-
signal — model, token counts, elapsed time, cost.
5+
- `pr_completed`: emitted once per PR lifecycle (merged / declined / deleted).
6+
Carries the roll-up finops signal — aggregated tokens, elapsed time, cost,
7+
findings and the final PR diff-size — plus an `outcome` so consumers can
8+
filter for merged PRs vs. abandoned ones.
89
- `feedback`: emitted when a reviewer disagrees with or acknowledges a
9-
finding. Carries the reviewer-precision signal.
10+
finding. Reactive and not tied to the PR lifecycle.
1011
"""
1112

1213
from __future__ import annotations
@@ -15,7 +16,7 @@
1516
from decimal import Decimal
1617
from typing import Annotated, Literal
1718

18-
from pydantic import BaseModel, ConfigDict, Field, field_validator
19+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
1920

2021

2122
def _to_utc(value: datetime) -> datetime:
@@ -29,25 +30,72 @@ class _Common(BaseModel):
2930
model_config = ConfigDict(extra="forbid")
3031

3132

32-
class NoerglerCompleted(_Common):
33-
event_type: Literal["completed"]
33+
class NoerglerPrCompleted(_Common):
34+
event_type: Literal["pr_completed"]
35+
outcome: Literal["merged", "declined", "deleted"] = Field(
36+
...,
37+
description=(
38+
"PR lifecycle outcome. Only 'merged' PRs reach production; "
39+
"'declined' and 'deleted' incurred LLM cost without shipping code "
40+
"and must be filtered out of throughput / DORA metrics."
41+
),
42+
)
3443
pr_key: str = Field(..., min_length=1, description="Bitbucket PR key, e.g. 'PROJ/repo#42'")
3544
repo: str = Field(..., min_length=1)
36-
commit_sha: str = Field(..., min_length=7)
37-
run_id: str = Field(..., min_length=1, description="noergler review-run id (idempotency key)")
38-
model: str = Field(..., min_length=1, description="LLM identifier, e.g. 'gpt-4o-2024-08-06'")
39-
prompt_tokens: int = Field(..., ge=0)
40-
completion_tokens: int = Field(..., ge=0)
41-
elapsed_ms: int = Field(..., ge=0)
42-
findings_count: int = Field(..., ge=0)
43-
cost_usd: Decimal = Field(..., ge=0)
44-
finished_at: datetime
45-
46-
@field_validator("finished_at")
45+
source_commit_sha: str = Field(
46+
...,
47+
min_length=7,
48+
description="Last reviewed source-branch commit (HEAD of fromRef when the PR closed).",
49+
)
50+
merge_commit_sha: str | None = Field(
51+
default=None,
52+
min_length=7,
53+
description="Merge commit SHA. Set only when outcome='merged'.",
54+
)
55+
lines_added: int = Field(..., ge=0, description="Final cumulative PR diff: lines added.")
56+
lines_removed: int = Field(..., ge=0, description="Final cumulative PR diff: lines removed.")
57+
files_changed: int = Field(..., ge=0, description="Final cumulative PR diff: files changed.")
58+
total_runs: int = Field(..., ge=1, description="Number of review runs aggregated.")
59+
total_prompt_tokens: int = Field(..., ge=0)
60+
total_completion_tokens: int = Field(..., ge=0)
61+
total_elapsed_ms: int = Field(..., ge=0)
62+
total_findings_count: int = Field(..., ge=0)
63+
total_cost_usd: Decimal = Field(..., ge=0)
64+
models_used: list[str] = Field(
65+
...,
66+
min_length=1,
67+
description="Distinct LLM identifiers used across the PR's review runs.",
68+
)
69+
first_review_at: datetime
70+
closed_at: datetime = Field(
71+
...,
72+
description="Timestamp the PR reached its terminal outcome (merged/declined/deleted).",
73+
)
74+
75+
@field_validator("first_review_at", "closed_at")
4776
@classmethod
4877
def _normalise_tz(cls, v: datetime) -> datetime:
4978
return _to_utc(v)
5079

80+
@field_validator("models_used")
81+
@classmethod
82+
def _models_non_empty(cls, v: list[str]) -> list[str]:
83+
if any(not m.strip() for m in v):
84+
raise ValueError("models_used entries must be non-empty strings")
85+
return v
86+
87+
@model_validator(mode="after")
88+
def _check_merge_commit_consistency(self) -> NoerglerPrCompleted:
89+
# Catch sender bugs: only merged PRs produce a merge commit, and a
90+
# merged PR is exactly the case where the sender must supply one.
91+
# Without this, "outcome=declined + merge_commit_sha=<sha>" would
92+
# silently land in the DB.
93+
if self.outcome == "merged" and not self.merge_commit_sha:
94+
raise ValueError("merge_commit_sha is required when outcome='merged'")
95+
if self.outcome != "merged" and self.merge_commit_sha is not None:
96+
raise ValueError(f"merge_commit_sha must be null when outcome='{self.outcome}'")
97+
return self
98+
5199

52100
class NoerglerFeedback(_Common):
53101
event_type: Literal["feedback"]
@@ -83,6 +131,6 @@ def _normalise_tz(cls, v: datetime) -> datetime:
83131

84132

85133
NoerglerWebhook = Annotated[
86-
NoerglerCompleted | NoerglerFeedback,
134+
NoerglerPrCompleted | NoerglerFeedback,
87135
Field(discriminator="event_type"),
88136
]

tests/fixtures/noergler_completed.json

Lines changed: 0 additions & 14 deletions
This file was deleted.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"event_type": "pr_completed",
3+
"outcome": "declined",
4+
"pr_key": "PROJ/payments-api#43",
5+
"repo": "acme/payments-api",
6+
"source_commit_sha": "f00ba1234567890abc1234567890abc123456789",
7+
"lines_added": 40,
8+
"lines_removed": 5,
9+
"files_changed": 2,
10+
"total_runs": 1,
11+
"total_prompt_tokens": 4200,
12+
"total_completion_tokens": 180,
13+
"total_elapsed_ms": 3100,
14+
"total_findings_count": 2,
15+
"total_cost_usd": "0.042000",
16+
"models_used": ["gpt-4o-2024-08-06"],
17+
"first_review_at": "2026-04-30T09:10:00Z",
18+
"closed_at": "2026-04-30T09:25:00Z"
19+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
{
2+
"event_type": "pr_completed",
3+
"outcome": "deleted",
4+
"pr_key": "PROJ/payments-api#44",
5+
"repo": "acme/payments-api",
6+
"source_commit_sha": "deadbeef1234567890abc1234567890abc123456",
7+
"lines_added": 0,
8+
"lines_removed": 0,
9+
"files_changed": 0,
10+
"total_runs": 1,
11+
"total_prompt_tokens": 1500,
12+
"total_completion_tokens": 60,
13+
"total_elapsed_ms": 1700,
14+
"total_findings_count": 0,
15+
"total_cost_usd": "0.015000",
16+
"models_used": ["gpt-4o-2024-08-06"],
17+
"first_review_at": "2026-05-01T08:00:00Z",
18+
"closed_at": "2026-05-01T08:05:00Z"
19+
}

0 commit comments

Comments
 (0)