glab-cli-extras 0.1.0__py3-none-any.whl

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.
Files changed (44) hide show
  1. glab_cli_extras/__init__.py +3 -0
  2. glab_cli_extras/artifacts/__init__.py +0 -0
  3. glab_cli_extras/artifacts/job_logs.py +95 -0
  4. glab_cli_extras/artifacts/threads.py +147 -0
  5. glab_cli_extras/cli/__init__.py +0 -0
  6. glab_cli_extras/cli/watch.py +426 -0
  7. glab_cli_extras/daemon/__init__.py +0 -0
  8. glab_cli_extras/daemon/__main__.py +10 -0
  9. glab_cli_extras/daemon/engine.py +491 -0
  10. glab_cli_extras/daemon/lifecycle.py +186 -0
  11. glab_cli_extras/daemon/locks.py +85 -0
  12. glab_cli_extras/daemon/scheduler.py +52 -0
  13. glab_cli_extras/daemon/server.py +79 -0
  14. glab_cli_extras/daemon/transport.py +185 -0
  15. glab_cli_extras/events.py +165 -0
  16. glab_cli_extras/gitlab/__init__.py +0 -0
  17. glab_cli_extras/gitlab/budget.py +135 -0
  18. glab_cli_extras/gitlab/client.py +225 -0
  19. glab_cli_extras/gitlab/glab.py +175 -0
  20. glab_cli_extras/gitlab/queries/__init__.py +11 -0
  21. glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
  22. glab_cli_extras/gitlab/queries/mr.graphql +42 -0
  23. glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
  24. glab_cli_extras/gitlab/refs.py +151 -0
  25. glab_cli_extras/paths.py +79 -0
  26. glab_cli_extras/py.typed +0 -0
  27. glab_cli_extras/resources/__init__.py +19 -0
  28. glab_cli_extras/resources/base.py +146 -0
  29. glab_cli_extras/resources/branch.py +265 -0
  30. glab_cli_extras/resources/mr.py +445 -0
  31. glab_cli_extras/resources/mr_list.py +150 -0
  32. glab_cli_extras/resources/pipeline.py +319 -0
  33. glab_cli_extras/serde.py +75 -0
  34. glab_cli_extras/watch/__init__.py +0 -0
  35. glab_cli_extras/watch/changelog.py +86 -0
  36. glab_cli_extras/watch/claude.py +193 -0
  37. glab_cli_extras/watch/client.py +316 -0
  38. glab_cli_extras/watch/sinks.py +83 -0
  39. glab_cli_extras/watch/streams.py +80 -0
  40. glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
  41. glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
  42. glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
  43. glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
  44. glab_cli_extras-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,445 @@
1
+ """Merge request watching: one GraphQL snapshot per poll, follow-up requests only for what moved.
2
+
3
+ - A moved `diffHeadSha` is a push. One `merge_base` request tells a fast-forward from a force-push.
4
+ - A thread whose notes changed (`updatedAt` moves on edit, resolve and unresolve) is re-fetched alone and its file
5
+ rewritten. Many changed threads at once are fetched with one list request instead.
6
+ - The head pipeline is a separate, shared `pipeline` resource; the daemon relays its events into the MR's stream.
7
+
8
+ The MR's own `updated_at` is not used: GitLab skips touching it within a minute of the last touch, and head pipeline
9
+ changes and single resolves never touch it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from dataclasses import dataclass, replace
16
+ from typing import Any, ClassVar
17
+
18
+ from glab_cli_extras.artifacts.threads import ThreadStore, is_system_discussion
19
+ from glab_cli_extras.events import GROUPS, PIPELINE_KINDS, RawEvent
20
+ from glab_cli_extras.gitlab import queries
21
+ from glab_cli_extras.gitlab.client import NotFound, encode_project
22
+ from glab_cli_extras.gitlab.refs import ResourceRef
23
+ from glab_cli_extras.paths import project_dir
24
+ from glab_cli_extras.resources.base import Poller, PollerOptions, gid_to_int, is_force_push, lower
25
+ from glab_cli_extras.serde import stable_hash
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+ TRANSIENT_MERGE_STATUSES = frozenset({"checking", "unchecked", "preparing"})
30
+ """GitLab reports these while it recomputes mergeability; the last settled value stays until a new one arrives."""
31
+ TERMINAL_STATES = frozenset({"merged", "closed"})
32
+ THREAD_LIST_THRESHOLD = 3
33
+ """Above this many changed threads, one paginated list request is cheaper than one request per thread."""
34
+ INTERVAL = 30.0
35
+ INTERVAL_PREMIUM = 15.0
36
+ MAX_THREAD_PAGES = 10
37
+
38
+ COMMENT_KINDS = GROUPS["comments"]
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class NoteState:
43
+ id: int
44
+ updated_at: str
45
+ author: str | None
46
+
47
+
48
+ @dataclass(frozen=True, slots=True)
49
+ class ThreadState:
50
+ id: str
51
+ resolvable: bool
52
+ resolved: bool
53
+ notes: tuple[NoteState, ...]
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class MrSnapshot:
58
+ iid: int
59
+ project: str
60
+ state: str
61
+ title: str
62
+ description_hash: str
63
+ draft: bool
64
+ merge_status: str | None
65
+ head_sha: str | None
66
+ source_branch: str | None
67
+ target_branch: str | None
68
+ web_url: str | None
69
+ approved: bool | None
70
+ approvals_left: int | None
71
+ approved_by: tuple[str, ...]
72
+ reviewers: tuple[str, ...]
73
+ head_pipeline_id: int | None = None
74
+ head_pipeline_status: str | None = None
75
+ head_pipeline_project: str | None = None
76
+ threads: tuple[ThreadState, ...] | None = None
77
+ """None when no subscriber wants comment events, so the query leaves discussions out."""
78
+
79
+
80
+ def _discussion_id(gid: Any) -> str:
81
+ """GraphQL `gid://gitlab/Discussion/<sha>` → the REST discussion id `<sha>`."""
82
+ return str(gid).rsplit("/", 1)[-1]
83
+
84
+
85
+ def parse_threads(nodes: list[Any]) -> tuple[ThreadState, ...]:
86
+ threads = []
87
+ for node in nodes:
88
+ notes = (node.get("notes") or {}).get("nodes") or []
89
+ if notes and all(n.get("system") for n in notes):
90
+ continue
91
+ threads.append(
92
+ ThreadState(
93
+ id=_discussion_id(node["id"]),
94
+ resolvable=bool(node.get("resolvable")),
95
+ resolved=bool(node.get("resolved")),
96
+ notes=tuple(
97
+ NoteState(
98
+ id=gid_to_int(n["id"]),
99
+ updated_at=str(n.get("updatedAt") or ""),
100
+ author=(n.get("author") or {}).get("username"),
101
+ )
102
+ for n in notes
103
+ if not n.get("system")
104
+ ),
105
+ )
106
+ )
107
+ return tuple(sorted(threads, key=lambda t: t.id))
108
+
109
+
110
+ def parse_mr(node: Any, project: str, threads: tuple[ThreadState, ...] | None) -> MrSnapshot:
111
+ if not node:
112
+ raise NotFound(f"merge request not found in {project}", 404)
113
+ pipeline = node.get("headPipeline") or None
114
+ return MrSnapshot(
115
+ iid=int(node["iid"]),
116
+ project=project,
117
+ state=lower(node.get("state")),
118
+ title=str(node.get("title") or ""),
119
+ description_hash=stable_hash(str(node.get("description") or "")),
120
+ draft=bool(node.get("draft")),
121
+ merge_status=lower(node.get("detailedMergeStatus")) or None,
122
+ head_sha=node.get("diffHeadSha"),
123
+ source_branch=node.get("sourceBranch"),
124
+ target_branch=node.get("targetBranch"),
125
+ web_url=node.get("webUrl"),
126
+ approved=node.get("approved"),
127
+ approvals_left=node.get("approvalsLeft"),
128
+ approved_by=tuple(sorted(n["username"] for n in (node.get("approvedBy") or {}).get("nodes") or [])),
129
+ reviewers=tuple(sorted(n["username"] for n in (node.get("reviewers") or {}).get("nodes") or [])),
130
+ head_pipeline_id=gid_to_int(pipeline["id"]) if pipeline else None,
131
+ head_pipeline_status=lower(pipeline.get("status")) if pipeline else None,
132
+ head_pipeline_project=((pipeline.get("project") or {}).get("fullPath") or project) if pipeline else None,
133
+ threads=threads,
134
+ )
135
+
136
+
137
+ def settle(prev: MrSnapshot | None, cur: MrSnapshot) -> MrSnapshot:
138
+ """Keep the last settled merge status while GitLab reports a transient one."""
139
+ if prev is not None and cur.merge_status in TRANSIENT_MERGE_STATUSES:
140
+ return replace(cur, merge_status=prev.merge_status)
141
+ return cur
142
+
143
+
144
+ def _set_diff(old: tuple[str, ...], new: tuple[str, ...]) -> tuple[list[str], list[str]]:
145
+ return sorted(set(new) - set(old)), sorted(set(old) - set(new))
146
+
147
+
148
+ def diff_threads(prev: tuple[ThreadState, ...], cur: tuple[ThreadState, ...]) -> list[RawEvent]:
149
+ events: list[RawEvent] = []
150
+ old_threads = {t.id: t for t in prev}
151
+ for thread in cur:
152
+ old = old_threads.get(thread.id)
153
+ if old is None:
154
+ first = thread.notes[0] if thread.notes else None
155
+ events.append(
156
+ RawEvent(
157
+ "thread_created",
158
+ {
159
+ "discussion_id": thread.id,
160
+ "author": first.author if first else None,
161
+ "resolvable": thread.resolvable,
162
+ "resolved": thread.resolved,
163
+ "notes": len(thread.notes),
164
+ },
165
+ )
166
+ )
167
+ continue
168
+ old_notes = {n.id: n for n in old.notes}
169
+ new_notes = [n for n in thread.notes if n.id not in old_notes]
170
+ resolution_changed = old.resolved != thread.resolved
171
+ edited = [n for n in thread.notes if n.id in old_notes and old_notes[n.id].updated_at != n.updated_at]
172
+ removed = len(set(old_notes) - {n.id for n in thread.notes})
173
+ # Resolving bumps `updatedAt` on every note of the thread, so an edit only counts without a resolve.
174
+ if new_notes or removed or (edited and not resolution_changed):
175
+ last = thread.notes[-1] if thread.notes else None
176
+ events.append(
177
+ RawEvent(
178
+ "thread_updated",
179
+ {
180
+ "discussion_id": thread.id,
181
+ "new_notes": len(new_notes),
182
+ "edited_notes": 0 if resolution_changed else len(edited),
183
+ "removed_notes": removed,
184
+ "last_author": last.author if last else None,
185
+ "resolved": thread.resolved,
186
+ },
187
+ )
188
+ )
189
+ if resolution_changed:
190
+ kind = "thread_resolved" if thread.resolved else "thread_unresolved"
191
+ events.append(RawEvent(kind, {"discussion_id": thread.id}))
192
+ return events
193
+
194
+
195
+ def diff_mr(prev: MrSnapshot, cur: MrSnapshot) -> list[RawEvent]:
196
+ """Events between two settled snapshots of one MR, in a stable order."""
197
+ events: list[RawEvent] = []
198
+ if prev.state != cur.state:
199
+ data: dict[str, Any] = {"from": prev.state, "to": cur.state}
200
+ if prev.state == "closed" and cur.state == "opened":
201
+ data["reopened"] = True
202
+ events.append(RawEvent("state_changed", data))
203
+ if prev.draft != cur.draft:
204
+ events.append(RawEvent("draft_changed", {"draft": cur.draft}))
205
+ if prev.title != cur.title:
206
+ events.append(RawEvent("title_changed", {"from": prev.title, "to": cur.title}))
207
+ if prev.description_hash != cur.description_hash:
208
+ events.append(RawEvent("description_changed", {}))
209
+ if prev.head_sha != cur.head_sha and cur.head_sha:
210
+ events.append(RawEvent("pushed", {"from": prev.head_sha, "to": cur.head_sha}))
211
+ if prev.merge_status != cur.merge_status and cur.merge_status:
212
+ events.append(RawEvent("merge_status_changed", {"from": prev.merge_status, "to": cur.merge_status}))
213
+ if (prev.approved_by, prev.approvals_left, prev.approved) != (cur.approved_by, cur.approvals_left, cur.approved):
214
+ added, removed = _set_diff(prev.approved_by, cur.approved_by)
215
+ events.append(
216
+ RawEvent(
217
+ "approvals_changed",
218
+ {
219
+ "approved": cur.approved,
220
+ "approvals_left": cur.approvals_left,
221
+ "approved_by": list(cur.approved_by),
222
+ "added": added,
223
+ "removed": removed,
224
+ },
225
+ )
226
+ )
227
+ if prev.reviewers != cur.reviewers:
228
+ added, removed = _set_diff(prev.reviewers, cur.reviewers)
229
+ events.append(
230
+ RawEvent("reviewers_changed", {"reviewers": list(cur.reviewers), "added": added, "removed": removed})
231
+ )
232
+ if prev.head_pipeline_id != cur.head_pipeline_id and cur.head_pipeline_id is not None:
233
+ events.append(
234
+ RawEvent(
235
+ "head_pipeline_changed",
236
+ {
237
+ "pipeline_id": cur.head_pipeline_id,
238
+ "status": cur.head_pipeline_status,
239
+ "previous_pipeline_id": prev.head_pipeline_id,
240
+ },
241
+ )
242
+ )
243
+ if prev.threads is not None and cur.threads is not None:
244
+ events.extend(diff_threads(prev.threads, cur.threads))
245
+ return events
246
+
247
+
248
+ class MrPoller(Poller[MrSnapshot]):
249
+ kind: ClassVar = "mr"
250
+ snapshot_type: ClassVar = MrSnapshot
251
+
252
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
253
+ super().__init__(*args, **kwargs)
254
+ self._threads: ThreadStore | None = None
255
+
256
+ @property
257
+ def _wants_threads(self) -> bool:
258
+ return self.options.wants(*COMMENT_KINDS)
259
+
260
+ @property
261
+ def _wants_pipeline(self) -> bool:
262
+ return self.options.wants("head_pipeline_changed", *PIPELINE_KINDS)
263
+
264
+ @property
265
+ def _encoded(self) -> str:
266
+ return encode_project(self.ref.project)
267
+
268
+ def thread_store(self, web_url: str | None) -> ThreadStore:
269
+ if self._threads is None:
270
+ directory = (
271
+ project_dir(self.ctx.cache_root, self.ref.host, self.ref.project) / f"mr-{self.ref.id}" / "threads"
272
+ )
273
+ fallback = f"https://{self.ref.host}/{self.ref.project}/-/merge_requests/{self.ref.id}"
274
+ self._threads = ThreadStore(directory, web_url or fallback)
275
+ return self._threads
276
+
277
+ async def _fetch_snapshot(self) -> MrSnapshot:
278
+ with_threads = self._wants_threads
279
+ variables: dict[str, str | int | bool] = {
280
+ "project": self.ref.project,
281
+ "iid": str(self.ref.id),
282
+ "withThreads": with_threads,
283
+ "withPipeline": self._wants_pipeline,
284
+ }
285
+ result = await self.ctx.client.graphql(queries.load("mr"), variables, kind="mr")
286
+ node = ((result.data or {}).get("project") or {}).get("mergeRequest")
287
+ if not node:
288
+ raise NotFound(f"merge request !{self.ref.id} not found in {self.ref.project}", 404)
289
+ threads: tuple[ThreadState, ...] | None = None
290
+ if with_threads:
291
+ connection = node.get("discussions") or {}
292
+ nodes = list(connection.get("nodes") or [])
293
+ pages = 1
294
+ while (connection.get("pageInfo") or {}).get("hasNextPage") and pages < MAX_THREAD_PAGES:
295
+ variables["threadsAfter"] = str(connection["pageInfo"]["endCursor"])
296
+ more = await self.ctx.client.graphql(queries.load("mr"), variables, kind="mr")
297
+ connection = (
298
+ (((more.data or {}).get("project") or {}).get("mergeRequest") or {}).get("discussions")
299
+ ) or {}
300
+ nodes.extend(connection.get("nodes") or [])
301
+ pages += 1
302
+ threads = parse_threads(nodes)
303
+ return parse_mr(node, self.ref.project, threads)
304
+
305
+ async def _is_force_push(self, old_sha: str, new_sha: str) -> tuple[bool, str | None]:
306
+ return await is_force_push(self.ctx.client, self.ref.project, old_sha, new_sha)
307
+
308
+ async def _sync_all_threads(self, store: ThreadStore) -> dict[str, str]:
309
+ paths: dict[str, str] = {}
310
+ page = 1
311
+ while page <= MAX_THREAD_PAGES:
312
+ batch = await self.ctx.client.get_json(
313
+ f"projects/{self._encoded}/merge_requests/{self.ref.id}/discussions",
314
+ {"per_page": 100, "page": page},
315
+ kind="discussions",
316
+ )
317
+ if not isinstance(batch, list) or not batch:
318
+ break
319
+ for discussion in batch:
320
+ if isinstance(discussion, dict) and not is_system_discussion(discussion):
321
+ path, _ = store.write(discussion)
322
+ paths[str(discussion["id"])] = str(path)
323
+ if len(batch) < 100:
324
+ break
325
+ page += 1
326
+ return paths
327
+
328
+ async def _sync_threads(self, store: ThreadStore, ids: set[str]) -> dict[str, str]:
329
+ if len(ids) > THREAD_LIST_THRESHOLD:
330
+ return await self._sync_all_threads(store)
331
+ paths = {}
332
+ for discussion_id in sorted(ids):
333
+ discussion = await self.ctx.client.get_json(
334
+ f"projects/{self._encoded}/merge_requests/{self.ref.id}/discussions/{discussion_id}",
335
+ kind="discussion",
336
+ )
337
+ if isinstance(discussion, dict):
338
+ path, _ = store.write(discussion)
339
+ paths[discussion_id] = str(path)
340
+ return paths
341
+
342
+ async def poll(self) -> list[RawEvent]:
343
+ prev = self.snapshot
344
+ cur = settle(prev, await self._fetch_snapshot())
345
+ store = self.thread_store(cur.web_url) if cur.threads is not None else None
346
+
347
+ if prev is None or (prev.threads is None and cur.threads is not None):
348
+ # Baseline, or comment tracking just started: write every thread file, report nothing about threads.
349
+ if store is not None:
350
+ await self._sync_all_threads(store)
351
+ if prev is None:
352
+ self.snapshot = cur
353
+ self._check_terminal()
354
+ return []
355
+ prev = replace(prev, threads=cur.threads)
356
+
357
+ events = diff_mr(prev, cur)
358
+ resolved_events = []
359
+ for event in events:
360
+ if event.kind == "pushed" and self.options.wants("pushed") and event.data.get("from"):
361
+ forced, reason = await self._is_force_push(str(event.data["from"]), str(event.data["to"]))
362
+ data = {**event.data, "forced": forced}
363
+ if reason:
364
+ data["forced_reason"] = reason
365
+ event = replace(event, data=data)
366
+ resolved_events.append(event)
367
+ events = resolved_events
368
+
369
+ if store is not None and prev.threads is not None and cur.threads is not None:
370
+ events = await self._attach_thread_files(store, prev.threads, cur.threads, events)
371
+
372
+ self.snapshot = cur
373
+ self._check_terminal()
374
+ return events
375
+
376
+ async def _attach_thread_files(
377
+ self,
378
+ store: ThreadStore,
379
+ prev: tuple[ThreadState, ...],
380
+ cur: tuple[ThreadState, ...],
381
+ events: list[RawEvent],
382
+ ) -> list[RawEvent]:
383
+ changed = {str(e.data["discussion_id"]) for e in events if e.kind in COMMENT_KINDS}
384
+ for gone in {t.id for t in prev} - {t.id for t in cur}:
385
+ store.remove(gone)
386
+ if not changed:
387
+ return events
388
+ paths = await self._sync_threads(store, changed)
389
+ attached = []
390
+ for event in events:
391
+ if event.kind in COMMENT_KINDS:
392
+ path = paths.get(str(event.data["discussion_id"]))
393
+ if path:
394
+ event = replace(event, artifacts=(path,))
395
+ attached.append(event)
396
+ return attached
397
+
398
+ def _check_terminal(self) -> None:
399
+ if self.snapshot is not None and self.snapshot.state in TERMINAL_STATES:
400
+ self.terminal_reason = self.snapshot.state
401
+ self.terminal_data = {"state": self.snapshot.state}
402
+
403
+ def next_delay(self) -> float:
404
+ return INTERVAL_PREMIUM if self.ctx.client.budget.premium_level else INTERVAL
405
+
406
+ def summary(self) -> dict[str, Any]:
407
+ snap = self.snapshot
408
+ if snap is None:
409
+ return {}
410
+ summary: dict[str, Any] = {
411
+ "iid": snap.iid,
412
+ "title": snap.title,
413
+ "state": snap.state,
414
+ "draft": snap.draft,
415
+ "merge_status": snap.merge_status,
416
+ "head_sha": snap.head_sha,
417
+ "source_branch": snap.source_branch,
418
+ "target_branch": snap.target_branch,
419
+ "web_url": snap.web_url,
420
+ "approved": snap.approved,
421
+ "approvals_left": snap.approvals_left,
422
+ "approved_by": list(snap.approved_by),
423
+ "reviewers": list(snap.reviewers),
424
+ }
425
+ if snap.head_pipeline_id is not None:
426
+ summary["head_pipeline"] = {"id": snap.head_pipeline_id, "status": snap.head_pipeline_status}
427
+ if snap.threads is not None:
428
+ summary["threads"] = {
429
+ "total": len(snap.threads),
430
+ "unresolved": sum(1 for t in snap.threads if t.resolvable and not t.resolved),
431
+ "dir": str(self.thread_store(snap.web_url).directory),
432
+ }
433
+ return summary
434
+
435
+ def children(self) -> frozenset[ResourceRef]:
436
+ snap = self.snapshot
437
+ if snap is None or snap.head_pipeline_id is None or self.terminal:
438
+ return frozenset()
439
+ if not self.options.wants(*PIPELINE_KINDS):
440
+ return frozenset()
441
+ project = snap.head_pipeline_project or self.ref.project
442
+ return frozenset({ResourceRef("pipeline", self.ref.host, project, snap.head_pipeline_id)})
443
+
444
+ def child_options(self, child: ResourceRef) -> PollerOptions | None:
445
+ return PollerOptions(self.options.events & frozenset(PIPELINE_KINDS), self.options.logs)
@@ -0,0 +1,150 @@
1
+ """Watching every merge request of a project through the MR list, ordered by `updated_at`.
2
+
3
+ Each poll asks for MRs updated after the watermark minus an overlap of two minutes. The overlap covers clock skew,
4
+ asynchronous system notes, and GitLab's throttled touch (it skips touching an MR within a minute of the last touch).
5
+ Because of that throttling, `mr_updated` is best effort: a second change within a minute of the first shows up only
6
+ with the next change after it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, replace
12
+ from datetime import UTC, datetime, timedelta
13
+ from typing import Any, ClassVar
14
+
15
+ from glab_cli_extras.events import RawEvent
16
+ from glab_cli_extras.gitlab.client import encode_project
17
+ from glab_cli_extras.resources.base import Poller
18
+
19
+ OVERLAP = timedelta(minutes=2)
20
+ INTERVAL = 60.0
21
+ MAX_PAGES = 5
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class MrEntry:
26
+ iid: int
27
+ state: str
28
+ updated_at: str
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class MrListSnapshot:
33
+ project: str
34
+ started_at: str
35
+ watermark: str
36
+ mrs: tuple[MrEntry, ...] = ()
37
+
38
+
39
+ def _iso(dt: datetime) -> str:
40
+ return dt.astimezone(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
41
+
42
+
43
+ def _parse(ts: str) -> datetime:
44
+ return datetime.fromisoformat(ts.replace("Z", "+00:00"))
45
+
46
+
47
+ def _event_data(mr: dict[str, Any]) -> dict[str, Any]:
48
+ return {
49
+ "iid": mr.get("iid"),
50
+ "title": mr.get("title"),
51
+ "state": mr.get("state"),
52
+ "author": (mr.get("author") or {}).get("username"),
53
+ "source_branch": mr.get("source_branch"),
54
+ "target_branch": mr.get("target_branch"),
55
+ "draft": mr.get("draft"),
56
+ "web_url": mr.get("web_url"),
57
+ "updated_at": mr.get("updated_at"),
58
+ }
59
+
60
+
61
+ def diff_mr_list(prev: MrListSnapshot, items: list[dict[str, Any]]) -> tuple[MrListSnapshot, list[RawEvent]]:
62
+ """Fold one page of the MR list into the snapshot and return the events it implies."""
63
+ known = {m.iid: m for m in prev.mrs}
64
+ started = _parse(prev.started_at)
65
+ watermark = _parse(prev.watermark)
66
+ events: list[RawEvent] = []
67
+ for mr in sorted(items, key=lambda m: str(m.get("updated_at") or "")):
68
+ iid = int(mr["iid"])
69
+ state = str(mr.get("state") or "")
70
+ updated_at = str(mr.get("updated_at") or "")
71
+ old = known.get(iid)
72
+ kind: str | None = None
73
+ if old is None:
74
+ created_at = mr.get("created_at")
75
+ if updated_at and _parse(updated_at) < started:
76
+ # Seen through the overlap window, but last changed before the watch began: nothing new.
77
+ kind = None
78
+ elif created_at and _parse(str(created_at)) >= started:
79
+ kind = "mr_opened"
80
+ elif state == "merged":
81
+ kind = "mr_merged"
82
+ elif state == "closed":
83
+ kind = "mr_closed"
84
+ else:
85
+ kind = "mr_updated"
86
+ elif old.state != state:
87
+ if state == "merged":
88
+ kind = "mr_merged"
89
+ elif state == "closed":
90
+ kind = "mr_closed"
91
+ elif state == "opened" and old.state == "closed":
92
+ kind = "mr_reopened"
93
+ else:
94
+ kind = "mr_updated"
95
+ elif updated_at > old.updated_at:
96
+ kind = "mr_updated"
97
+ if kind is not None:
98
+ events.append(RawEvent(kind, _event_data(mr)))
99
+ known[iid] = MrEntry(iid, state, updated_at)
100
+ if updated_at:
101
+ watermark = max(watermark, _parse(updated_at))
102
+ snapshot = replace(prev, watermark=_iso(watermark), mrs=tuple(sorted(known.values(), key=lambda m: m.iid)))
103
+ return snapshot, events
104
+
105
+
106
+ class MrListPoller(Poller[MrListSnapshot]):
107
+ kind: ClassVar = "mrs"
108
+ snapshot_type: ClassVar = MrListSnapshot
109
+
110
+ async def _fetch(self, since: datetime) -> list[dict[str, Any]]:
111
+ items: list[dict[str, Any]] = []
112
+ for page in range(1, MAX_PAGES + 1):
113
+ batch = await self.ctx.client.get_json(
114
+ f"projects/{encode_project(self.ref.project)}/merge_requests",
115
+ {
116
+ "state": "all",
117
+ "order_by": "updated_at",
118
+ "sort": "desc",
119
+ "updated_after": _iso(since),
120
+ "per_page": 100,
121
+ "page": page,
122
+ },
123
+ kind="merge_requests",
124
+ )
125
+ if not isinstance(batch, list):
126
+ break
127
+ items.extend(m for m in batch if isinstance(m, dict))
128
+ if len(batch) < 100:
129
+ break
130
+ return items
131
+
132
+ async def poll(self) -> list[RawEvent]:
133
+ now = datetime.fromtimestamp(self.ctx.clock(), UTC)
134
+ if self.snapshot is None:
135
+ start = now - OVERLAP
136
+ baseline = MrListSnapshot(self.ref.project, _iso(now), _iso(start))
137
+ self.snapshot, _ = diff_mr_list(baseline, await self._fetch(start))
138
+ return []
139
+ items = await self._fetch(_parse(self.snapshot.watermark) - OVERLAP)
140
+ self.snapshot, events = diff_mr_list(self.snapshot, items)
141
+ return events
142
+
143
+ def next_delay(self) -> float:
144
+ return INTERVAL
145
+
146
+ def summary(self) -> dict[str, Any]:
147
+ snap = self.snapshot
148
+ if snap is None:
149
+ return {}
150
+ return {"project": snap.project, "watermark": snap.watermark, "recently_updated": len(snap.mrs)}