queue-watch 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.
- queue_watch/__init__.py +3 -0
- queue_watch/adapters/__init__.py +34 -0
- queue_watch/adapters/command.py +54 -0
- queue_watch/adapters/flock_queue.py +322 -0
- queue_watch/adapters/github_actions.py +164 -0
- queue_watch/adapters/github_merge_queue.py +141 -0
- queue_watch/app.py +876 -0
- queue_watch/changes.py +181 -0
- queue_watch/cli.py +129 -0
- queue_watch/config.py +105 -0
- queue_watch/discovery.py +149 -0
- queue_watch/model.py +188 -0
- queue_watch/process.py +67 -0
- queue_watch/widgets.py +774 -0
- queue_watch-0.1.0.dist-info/METADATA +141 -0
- queue_watch-0.1.0.dist-info/RECORD +19 -0
- queue_watch-0.1.0.dist-info/WHEEL +4 -0
- queue_watch-0.1.0.dist-info/entry_points.txt +2 -0
- queue_watch-0.1.0.dist-info/licenses/LICENSE +21 -0
queue_watch/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Adapters turn one external system into snapshot v1 (see queue_watch.model)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Adapter:
|
|
9
|
+
"""Base class. Subclasses take their options as keyword-only arguments.
|
|
10
|
+
|
|
11
|
+
A ``config_dir`` parameter, when present, receives the directory of the config
|
|
12
|
+
file; it is supplied by the loader and cannot be set from configuration.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
name: ClassVar[str]
|
|
16
|
+
label: ClassVar[str]
|
|
17
|
+
interval: ClassVar[float] = 15.0
|
|
18
|
+
|
|
19
|
+
async def snapshot(self) -> dict:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
async def children(self, record: dict) -> list[dict]:
|
|
23
|
+
"""Detail records (jobs, steps) for the inspector; inline by default."""
|
|
24
|
+
return record.get("children") or []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def registry() -> dict[str, type[Adapter]]:
|
|
28
|
+
from .command import Command
|
|
29
|
+
from .flock_queue import FlockQueue
|
|
30
|
+
from .github_actions import GitHubActions
|
|
31
|
+
from .github_merge_queue import GitHubMergeQueue
|
|
32
|
+
|
|
33
|
+
adapters = (GitHubActions, GitHubMergeQueue, FlockQueue, Command)
|
|
34
|
+
return {adapter.name: adapter for adapter in adapters}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Any program that prints one snapshot v1 JSON object on stdout.
|
|
2
|
+
|
|
3
|
+
This is the extension point for systems without a built-in adapter (GitLab,
|
|
4
|
+
Buildkite, Jenkins, Bors, in-house queues). ``command`` is an argument list run
|
|
5
|
+
without a shell. Locally it runs in ``cwd``, which defaults to the directory of
|
|
6
|
+
the config file. With ``host`` set it runs over SSH, in ``cwd`` on that host
|
|
7
|
+
when given. A nonzero exit, a timeout or output that breaks the contract is
|
|
8
|
+
shown as a source error, and the last good data stays on screen.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ..process import check_host, over_ssh, run_json
|
|
17
|
+
from . import Adapter
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Command(Adapter):
|
|
21
|
+
name = "command"
|
|
22
|
+
label = "Command"
|
|
23
|
+
interval = 15.0
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
*,
|
|
28
|
+
command: list[str],
|
|
29
|
+
host: str | None = None,
|
|
30
|
+
cwd: str | None = None,
|
|
31
|
+
timeout: float = 25,
|
|
32
|
+
config_dir: Path | None = None,
|
|
33
|
+
):
|
|
34
|
+
if (
|
|
35
|
+
not isinstance(command, list)
|
|
36
|
+
or not command
|
|
37
|
+
or not all(isinstance(arg, str) and arg for arg in command)
|
|
38
|
+
):
|
|
39
|
+
raise ValueError(
|
|
40
|
+
'command must be a list of arguments, for example ["./status", "--json"]'
|
|
41
|
+
)
|
|
42
|
+
if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or timeout <= 0:
|
|
43
|
+
raise ValueError("timeout must be a positive number of seconds")
|
|
44
|
+
self.command, self.host, self.timeout = command, check_host(host), float(timeout)
|
|
45
|
+
if self.host is not None:
|
|
46
|
+
self.cwd = cwd
|
|
47
|
+
else:
|
|
48
|
+
base = config_dir or Path.cwd()
|
|
49
|
+
self.cwd = str(base / os.path.expanduser(cwd)) if cwd else str(base)
|
|
50
|
+
|
|
51
|
+
async def snapshot(self) -> dict:
|
|
52
|
+
if self.host is not None:
|
|
53
|
+
return await run_json(over_ssh(self.host, self.command, self.cwd), timeout=self.timeout)
|
|
54
|
+
return await run_json(self.command, cwd=self.cwd, timeout=self.timeout)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""A FIFO of flock-held ticket files guarding one integration lock, on Linux.
|
|
2
|
+
|
|
3
|
+
The pattern: each waiting merger holds an exclusive flock on its own ticket file
|
|
4
|
+
(sorted names give FIFO order) and the head of the queue holds the main lock. The
|
|
5
|
+
adapter reads lock holders from ``/proc/locks`` instead of acquiring a flock, so
|
|
6
|
+
watching never perturbs the queue. Processes are matched by command line: either
|
|
7
|
+
the executable itself is named ``process``, or an interpreter (``interpreters``
|
|
8
|
+
prefixes) runs a script named ``process``; the first positional argument after it
|
|
9
|
+
names the change. Wrapper processes (``uv run``, ``timeout``) are skipped.
|
|
10
|
+
|
|
11
|
+
``history`` is an optional JSON Lines log with one object per finished attempt:
|
|
12
|
+
``item`` and ``outcome`` are required; ``started_at`` and ``finished_at`` are
|
|
13
|
+
optional; every other key is shown in the inspector.
|
|
14
|
+
|
|
15
|
+
Paths may contain ``{git_dir}``, resolved from the Git checkout at ``root``. With
|
|
16
|
+
``host`` set, the probe is sent to ``python3 -`` over SSH on stdin; nothing is
|
|
17
|
+
installed or written on the host.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import glob
|
|
24
|
+
import inspect
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import subprocess
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from ..model import DEFAULT_VIEWS, SCHEMA
|
|
32
|
+
from ..process import check_host, over_ssh, run_json
|
|
33
|
+
from . import Adapter
|
|
34
|
+
|
|
35
|
+
OUTCOMES = {
|
|
36
|
+
"success": "success",
|
|
37
|
+
"merged": "success",
|
|
38
|
+
"failure": "failure",
|
|
39
|
+
"failed": "failure",
|
|
40
|
+
"refused": "failure",
|
|
41
|
+
"exception": "failure",
|
|
42
|
+
"error": "failure",
|
|
43
|
+
"cancelled": "cancelled",
|
|
44
|
+
}
|
|
45
|
+
HISTORY_KEYS = {"item", "outcome", "started_at", "finished_at"}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Shipped to the queue host over stdin, so it must stay self-contained.
|
|
49
|
+
def flock_probe(options: dict, proc_root: str = "/proc") -> dict:
|
|
50
|
+
git_dir = ""
|
|
51
|
+
if options.get("root"):
|
|
52
|
+
git_dir = subprocess.run(
|
|
53
|
+
[
|
|
54
|
+
"git",
|
|
55
|
+
"-C",
|
|
56
|
+
os.path.expanduser(options["root"]),
|
|
57
|
+
"rev-parse",
|
|
58
|
+
"--absolute-git-dir",
|
|
59
|
+
],
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
check=True,
|
|
63
|
+
timeout=10,
|
|
64
|
+
).stdout.strip()
|
|
65
|
+
|
|
66
|
+
def resolve(template):
|
|
67
|
+
if "{git_dir}" in template and not git_dir:
|
|
68
|
+
raise ValueError("{git_dir} needs the root option")
|
|
69
|
+
return os.path.expanduser(template.replace("{git_dir}", git_dir))
|
|
70
|
+
|
|
71
|
+
proc = Path(proc_root)
|
|
72
|
+
locks = {}
|
|
73
|
+
for line in (proc / "locks").read_text().splitlines():
|
|
74
|
+
fields = line.split()
|
|
75
|
+
if len(fields) < 6 or fields[1] != "FLOCK":
|
|
76
|
+
continue # Includes blocked lock requests (the -> rows).
|
|
77
|
+
device = fields[5].split(":")
|
|
78
|
+
if len(device) == 3:
|
|
79
|
+
locks[(int(device[0], 16), int(device[1], 16), int(device[2]))] = int(fields[4])
|
|
80
|
+
|
|
81
|
+
def holder(path):
|
|
82
|
+
try:
|
|
83
|
+
stat = os.stat(path)
|
|
84
|
+
except FileNotFoundError:
|
|
85
|
+
return None
|
|
86
|
+
return locks.get((os.major(stat.st_dev), os.minor(stat.st_dev), stat.st_ino))
|
|
87
|
+
|
|
88
|
+
def command(pid):
|
|
89
|
+
try:
|
|
90
|
+
raw = (proc / str(pid) / "cmdline").read_bytes()
|
|
91
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
|
92
|
+
return []
|
|
93
|
+
return raw.decode("utf-8", errors="replace").rstrip("\0").split("\0")
|
|
94
|
+
|
|
95
|
+
name = options["process"]
|
|
96
|
+
interpreters = tuple(options.get("interpreters", ("python", "pypy")))
|
|
97
|
+
owner_flag = options.get("owner_flag")
|
|
98
|
+
|
|
99
|
+
def match(argv):
|
|
100
|
+
"""Return (item, owner) when argv is the merger itself, not a wrapper."""
|
|
101
|
+
if not argv:
|
|
102
|
+
return None
|
|
103
|
+
if Path(argv[0]).name == name:
|
|
104
|
+
rest = argv[1:]
|
|
105
|
+
elif Path(argv[0]).name.startswith(interpreters):
|
|
106
|
+
for index, arg in enumerate(argv[1:], 1):
|
|
107
|
+
if Path(arg).name == name:
|
|
108
|
+
rest = argv[index + 1 :]
|
|
109
|
+
break
|
|
110
|
+
else:
|
|
111
|
+
return None
|
|
112
|
+
else:
|
|
113
|
+
return None
|
|
114
|
+
item = rest[0] if rest and not rest[0].startswith("-") else "unknown"
|
|
115
|
+
owner = None
|
|
116
|
+
if owner_flag and owner_flag in rest:
|
|
117
|
+
at = rest.index(owner_flag) + 1
|
|
118
|
+
owner = rest[at] if at < len(rest) else None
|
|
119
|
+
return item, owner
|
|
120
|
+
|
|
121
|
+
active = holder(resolve(options["lock"]))
|
|
122
|
+
rows, seen = [], set()
|
|
123
|
+
for ticket in sorted(glob.glob(resolve(options["tickets"]))):
|
|
124
|
+
pid = holder(ticket)
|
|
125
|
+
if pid is None:
|
|
126
|
+
continue # Abandoned tickets are not queue entries. Never delete them.
|
|
127
|
+
try:
|
|
128
|
+
modified = os.stat(ticket).st_mtime
|
|
129
|
+
except FileNotFoundError:
|
|
130
|
+
continue # A merger finished between observations.
|
|
131
|
+
item, owner = match(command(pid)) or ("unknown", None)
|
|
132
|
+
seen.add(pid)
|
|
133
|
+
rows.append(
|
|
134
|
+
{
|
|
135
|
+
"id": f"ticket:{Path(ticket).name}:{pid}",
|
|
136
|
+
"item": item,
|
|
137
|
+
"owner": owner,
|
|
138
|
+
"state": "integrating" if pid == active else "queued",
|
|
139
|
+
"position": len(rows) + 1,
|
|
140
|
+
"pid": pid,
|
|
141
|
+
"since": datetime.fromtimestamp(modified, UTC).isoformat(),
|
|
142
|
+
"ticket": Path(ticket).name,
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
# A holder without a ticket stays visible without an invented position.
|
|
146
|
+
if active is not None and active not in seen:
|
|
147
|
+
item, owner = match(command(active)) or ("unknown", None)
|
|
148
|
+
rows.insert(
|
|
149
|
+
0,
|
|
150
|
+
{
|
|
151
|
+
"id": f"holder:{active}",
|
|
152
|
+
"item": item,
|
|
153
|
+
"owner": owner,
|
|
154
|
+
"state": "integrating",
|
|
155
|
+
"position": None,
|
|
156
|
+
"pid": active,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
seen.add(active)
|
|
160
|
+
inaccessible = 0
|
|
161
|
+
for entry in proc.iterdir():
|
|
162
|
+
if not entry.name.isdigit() or int(entry.name) in seen:
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
if entry.stat().st_uid != os.getuid():
|
|
166
|
+
continue
|
|
167
|
+
raw = (entry / "cmdline").read_bytes()
|
|
168
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
169
|
+
continue
|
|
170
|
+
except PermissionError:
|
|
171
|
+
inaccessible += 1
|
|
172
|
+
continue
|
|
173
|
+
info = match(raw.decode("utf-8", errors="replace").rstrip("\0").split("\0"))
|
|
174
|
+
if info is not None:
|
|
175
|
+
rows.append(
|
|
176
|
+
{
|
|
177
|
+
"id": f"process:{entry.name}",
|
|
178
|
+
"item": info[0],
|
|
179
|
+
"owner": info[1],
|
|
180
|
+
"state": "preparing",
|
|
181
|
+
"position": None,
|
|
182
|
+
"pid": int(entry.name),
|
|
183
|
+
}
|
|
184
|
+
)
|
|
185
|
+
limit = int(options.get("history_limit", 30))
|
|
186
|
+
recent, malformed = [], 0
|
|
187
|
+
if options.get("history"):
|
|
188
|
+
try:
|
|
189
|
+
with open(resolve(options["history"]), "rb") as stream:
|
|
190
|
+
size = stream.seek(0, 2)
|
|
191
|
+
offset = max(0, size - 262144)
|
|
192
|
+
stream.seek(offset)
|
|
193
|
+
if offset:
|
|
194
|
+
stream.readline() # Discard the potentially partial first record.
|
|
195
|
+
lines = stream.read().splitlines(keepends=True)
|
|
196
|
+
for line in lines:
|
|
197
|
+
if not line.endswith(b"\n"):
|
|
198
|
+
continue # A concurrent append is not a completed record.
|
|
199
|
+
try:
|
|
200
|
+
record = json.loads(line)
|
|
201
|
+
if not isinstance(record, dict) or "item" not in record:
|
|
202
|
+
raise ValueError("invalid history record")
|
|
203
|
+
recent.append(record)
|
|
204
|
+
except (ValueError, UnicodeDecodeError):
|
|
205
|
+
malformed += 1
|
|
206
|
+
except FileNotFoundError:
|
|
207
|
+
pass
|
|
208
|
+
warnings = []
|
|
209
|
+
if malformed:
|
|
210
|
+
warnings.append(f"Skipped {malformed} malformed history records")
|
|
211
|
+
if inaccessible:
|
|
212
|
+
warnings.append(f"Cannot inspect {inaccessible} same-user processes")
|
|
213
|
+
return {
|
|
214
|
+
"observed_at": datetime.now(UTC).isoformat(),
|
|
215
|
+
"queue": rows,
|
|
216
|
+
"history": list(reversed(recent[-limit:])),
|
|
217
|
+
"warnings": warnings,
|
|
218
|
+
"history_limit": limit,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
PROBE_PRELUDE = (
|
|
223
|
+
"import glob, json, os, subprocess\n"
|
|
224
|
+
"from datetime import UTC, datetime\n"
|
|
225
|
+
"from pathlib import Path\n"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class FlockQueue(Adapter):
|
|
230
|
+
name = "flock-queue"
|
|
231
|
+
label = "Integration"
|
|
232
|
+
interval = 5.0
|
|
233
|
+
|
|
234
|
+
def __init__(
|
|
235
|
+
self,
|
|
236
|
+
*,
|
|
237
|
+
lock: str,
|
|
238
|
+
tickets: str,
|
|
239
|
+
process: str,
|
|
240
|
+
root: str | None = None,
|
|
241
|
+
host: str | None = None,
|
|
242
|
+
interpreters: list[str] | None = None,
|
|
243
|
+
owner_flag: str | None = None,
|
|
244
|
+
history: str | None = None,
|
|
245
|
+
history_limit: int = 30,
|
|
246
|
+
history_columns: list[dict] | None = None,
|
|
247
|
+
):
|
|
248
|
+
self.host = check_host(host)
|
|
249
|
+
self.options = {
|
|
250
|
+
"root": root,
|
|
251
|
+
"lock": lock,
|
|
252
|
+
"tickets": tickets,
|
|
253
|
+
"process": process,
|
|
254
|
+
"owner_flag": owner_flag,
|
|
255
|
+
"history": history,
|
|
256
|
+
"history_limit": int(history_limit),
|
|
257
|
+
}
|
|
258
|
+
if interpreters is not None:
|
|
259
|
+
self.options["interpreters"] = list(interpreters)
|
|
260
|
+
self.history_columns = list(history_columns or [])
|
|
261
|
+
|
|
262
|
+
async def probe(self) -> dict:
|
|
263
|
+
if self.host is None:
|
|
264
|
+
return await asyncio.to_thread(flock_probe, self.options)
|
|
265
|
+
source = PROBE_PRELUDE + inspect.getsource(flock_probe)
|
|
266
|
+
source += f"\nprint(json.dumps(flock_probe(json.loads({json.dumps(self.options)!r}))))\n"
|
|
267
|
+
return await run_json(over_ssh(self.host, ["python3", "-"]), stdin=source)
|
|
268
|
+
|
|
269
|
+
async def snapshot(self) -> dict:
|
|
270
|
+
raw = await self.probe()
|
|
271
|
+
records = []
|
|
272
|
+
for row in raw["queue"]:
|
|
273
|
+
records.append(
|
|
274
|
+
{
|
|
275
|
+
"id": row["id"],
|
|
276
|
+
"kind": "queue_entry",
|
|
277
|
+
"state": "queued" if row["state"] == "queued" else "running",
|
|
278
|
+
"raw_state": row["state"],
|
|
279
|
+
"title": row["item"],
|
|
280
|
+
"owner": row.get("owner"),
|
|
281
|
+
"position": row.get("position"),
|
|
282
|
+
"created_at": row.get("since"),
|
|
283
|
+
"fields": {key: row[key] for key in ("pid", "ticket") if key in row},
|
|
284
|
+
}
|
|
285
|
+
)
|
|
286
|
+
for index, entry in enumerate(raw["history"]):
|
|
287
|
+
outcome = str(entry.get("outcome", "unknown"))
|
|
288
|
+
records.append(
|
|
289
|
+
{
|
|
290
|
+
"id": f"attempt:{index}:{entry.get('item')}:{entry.get('finished_at')}",
|
|
291
|
+
"kind": "attempt",
|
|
292
|
+
"state": OUTCOMES.get(outcome, "unknown"),
|
|
293
|
+
"raw_state": outcome,
|
|
294
|
+
"title": str(entry["item"]),
|
|
295
|
+
"started_at": timestamp(entry.get("started_at")),
|
|
296
|
+
"finished_at": timestamp(entry.get("finished_at")),
|
|
297
|
+
"fields": {
|
|
298
|
+
key: value for key, value in entry.items() if key not in HISTORY_KEYS
|
|
299
|
+
},
|
|
300
|
+
}
|
|
301
|
+
)
|
|
302
|
+
attempts = dict(DEFAULT_VIEWS["attempt"])
|
|
303
|
+
attempts["columns"] = attempts["columns"] + self.history_columns
|
|
304
|
+
return {
|
|
305
|
+
"schema": SCHEMA,
|
|
306
|
+
"observed_at": raw["observed_at"],
|
|
307
|
+
"records": records,
|
|
308
|
+
"views": [DEFAULT_VIEWS["queue_entry"], attempts],
|
|
309
|
+
"warnings": raw["warnings"],
|
|
310
|
+
"note": "FIFO positions are observed, not reserved. Preparing mergers hold "
|
|
311
|
+
f"no ticket yet. History is the last {raw['history_limit']} attempts "
|
|
312
|
+
"within a 256 KiB log tail.",
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def timestamp(value) -> str | None:
|
|
317
|
+
"""History logs are written by other tools; drop unparseable times."""
|
|
318
|
+
try:
|
|
319
|
+
parsed = datetime.fromisoformat(value)
|
|
320
|
+
except (TypeError, ValueError):
|
|
321
|
+
return None
|
|
322
|
+
return parsed.isoformat() if parsed.tzinfo else None
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""GitHub Actions workflow runs through the authenticated ``gh`` CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import re
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
|
|
9
|
+
from .. import process
|
|
10
|
+
from ..model import SCHEMA
|
|
11
|
+
from . import Adapter
|
|
12
|
+
|
|
13
|
+
ACTIVE_STATUSES = ("queued", "in_progress", "waiting", "pending", "requested")
|
|
14
|
+
STATES = {
|
|
15
|
+
"queued": "queued",
|
|
16
|
+
"pending": "queued",
|
|
17
|
+
"requested": "queued",
|
|
18
|
+
"waiting": "blocked",
|
|
19
|
+
"action_required": "blocked",
|
|
20
|
+
"in_progress": "running",
|
|
21
|
+
"success": "success",
|
|
22
|
+
"failure": "failure",
|
|
23
|
+
"timed_out": "failure",
|
|
24
|
+
"startup_failure": "failure",
|
|
25
|
+
"cancelled": "cancelled",
|
|
26
|
+
"skipped": "neutral",
|
|
27
|
+
"neutral": "neutral",
|
|
28
|
+
"stale": "neutral",
|
|
29
|
+
}
|
|
30
|
+
PAGE = 100
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def state_of(item: dict) -> tuple[str, str]:
|
|
34
|
+
raw = item.get("conclusion") or item.get("status") or "unknown"
|
|
35
|
+
return STATES.get(raw, "unknown"), raw
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GitHubActions(Adapter):
|
|
39
|
+
name = "github-actions"
|
|
40
|
+
label = "CI"
|
|
41
|
+
interval = 15.0
|
|
42
|
+
|
|
43
|
+
def __init__(self, *, repo: str):
|
|
44
|
+
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
|
|
45
|
+
raise ValueError(f"repo must be owner/repo, got {repo!r}")
|
|
46
|
+
self.repo = repo
|
|
47
|
+
|
|
48
|
+
async def snapshot(self) -> dict:
|
|
49
|
+
# Fetch each active state separately: an old queued run must not disappear
|
|
50
|
+
# behind a page of newer completed runs. GitHub caps each search at 1000.
|
|
51
|
+
async def fetch(status):
|
|
52
|
+
endpoint = f"repos/{self.repo}/actions/runs?per_page={PAGE}"
|
|
53
|
+
if status:
|
|
54
|
+
endpoint += "&status=" + status
|
|
55
|
+
return await process.run_json(["gh", "api", endpoint])
|
|
56
|
+
|
|
57
|
+
statuses = (*ACTIVE_STATUSES, None)
|
|
58
|
+
pages = await asyncio.gather(*(fetch(status) for status in statuses))
|
|
59
|
+
runs: dict[int, dict] = {}
|
|
60
|
+
warnings = []
|
|
61
|
+
for status, page in zip(statuses, pages, strict=True):
|
|
62
|
+
if status and page.get("total_count", 0) > PAGE:
|
|
63
|
+
warnings.append(f"{status}: showing first {PAGE} of {page['total_count']} runs")
|
|
64
|
+
for run in page["workflow_runs"]:
|
|
65
|
+
previous = runs.get(run["id"])
|
|
66
|
+
identity = (run.get("run_attempt") or 0, run.get("updated_at") or "")
|
|
67
|
+
if previous is None or identity >= (
|
|
68
|
+
previous.get("run_attempt") or 0,
|
|
69
|
+
previous.get("updated_at") or "",
|
|
70
|
+
):
|
|
71
|
+
runs[run["id"]] = run
|
|
72
|
+
ordered = sorted(
|
|
73
|
+
runs.values(),
|
|
74
|
+
key=lambda run: (run.get("status") == "completed", -int(run["id"])),
|
|
75
|
+
)
|
|
76
|
+
return {
|
|
77
|
+
"schema": SCHEMA,
|
|
78
|
+
"observed_at": datetime.now(UTC).isoformat(),
|
|
79
|
+
"records": [self.record(run) for run in ordered],
|
|
80
|
+
"views": [
|
|
81
|
+
{
|
|
82
|
+
"kind": "run",
|
|
83
|
+
"label": "Runs",
|
|
84
|
+
"columns": [
|
|
85
|
+
{"key": "state", "label": "State", "width": 12, "format": "state"},
|
|
86
|
+
{"key": "title", "label": "Workflow / title", "width": 25},
|
|
87
|
+
{"key": "ref", "label": "Branch", "width": 18},
|
|
88
|
+
{"key": "sha", "label": "SHA", "width": 8, "format": "sha"},
|
|
89
|
+
{"key": "created_at", "label": "Age", "width": 9, "format": "age"},
|
|
90
|
+
],
|
|
91
|
+
}
|
|
92
|
+
],
|
|
93
|
+
"warnings": warnings,
|
|
94
|
+
"note": f"Up to {PAGE} runs per active state plus the {PAGE} most recent.",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@staticmethod
|
|
98
|
+
def record(run: dict) -> dict:
|
|
99
|
+
state, raw = state_of(run)
|
|
100
|
+
return {
|
|
101
|
+
"id": str(run["id"]),
|
|
102
|
+
"kind": "run",
|
|
103
|
+
"state": state,
|
|
104
|
+
"raw_state": raw,
|
|
105
|
+
"title": run.get("name") or run.get("display_title") or str(run["id"]),
|
|
106
|
+
"subtitle": run.get("display_title"),
|
|
107
|
+
"ref": run.get("head_branch"),
|
|
108
|
+
"sha": run.get("head_sha"),
|
|
109
|
+
"created_at": iso(run.get("created_at")),
|
|
110
|
+
"updated_at": iso(run.get("updated_at")),
|
|
111
|
+
"url": run.get("html_url"),
|
|
112
|
+
"fields": {"run": run["id"], "attempt": run.get("run_attempt")},
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async def children(self, record: dict) -> list[dict]:
|
|
116
|
+
# Paginate so large platform matrices are not silently truncated.
|
|
117
|
+
pages = await process.run_json(
|
|
118
|
+
[
|
|
119
|
+
"gh",
|
|
120
|
+
"api",
|
|
121
|
+
"--paginate",
|
|
122
|
+
"--slurp",
|
|
123
|
+
f"repos/{self.repo}/actions/runs/{int(record['id'])}/jobs?per_page={PAGE}",
|
|
124
|
+
]
|
|
125
|
+
)
|
|
126
|
+
jobs = []
|
|
127
|
+
for job in (job for page in pages for job in page["jobs"]):
|
|
128
|
+
state, raw = state_of(job)
|
|
129
|
+
steps = []
|
|
130
|
+
for step in job.get("steps") or []:
|
|
131
|
+
step_state, step_raw = state_of(step)
|
|
132
|
+
steps.append(
|
|
133
|
+
{
|
|
134
|
+
"id": f"{job['id']}:{step.get('number')}",
|
|
135
|
+
"kind": "step",
|
|
136
|
+
"state": step_state,
|
|
137
|
+
"raw_state": step_raw,
|
|
138
|
+
"title": step.get("name") or "step",
|
|
139
|
+
"started_at": iso(step.get("started_at")),
|
|
140
|
+
"finished_at": iso(step.get("completed_at")),
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
jobs.append(
|
|
144
|
+
{
|
|
145
|
+
"id": str(job["id"]),
|
|
146
|
+
"kind": "job",
|
|
147
|
+
"state": state,
|
|
148
|
+
"raw_state": raw,
|
|
149
|
+
"title": job.get("name") or str(job["id"]),
|
|
150
|
+
"started_at": iso(job.get("started_at")),
|
|
151
|
+
"finished_at": iso(job.get("completed_at")),
|
|
152
|
+
"url": job.get("html_url"),
|
|
153
|
+
"fields": {"runner": job.get("runner_name")},
|
|
154
|
+
"children": steps,
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
return jobs
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def iso(value: str | None) -> str | None:
|
|
161
|
+
"""GitHub returns ``Z`` suffixes; normalize to an explicit offset."""
|
|
162
|
+
if not value:
|
|
163
|
+
return None
|
|
164
|
+
return datetime.fromisoformat(value).isoformat()
|