Skip to content

Commit 536186e

Browse files
authored
feat(bitbucket): wire DX Core 4 'code review pickup time' signals (#57)
* feat(bitbucket): wire DX Core 4 'code review pickup time' signals The README cites DX Core 4 'code review pickup time' as a derivable metric but the inputs weren't all present: - Only `pr:comment:added` was subscribed. DX defines pickup as the first ANY reviewer action — silent approvals and `needs_work` flips never showed up, and the bot comment noergler posts seconds after pr:opened would have driven every PR's pickup-time to ~0. - For reviewer-activity events the parser was filling `author` from `pullRequest.author` (the PR opener) instead of `actor` (the reviewer). That meant the `author != pr_opener` filter couldn't ever fire. This PR: - Adds `pr:reviewer:approved`, `pr:reviewer:needs_work`, and `pr:reviewer:updated` to the Bitbucket onboarding event list. Together with the existing `pr:comment:added` they cover every reviewer action Bitbucket DC emits. - Teaches the parser that comment / reviewer events are reviewer-activity: it now picks `actor` (the human or bot performing the action) over `pullRequest.author` for those event types only. PR-lifecycle events (opened / merged / from_ref_updated / deleted) keep the existing PR author semantics; a regression test pins this. - Adds a `noergler` entry to the example automation config so `detect_automation_source` flags noergler's instant review comment as `is_automated=true`. The existing `NOT is_automated` filter in pickup-time queries then strips it. The README explicitly calls out that every review-time bot needs a matching automation entry. Tests cover: actor-as-author for `pr:reviewer:approved` and `pr:comment:added`, regression guard that `pr:merged` still attributes to the PR opener (not the merger), and that the config detection picks up the noergler entry. * review(#57): subscribe pr:reviewer:unapproved, dedicated comment fixture - Subscribe pr:reviewer:unapproved and add it to the pickup-time query. The parser already treated it as reviewer activity but the event was never delivered (not in REQUIRED_WEBHOOK_EVENTS) and never queried, so the parser branch was dead. A retracted approval is reviewer engagement too — same actor-wins rule applies. - New tests/fixtures/bitbucket_pr_comment_added.json so the comment-route test doesn't reuse the reviewer:approved fixture with a swapped eventKey (which left a stale REVIEWER 'participant' block in the payload). - New test for pr:reviewer:unapproved (actor-as-author). - New test for the edge case where pullRequest.author is an empty block: the actor-wins path covers it cleanly, no None author. branch_prefixes:[] vs missing key concern (raised in review) confirmed to be semantically identical in config.py: tuple(cfg.get("branch_prefixes") or []).
1 parent 22b2e5f commit 536186e

8 files changed

Lines changed: 238 additions & 4 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ the data captured in v1.
6363
| **Deployment frequency** | `COUNT(*)` of `argocd_events` per `app_name` / `team` / time window where `operation_phase = 'Succeeded' AND environment = 'prod'`. Drop the `environment` filter (or slice by it) for staging visibility. |
6464
| **Lead time for changes** | For each merged PR, `MIN(bitbucket_events.occurred_at)` for the PR (first commit) → `argocd_events.occurred_at` of the prod deploy that carries the same `commit_sha` and `environment = 'prod'`. Joined via the SHA. Stratify by `bitbucket_events.change_type` (feature / hotfix / bugfix / …) to see hotfix lead time vs. feature lead time separately. |
6565
| **PR cycle time** | `pullrequest:fulfilled.occurred_at − pullrequest:created.occurred_at` per PR id. |
66-
| **Time to first review** *(DX Core 4 "code review pickup time")* | First reviewer event timestamp − PR-created timestamp on `bitbucket_events`. |
66+
| **Time to first review** *(DX Core 4 "code review pickup time")* | `MIN(occurred_at) WHERE event_type IN ('pr:comment:added', 'pr:reviewer:approved', 'pr:reviewer:unapproved', 'pr:reviewer:needs_work', 'pr:reviewer:updated') AND author != <pr_opener_handle> AND NOT is_automated` per PR, minus the matching `pr:opened` row. The five-event union covers every reviewer touch Bitbucket DC emits — silent approvals, retracted approvals, "needs work" flips, and bare reviewer-status changes; `NOT is_automated` strips bot comments (noergler / Renovate / etc.) — every review-time bot must have its handle in the `automation` config block, otherwise its instant comment drives the metric toward zero. |
6767
| **Build success rate** | `pipeline_events` with `phase = 'COMPLETED'` grouped by `status`. Slice by `source` to compare Jenkins vs Tekton, by `pipeline_name` / `team` for ownership. |
6868
| **Build duration** | `pipeline_events.duration_seconds` (a Postgres `GENERATED ALWAYS AS (finished_at − started_at)` column). |
6969
| **Deploy success rate** | `argocd_events` with `operation_phase IN ('Succeeded', 'Failed')` aggregated, filtered to `environment = 'prod'` for the prod-only view. |

openshift/collector/riptide.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
"mend": {
3030
"authors": ["mend-bot", "mend[bot]"],
3131
"branch_prefixes": ["whitesource-"]
32+
},
33+
"noergler": {
34+
"authors": ["noergler"],
35+
"branch_prefixes": []
3236
}
3337
}
3438
}

scripts/bitbucket_onboarding.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,24 @@
6060
DEFAULT_WEBHOOK_NAME = "riptide"
6161

6262
# Events riptide's bitbucket router (src/riptide_collector/routers/bitbucket.py)
63-
# can extract data from: PR lifecycle + push (for revert detection in
63+
# can extract data from: PR lifecycle, reviewer activity (for DX Core 4
64+
# "code review pickup time"), and push (for revert detection in
6465
# `push.changes[]`).
66+
#
67+
# Pickup time per DX Core 4 = MIN(timestamp) across the first reviewer
68+
# action of any kind. A single signal (e.g. only `pr:comment:added`) is
69+
# not enough — reviewers often approve silently without typing anything,
70+
# and a comment-only signal both misses those and inflates the metric
71+
# with bot comments. The four reviewer-activity events here together
72+
# cover the workflows BBS DC reviewers actually use.
6573
REQUIRED_WEBHOOK_EVENTS: tuple[str, ...] = (
6674
"pr:opened",
6775
"pr:from_ref_updated",
6876
"pr:comment:added",
77+
"pr:reviewer:approved",
78+
"pr:reviewer:unapproved",
79+
"pr:reviewer:needs_work",
80+
"pr:reviewer:updated",
6981
"pr:merged",
7082
"pr:deleted",
7183
"repo:refs_changed",

src/riptide_collector/parsers_bitbucket.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@
1616
parse_change_type,
1717
)
1818

19+
# Events where the meaningful actor is the reviewer/commenter, not the
20+
# PR author. Used to power the DX Core 4 "code review pickup time" metric:
21+
# MIN(occurred_at WHERE event_type IN these AND author != pr_opener)
22+
# answers "when did someone other than the PR author engage with the PR".
23+
_REVIEWER_ACTIVITY_EVENTS = frozenset(
24+
{
25+
"pr:comment:added",
26+
"pr:reviewer:approved",
27+
"pr:reviewer:needs_work",
28+
"pr:reviewer:updated",
29+
"pr:reviewer:unapproved",
30+
}
31+
)
32+
1933

2034
@dataclass(frozen=True)
2135
class BitbucketEventDraft:
@@ -151,6 +165,13 @@ def extract_event(
151165
author: str | None = None
152166
is_revert = False
153167

168+
# Reviewer-activity events carry the actor (the reviewer / commenter)
169+
# as the meaningful "who did this" — different from pr.author who
170+
# opened the PR. We need that to attribute the "first review pickup"
171+
# signal (DX Core 4) to the right user and to filter out the
172+
# PR-author-self-comment and bot-comment noise.
173+
is_reviewer_activity = event_type in _REVIEWER_ACTIVITY_EVENTS
174+
154175
if pr:
155176
from_ref = _as_dict(pr.get("fromRef"))
156177
display_id = from_ref.get("displayId")
@@ -159,8 +180,9 @@ def extract_event(
159180
branch_name = display_id
160181
if isinstance(latest_commit, str):
161182
commit_sha = latest_commit
162-
author_user = _as_dict(_as_dict(pr.get("author")).get("user"))
163-
author = _user_handle(author_user)
183+
if not is_reviewer_activity:
184+
author_user = _as_dict(_as_dict(pr.get("author")).get("user"))
185+
author = _user_handle(author_user)
164186
# PR-side revert detection: the title is the only signal we have
165187
# without a REST round-trip. Push-side detection would need the
166188
# commit messages between fromHash..toHash.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"eventKey": "pr:comment:added",
3+
"date": "2026-04-28T10:05:00+0000",
4+
"actor": {
5+
"name": "bob",
6+
"displayName": "Bob Reviewer",
7+
"slug": "bob"
8+
},
9+
"pullRequest": {
10+
"id": 42,
11+
"title": "ABC-123: Add payment retry",
12+
"description": "Fixes ABC-123 and PROJ-9",
13+
"state": "OPEN",
14+
"fromRef": {
15+
"id": "refs/heads/feature/ABC-123-retries",
16+
"displayId": "feature/ABC-123-retries",
17+
"latestCommit": "abc1234567890abc1234567890abc1234567890a",
18+
"repository": {
19+
"slug": "payments-api",
20+
"project": {"key": "ACME"}
21+
}
22+
},
23+
"toRef": {
24+
"id": "refs/heads/master",
25+
"displayId": "master",
26+
"repository": {
27+
"slug": "payments-api",
28+
"project": {"key": "ACME"}
29+
}
30+
},
31+
"author": {
32+
"user": {
33+
"name": "alice",
34+
"displayName": "Alice Example",
35+
"slug": "alice"
36+
}
37+
}
38+
},
39+
"comment": {
40+
"id": 1001,
41+
"text": "Could you split the retry policy into its own helper?",
42+
"author": {
43+
"name": "bob",
44+
"displayName": "Bob Reviewer",
45+
"slug": "bob"
46+
}
47+
}
48+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
{
2+
"eventKey": "pr:reviewer:approved",
3+
"date": "2026-04-28T10:05:00+0000",
4+
"actor": {
5+
"name": "bob",
6+
"displayName": "Bob Reviewer",
7+
"slug": "bob"
8+
},
9+
"pullRequest": {
10+
"id": 42,
11+
"title": "ABC-123: Add payment retry",
12+
"description": "Fixes ABC-123 and PROJ-9",
13+
"state": "OPEN",
14+
"fromRef": {
15+
"id": "refs/heads/feature/ABC-123-retries",
16+
"displayId": "feature/ABC-123-retries",
17+
"latestCommit": "abc1234567890abc1234567890abc1234567890a",
18+
"repository": {
19+
"slug": "payments-api",
20+
"project": {"key": "ACME"}
21+
}
22+
},
23+
"toRef": {
24+
"id": "refs/heads/master",
25+
"displayId": "master",
26+
"repository": {
27+
"slug": "payments-api",
28+
"project": {"key": "ACME"}
29+
}
30+
},
31+
"author": {
32+
"user": {
33+
"name": "alice",
34+
"displayName": "Alice Example",
35+
"slug": "alice"
36+
}
37+
}
38+
},
39+
"participant": {
40+
"user": {
41+
"name": "bob",
42+
"displayName": "Bob Reviewer",
43+
"slug": "bob"
44+
},
45+
"role": "REVIEWER",
46+
"approved": true,
47+
"status": "APPROVED"
48+
},
49+
"previousStatus": "UNAPPROVED"
50+
}

tests/test_config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,18 @@ def test_human_returns_none(self, tmp_path: Path) -> None:
127127
store = RiptideConfigStore(path)
128128
assert store.detect_automation_source("alice", "feature/x") is None
129129

130+
def test_review_bot_author_match(self, tmp_path: Path) -> None:
131+
# Review-time bots (e.g. noergler) need a config entry to be
132+
# recognised because their handle ("noergler") doesn't match the
133+
# `*-bot` / `*[bot]` heuristic. With the entry in place, comment
134+
# rows authored by noergler land with is_automated=true and the
135+
# DX Core 4 pickup-time query filters them out.
136+
data = json.loads(json.dumps(VALID))
137+
data["automation"]["noergler"] = {"authors": ["noergler"], "branch_prefixes": []}
138+
path = _write(tmp_path / "c.json", data)
139+
store = RiptideConfigStore(path)
140+
assert store.detect_automation_source("noergler", None) == "noergler"
141+
130142

131143
class TestEnvironments:
132144
def test_defaults_when_block_absent(self, tmp_path: Path) -> None:

tests/test_parsers_bitbucket.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,92 @@ def test_falls_back_to_actor_when_pr_author_missing(self) -> None:
192192
assert isinstance(result, BitbucketEventDraft)
193193
assert result.author == "alice"
194194

195+
def test_reviewer_event_uses_actor_not_pr_author(self) -> None:
196+
# Given — pr:reviewer:approved fixture has pullRequest.author=alice
197+
# and top-level actor=bob (the reviewer doing the approval).
198+
body = _load("bitbucket_pr_reviewer_approved.json")
199+
200+
# When
201+
result = extract_event(
202+
body,
203+
x_event_key="pr:reviewer:approved",
204+
x_request_uuid="r",
205+
x_hook_uuid=None,
206+
)
207+
208+
# Then — for review-pickup analytics we want the reviewer's handle,
209+
# not the PR opener's; the parser must prefer actor for these events.
210+
assert isinstance(result, BitbucketEventDraft)
211+
assert result.author == "bob"
212+
# pr_id still extracted from the payload so feedback joins back
213+
# to the opener via bitbucket_events rows for the same pr_id.
214+
assert result.pr_id == 42
215+
216+
def test_comment_added_uses_actor_not_pr_author(self) -> None:
217+
# Same rule applies to pr:comment:added — the commenter is the
218+
# signal, not the PR opener. Dedicated fixture (not the reviewer
219+
# fixture with a swapped eventKey) so the shape stays honest.
220+
body = _load("bitbucket_pr_comment_added.json")
221+
222+
result = extract_event(
223+
body,
224+
x_event_key="pr:comment:added",
225+
x_request_uuid="r",
226+
x_hook_uuid=None,
227+
)
228+
229+
assert isinstance(result, BitbucketEventDraft)
230+
assert result.author == "bob"
231+
232+
def test_reviewer_unapproved_uses_actor_not_pr_author(self) -> None:
233+
# 'pr:reviewer:unapproved' (retracted approval) is also reviewer
234+
# engagement and feeds the pickup-time metric. Same actor-wins rule.
235+
body = _load("bitbucket_pr_reviewer_approved.json")
236+
237+
result = extract_event(
238+
body,
239+
x_event_key="pr:reviewer:unapproved",
240+
x_request_uuid="r",
241+
x_hook_uuid=None,
242+
)
243+
244+
assert isinstance(result, BitbucketEventDraft)
245+
assert result.author == "bob"
246+
assert result.event_type == "pr:reviewer:unapproved"
247+
248+
def test_reviewer_event_falls_back_to_actor_when_pr_author_missing(self) -> None:
249+
# Edge case: a Bitbucket DC payload without pullRequest.author (or
250+
# with a broken/empty author block). The reviewer-activity path
251+
# already prefers actor; this confirms the regular actor fallback
252+
# at the end of extract_event still works as a backstop and we
253+
# don't end up with author=None.
254+
body = _load("bitbucket_pr_reviewer_approved.json")
255+
body["pullRequest"]["author"] = {}
256+
257+
result = extract_event(
258+
body,
259+
x_event_key="pr:reviewer:approved",
260+
x_request_uuid="r",
261+
x_hook_uuid=None,
262+
)
263+
264+
assert isinstance(result, BitbucketEventDraft)
265+
assert result.author == "bob"
266+
267+
def test_pr_opened_keeps_pr_author_not_actor(self) -> None:
268+
# Regression guard: for PR-lifecycle events (opened / merged /
269+
# from_ref_updated / deleted) we still want the PR author, even
270+
# if a maintainer (different actor) triggered the event.
271+
body = _load("bitbucket_pr_merged.json")
272+
body["actor"] = {"name": "carol", "slug": "carol"}
273+
274+
result = extract_event(body, x_event_key="pr:merged", x_request_uuid="r", x_hook_uuid=None)
275+
276+
assert isinstance(result, BitbucketEventDraft)
277+
# alice opened the PR; carol merged it. The historical row should
278+
# still attribute the PR to alice.
279+
assert result.author == "alice"
280+
195281

196282
class TestEventTypeAndOccurredAt:
197283
def test_event_type_defaults_to_unknown_when_header_missing(self) -> None:

0 commit comments

Comments
 (0)