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.
- glab_cli_extras/__init__.py +3 -0
- glab_cli_extras/artifacts/__init__.py +0 -0
- glab_cli_extras/artifacts/job_logs.py +95 -0
- glab_cli_extras/artifacts/threads.py +147 -0
- glab_cli_extras/cli/__init__.py +0 -0
- glab_cli_extras/cli/watch.py +426 -0
- glab_cli_extras/daemon/__init__.py +0 -0
- glab_cli_extras/daemon/__main__.py +10 -0
- glab_cli_extras/daemon/engine.py +491 -0
- glab_cli_extras/daemon/lifecycle.py +186 -0
- glab_cli_extras/daemon/locks.py +85 -0
- glab_cli_extras/daemon/scheduler.py +52 -0
- glab_cli_extras/daemon/server.py +79 -0
- glab_cli_extras/daemon/transport.py +185 -0
- glab_cli_extras/events.py +165 -0
- glab_cli_extras/gitlab/__init__.py +0 -0
- glab_cli_extras/gitlab/budget.py +135 -0
- glab_cli_extras/gitlab/client.py +225 -0
- glab_cli_extras/gitlab/glab.py +175 -0
- glab_cli_extras/gitlab/queries/__init__.py +11 -0
- glab_cli_extras/gitlab/queries/branch_pipelines.graphql +8 -0
- glab_cli_extras/gitlab/queries/mr.graphql +42 -0
- glab_cli_extras/gitlab/queries/pipeline.graphql +38 -0
- glab_cli_extras/gitlab/refs.py +151 -0
- glab_cli_extras/paths.py +79 -0
- glab_cli_extras/py.typed +0 -0
- glab_cli_extras/resources/__init__.py +19 -0
- glab_cli_extras/resources/base.py +146 -0
- glab_cli_extras/resources/branch.py +265 -0
- glab_cli_extras/resources/mr.py +445 -0
- glab_cli_extras/resources/mr_list.py +150 -0
- glab_cli_extras/resources/pipeline.py +319 -0
- glab_cli_extras/serde.py +75 -0
- glab_cli_extras/watch/__init__.py +0 -0
- glab_cli_extras/watch/changelog.py +86 -0
- glab_cli_extras/watch/claude.py +193 -0
- glab_cli_extras/watch/client.py +316 -0
- glab_cli_extras/watch/sinks.py +83 -0
- glab_cli_extras/watch/streams.py +80 -0
- glab_cli_extras-0.1.0.dist-info/METADATA +230 -0
- glab_cli_extras-0.1.0.dist-info/RECORD +44 -0
- glab_cli_extras-0.1.0.dist-info/WHEEL +4 -0
- glab_cli_extras-0.1.0.dist-info/entry_points.txt +2 -0
- 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)}
|