queue-watch 0.3.0__tar.gz → 0.4.0__tar.gz
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-0.3.0 → queue_watch-0.4.0}/PKG-INFO +5 -2
- {queue_watch-0.3.0 → queue_watch-0.4.0}/README.md +4 -1
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/__init__.py +1 -1
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_actions.py +45 -11
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/app.py +7 -2
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/cli.py +9 -2
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/demo.py +91 -34
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/process.py +18 -6
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/widgets.py +75 -23
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_app.py +39 -0
- queue_watch-0.4.0/tests/test_demo.py +62 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_github_actions.py +79 -10
- queue_watch-0.3.0/tests/test_demo.py +0 -26
- {queue_watch-0.3.0 → queue_watch-0.4.0}/.gitignore +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/LICENSE +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/examples/queue-status.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/examples/queue.json +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/examples/sources.toml +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/pyproject.toml +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/__init__.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/command.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/flock_queue.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_merge_queue.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/changes.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/config.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/discovery.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/model.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_changes.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_command.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_contract.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_discovery.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_flock_queue.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_github_merge_queue.py +0 -0
- {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_snapshots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: queue-watch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Read-only terminal dashboard for CI runs and merge queues, with mouse support
|
|
5
5
|
Project-URL: Homepage, https://github.com/wolfiesch/queue-watch
|
|
6
6
|
Project-URL: Issues, https://github.com/wolfiesch/queue-watch/issues
|
|
@@ -62,6 +62,7 @@ queue-watch profile add work --repo acme/api --repo acme/web
|
|
|
62
62
|
queue-watch --config sources.toml # any combination of sources
|
|
63
63
|
queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
|
|
64
64
|
queue-watch --demo # a simulated repository
|
|
65
|
+
queue-watch --demo 2 # two simulated repositories, as in a profile
|
|
65
66
|
queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
|
|
66
67
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
67
68
|
```
|
|
@@ -91,6 +92,8 @@ queue-watch profile add default --repo acme/api # opens outside a
|
|
|
91
92
|
|
|
92
93
|
`profile add` writes Actions for each repository, plus its merge queue when the default branch has one, with the repository's name in each label. Repositories already in the profile are left alone, and anything written by hand stays. A profile is ordinary [source config](#sources), so you can add `command` and `flock-queue` sources too. When a dashboard spans several repositories, a branch name such as `main` in two of them is two rows, each labelled with its repository.
|
|
93
94
|
|
|
95
|
+

|
|
96
|
+
|
|
94
97
|
### Repository configs need trust
|
|
95
98
|
|
|
96
99
|
A config can run commands and open SSH connections, so a `.queue-watch.toml` that arrives with a clone does nothing until you review it and run:
|
|
@@ -165,7 +168,7 @@ uv run pytest -q # includes SVG snapshot tests
|
|
|
165
168
|
uv run pytest -q --snapshot-update # after an intended layout change
|
|
166
169
|
```
|
|
167
170
|
|
|
168
|
-
`docs/screenshot.png` is the wide layout snapshot rendered in a browser; `uv run python docs/record_demo.py` re-records `docs/demo.gif` (
|
|
171
|
+
`docs/screenshot.png` is the wide layout snapshot rendered in a browser; `uv run python docs/record_demo.py` re-records `docs/demo.gif`, and `--repos 2` records `docs/demo-profile.gif` (both need Chrome and ffmpeg). To release, bump `__version__` in `src/queue_watch/__init__.py`, merge, and push a matching `vX.Y.Z` tag; the release workflow tests, builds, publishes to PyPI with trusted publishing, and creates the GitHub release.
|
|
169
172
|
|
|
170
173
|
## License
|
|
171
174
|
|
|
@@ -35,6 +35,7 @@ queue-watch profile add work --repo acme/api --repo acme/web
|
|
|
35
35
|
queue-watch --config sources.toml # any combination of sources
|
|
36
36
|
queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
|
|
37
37
|
queue-watch --demo # a simulated repository
|
|
38
|
+
queue-watch --demo 2 # two simulated repositories, as in a profile
|
|
38
39
|
queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
|
|
39
40
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
40
41
|
```
|
|
@@ -64,6 +65,8 @@ queue-watch profile add default --repo acme/api # opens outside a
|
|
|
64
65
|
|
|
65
66
|
`profile add` writes Actions for each repository, plus its merge queue when the default branch has one, with the repository's name in each label. Repositories already in the profile are left alone, and anything written by hand stays. A profile is ordinary [source config](#sources), so you can add `command` and `flock-queue` sources too. When a dashboard spans several repositories, a branch name such as `main` in two of them is two rows, each labelled with its repository.
|
|
66
67
|
|
|
68
|
+

|
|
69
|
+
|
|
67
70
|
### Repository configs need trust
|
|
68
71
|
|
|
69
72
|
A config can run commands and open SSH connections, so a `.queue-watch.toml` that arrives with a clone does nothing until you review it and run:
|
|
@@ -138,7 +141,7 @@ uv run pytest -q # includes SVG snapshot tests
|
|
|
138
141
|
uv run pytest -q --snapshot-update # after an intended layout change
|
|
139
142
|
```
|
|
140
143
|
|
|
141
|
-
`docs/screenshot.png` is the wide layout snapshot rendered in a browser; `uv run python docs/record_demo.py` re-records `docs/demo.gif` (
|
|
144
|
+
`docs/screenshot.png` is the wide layout snapshot rendered in a browser; `uv run python docs/record_demo.py` re-records `docs/demo.gif`, and `--repos 2` records `docs/demo-profile.gif` (both need Chrome and ffmpeg). To release, bump `__version__` in `src/queue_watch/__init__.py`, merge, and push a matching `vX.Y.Z` tag; the release workflow tests, builds, publishes to PyPI with trusted publishing, and creates the GitHub release.
|
|
142
145
|
|
|
143
146
|
## License
|
|
144
147
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
import re
|
|
7
8
|
from datetime import UTC, datetime
|
|
8
9
|
|
|
@@ -28,6 +29,7 @@ STATES = {
|
|
|
28
29
|
"stale": "neutral",
|
|
29
30
|
}
|
|
30
31
|
PAGE = 100
|
|
32
|
+
ETAGS = 200 # cached responses kept for revalidation: the run lists plus recent job lists
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def state_of(item: dict) -> tuple[str, str]:
|
|
@@ -44,6 +46,41 @@ class GitHubActions(Adapter):
|
|
|
44
46
|
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
|
|
45
47
|
raise ValueError(f"repo must be owner/repo, got {repo!r}")
|
|
46
48
|
self.repo = repo
|
|
49
|
+
self.etags: dict[str, tuple[str, object, bool]] = {}
|
|
50
|
+
|
|
51
|
+
async def get(self, endpoint: str) -> tuple[object, bool]:
|
|
52
|
+
"""GET one page, sending back the last ETag; returns ``(body, more_pages)``.
|
|
53
|
+
|
|
54
|
+
GitHub does not count a 304 against the rate limit, so a page that has not
|
|
55
|
+
changed since the last poll costs nothing. The cache is per page: a 304 says
|
|
56
|
+
nothing about later pages, so callers that need them still fetch them.
|
|
57
|
+
"""
|
|
58
|
+
argv = ["gh", "api", "--include", endpoint]
|
|
59
|
+
cached = self.etags.get(endpoint)
|
|
60
|
+
if cached:
|
|
61
|
+
argv += ["--header", f"If-None-Match: {cached[0]}"]
|
|
62
|
+
returncode, stdout, stderr = await process.capture(argv)
|
|
63
|
+
head, _, body = stdout.decode(errors="replace").replace("\r\n", "\n").partition("\n\n")
|
|
64
|
+
status, *lines = head.split("\n")
|
|
65
|
+
if cached and status.split()[1:2] == ["304"]:
|
|
66
|
+
if endpoint in self.etags: # evictions can run while this request was out
|
|
67
|
+
self.etags[endpoint] = self.etags.pop(endpoint) # most recently used last
|
|
68
|
+
return cached[1], cached[2]
|
|
69
|
+
if returncode:
|
|
70
|
+
raise process.failure(argv, returncode, stderr)
|
|
71
|
+
headers = {}
|
|
72
|
+
for line in lines:
|
|
73
|
+
name, sep, value = line.partition(":")
|
|
74
|
+
if sep:
|
|
75
|
+
headers[name.strip().lower()] = value.strip()
|
|
76
|
+
data = json.loads(body)
|
|
77
|
+
more = 'rel="next"' in headers.get("link", "")
|
|
78
|
+
self.etags.pop(endpoint, None)
|
|
79
|
+
if headers.get("etag"):
|
|
80
|
+
self.etags[endpoint] = (headers["etag"], data, more)
|
|
81
|
+
while len(self.etags) > ETAGS:
|
|
82
|
+
self.etags.pop(next(iter(self.etags)))
|
|
83
|
+
return data, more
|
|
47
84
|
|
|
48
85
|
async def snapshot(self) -> dict:
|
|
49
86
|
# Fetch each active state separately: an old queued run must not disappear
|
|
@@ -52,7 +89,8 @@ class GitHubActions(Adapter):
|
|
|
52
89
|
endpoint = f"repos/{self.repo}/actions/runs?per_page={PAGE}"
|
|
53
90
|
if status:
|
|
54
91
|
endpoint += "&status=" + status
|
|
55
|
-
|
|
92
|
+
page, _ = await self.get(endpoint)
|
|
93
|
+
return page
|
|
56
94
|
|
|
57
95
|
statuses = (*ACTIVE_STATUSES, None)
|
|
58
96
|
pages = await asyncio.gather(*(fetch(status) for status in statuses))
|
|
@@ -113,16 +151,12 @@ class GitHubActions(Adapter):
|
|
|
113
151
|
}
|
|
114
152
|
|
|
115
153
|
async def children(self, record: dict) -> list[dict]:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"--slurp",
|
|
123
|
-
f"repos/{self.repo}/actions/runs/{int(record['id'])}/jobs?per_page={PAGE}",
|
|
124
|
-
]
|
|
125
|
-
)
|
|
154
|
+
endpoint = f"repos/{self.repo}/actions/runs/{int(record['id'])}/jobs?per_page={PAGE}"
|
|
155
|
+
first, more = await self.get(endpoint)
|
|
156
|
+
pages = [first]
|
|
157
|
+
if more:
|
|
158
|
+
# Paginate so large platform matrices are not silently truncated.
|
|
159
|
+
pages = await process.run_json(["gh", "api", "--paginate", "--slurp", endpoint])
|
|
126
160
|
jobs = []
|
|
127
161
|
for job in (job for page in pages for job in page["jobs"]):
|
|
128
162
|
state, raw = state_of(job)
|
|
@@ -132,6 +132,7 @@ Screen { background: $surface; }
|
|
|
132
132
|
.queue-head .took-stats { width: auto; }
|
|
133
133
|
.queue-head .depth, .queue-head .took { display: none; }
|
|
134
134
|
.queue.-depth .queue-head .depth, .queue.-took .queue-head .took { display: block; }
|
|
135
|
+
.queue.-took.-took-flat .queue-head .took.spark { display: none; }
|
|
135
136
|
.-narrow .queue .queue-head .spark, .-narrow .queue .queue-head .depth { display: none; }
|
|
136
137
|
Sparkline > .sparkline--max-color { color: $error; }
|
|
137
138
|
Sparkline > .sparkline--min-color { color: $success; }
|
|
@@ -473,7 +474,8 @@ class QueueWatch(App):
|
|
|
473
474
|
self.query_one(ChangeTable).show(visible, queue, self.selected, empty)
|
|
474
475
|
tabs = self.query_one("#tabs", TabbedContent)
|
|
475
476
|
tabs.get_tab(CHANGES).label = f"Changes {len(visible)}"
|
|
476
|
-
|
|
477
|
+
# Snapshot: awaiting show() lets a concurrent fetch mount a new strip.
|
|
478
|
+
for source_id, strip in list(self.strips.items()):
|
|
477
479
|
records = snapshots.get(source_id, {}).get("records", [])
|
|
478
480
|
mine = {
|
|
479
481
|
rid: change for (sid, rid), change in self.by_record.items() if sid == source_id
|
|
@@ -842,7 +844,10 @@ class QueueWatch(App):
|
|
|
842
844
|
|
|
843
845
|
@on(DataTable.RowHighlighted)
|
|
844
846
|
async def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
845
|
-
|
|
847
|
+
# Highlights still arrive while the app shuts down and its widgets go away.
|
|
848
|
+
if not self.is_running or self.rendering:
|
|
849
|
+
return
|
|
850
|
+
if event.data_table.id == self.active_pane():
|
|
846
851
|
await self.show_selection()
|
|
847
852
|
|
|
848
853
|
@on(TabbedContent.TabActivated)
|
|
@@ -18,6 +18,7 @@ Examples:
|
|
|
18
18
|
queue-watch --config sources.toml --once
|
|
19
19
|
queue-watch trust # after reviewing .queue-watch.toml
|
|
20
20
|
queue-watch --demo # simulated repository, no login needed
|
|
21
|
+
queue-watch --demo 2 # two simulated repositories, like a profile
|
|
21
22
|
queue-watch profile add work --repo acme/api --repo acme/web
|
|
22
23
|
queue-watch profile list
|
|
23
24
|
"""
|
|
@@ -144,7 +145,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
144
145
|
group.add_argument("--config", type=Path, help="TOML file with [[source]] tables")
|
|
145
146
|
group.add_argument("--profile", help="NAME for ~/.config/queue-watch/NAME.toml")
|
|
146
147
|
group.add_argument(
|
|
147
|
-
"--demo",
|
|
148
|
+
"--demo",
|
|
149
|
+
nargs="?",
|
|
150
|
+
const=1,
|
|
151
|
+
type=int,
|
|
152
|
+
choices=(1, 2),
|
|
153
|
+
metavar="REPOS",
|
|
154
|
+
help="simulated repository, or 2 for two watched like a profile; no network or login",
|
|
148
155
|
)
|
|
149
156
|
parser.add_argument("--once", action="store_true", help="print JSON once; no TUI")
|
|
150
157
|
parser.add_argument(
|
|
@@ -165,7 +172,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
165
172
|
|
|
166
173
|
clock = demo.Clock()
|
|
167
174
|
widgets.now = clock.now # ages and estimates follow the simulated clock
|
|
168
|
-
sources = demo.sources(clock)
|
|
175
|
+
sources = demo.sources(clock, args.demo)
|
|
169
176
|
elif args.config:
|
|
170
177
|
sources = load(args.config.expanduser())
|
|
171
178
|
elif args.profile:
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""``--demo``:
|
|
1
|
+
"""``--demo``: made-up repositories whose CI and merge queues move on their own.
|
|
2
2
|
|
|
3
3
|
Nothing touches the network. Simulated time runs at ``SPEED`` times the wall clock
|
|
4
4
|
so a minute of watching shows several merges, a failed check and its fix. Events
|
|
@@ -19,20 +19,59 @@ from .model import SCHEMA
|
|
|
19
19
|
SPEED = 15 # simulated seconds per wall-clock second
|
|
20
20
|
STEP = 30 # simulated seconds per tick
|
|
21
21
|
WARMUP = 40 # ticks simulated before the first frame, so history exists
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Repo:
|
|
26
|
+
"""One simulated repository: its pull requests and where its numbering starts."""
|
|
27
|
+
|
|
28
|
+
name: str
|
|
29
|
+
pulls: tuple[tuple[str, str, str], ...] # title, branch, owner
|
|
30
|
+
first_pull: int
|
|
31
|
+
first_run: int
|
|
32
|
+
release: str | None # a release run left waiting for approval on main
|
|
33
|
+
offset: int # phase into the event patterns, so repositories move out of step
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def url(self) -> str:
|
|
37
|
+
return f"https://github.com/{self.name}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
WIDGETS = Repo(
|
|
41
|
+
"acme/widgets",
|
|
42
|
+
(
|
|
43
|
+
("Stream large uploads", "feat/stream-upload", "dana"),
|
|
44
|
+
("Cache parsed headers", "feat/parser-cache", "lee"),
|
|
45
|
+
("Document merge queue", "docs/merge-queue", "sam"),
|
|
46
|
+
("Fix font fallback on Windows", "fix/font-fallback", "ari"),
|
|
47
|
+
("Faster diff rendering", "feat/diff-speed", "noor"),
|
|
48
|
+
("Retry flaky socket reads", "fix/socket-retry", "kai"),
|
|
49
|
+
("Bump lockfile", "chore/lockfile", "lee"),
|
|
50
|
+
("Add CSV exporter", "feat/csv-export", "dana"),
|
|
51
|
+
("Tidy error messages", "fix/errors", "sam"),
|
|
52
|
+
("Theme tokens for dark mode", "feat/theme", "noor"),
|
|
53
|
+
),
|
|
54
|
+
first_pull=482,
|
|
55
|
+
first_run=9100,
|
|
56
|
+
release="v2.4.0",
|
|
57
|
+
offset=0,
|
|
58
|
+
)
|
|
59
|
+
# Shares branch names (main, chore/lockfile, fix/errors) with WIDGETS: a profile
|
|
60
|
+
# watching both must keep them apart.
|
|
61
|
+
GADGETS = Repo(
|
|
62
|
+
"acme/gadgets",
|
|
63
|
+
(
|
|
64
|
+
("Pair over Bluetooth LE", "feat/ble-pairing", "mika"),
|
|
65
|
+
("Bump lockfile", "chore/lockfile", "kai"),
|
|
66
|
+
("Fix battery drain in sleep", "fix/sleep-drain", "ari"),
|
|
67
|
+
("Firmware update screen", "feat/fw-update", "noor"),
|
|
68
|
+
("Tidy error messages", "fix/errors", "lee"),
|
|
69
|
+
("Calibrate the gyro at boot", "feat/gyro-cal", "dana"),
|
|
70
|
+
),
|
|
71
|
+
first_pull=1201,
|
|
72
|
+
first_run=5300,
|
|
73
|
+
release=None,
|
|
74
|
+
offset=3,
|
|
36
75
|
)
|
|
37
76
|
ARRIVAL_GAPS = (2, 1, 3, 6, 7, 2, 3, 8) # ticks between arrivals: bursts, then lulls
|
|
38
77
|
CHECK_TICKS = (3, 5, 4, 6, 3) # how long CI takes on a pull request
|
|
@@ -96,6 +135,7 @@ class Entry:
|
|
|
96
135
|
@dataclass
|
|
97
136
|
class Simulation:
|
|
98
137
|
clock: Clock
|
|
138
|
+
repo: Repo = WIDGETS
|
|
99
139
|
epoch: float = 0.0 # simulated time of tick 0
|
|
100
140
|
tick: int = 0
|
|
101
141
|
pulls: int = 0
|
|
@@ -108,7 +148,9 @@ class Simulation:
|
|
|
108
148
|
|
|
109
149
|
def __post_init__(self) -> None:
|
|
110
150
|
self.epoch = self.clock.now() - WARMUP * STEP
|
|
111
|
-
self.release =
|
|
151
|
+
self.release = None
|
|
152
|
+
if self.repo.release:
|
|
153
|
+
self.release = self.start_run("Release", self.repo.release, "main", 2, 0, "blocked")
|
|
112
154
|
self.advance()
|
|
113
155
|
|
|
114
156
|
def at(self, tick: int) -> float:
|
|
@@ -122,8 +164,8 @@ class Simulation:
|
|
|
122
164
|
|
|
123
165
|
def start_run(self, workflow: str, title: str, ref: str, ticks: int, salt: int, outcome: str):
|
|
124
166
|
run = Run(
|
|
125
|
-
|
|
126
|
-
self.tick + ticks, outcome,
|
|
167
|
+
self.repo.first_run + self.started, workflow, title, ref, sha(self.repo.name, ref, salt),
|
|
168
|
+
self.tick, self.tick + ticks, outcome,
|
|
127
169
|
) # fmt: skip
|
|
128
170
|
self.started += 1
|
|
129
171
|
self.runs.append(run)
|
|
@@ -152,6 +194,7 @@ class Simulation:
|
|
|
152
194
|
{
|
|
153
195
|
"id": f"attempt-{self.merges}",
|
|
154
196
|
"kind": "attempt",
|
|
197
|
+
"repo": self.repo.name,
|
|
155
198
|
"state": "failure" if failed else "success",
|
|
156
199
|
"raw_state": "failed" if failed else "merged",
|
|
157
200
|
"title": f"#{head.number} {head.title}",
|
|
@@ -160,7 +203,7 @@ class Simulation:
|
|
|
160
203
|
"owner": head.owner,
|
|
161
204
|
"started_at": iso(self.at(head.since)),
|
|
162
205
|
"finished_at": iso(self.at(tick)),
|
|
163
|
-
"url": f"{
|
|
206
|
+
"url": f"{self.repo.url}/pull/{head.number}",
|
|
164
207
|
"fields": {"pull_request": head.number},
|
|
165
208
|
}
|
|
166
209
|
)
|
|
@@ -175,17 +218,18 @@ class Simulation:
|
|
|
175
218
|
first.state, first.since = "merging", tick
|
|
176
219
|
del self.runs[: max(0, len(self.runs) - 14)]
|
|
177
220
|
del self.attempts[: max(0, len(self.attempts) - 40)] # about an hour of merges
|
|
178
|
-
if self.release not in self.runs:
|
|
221
|
+
if self.release and self.release not in self.runs:
|
|
179
222
|
self.runs.insert(0, self.release)
|
|
180
223
|
|
|
181
224
|
def arrive(self) -> None:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
225
|
+
pulls, phase = self.repo.pulls, self.pulls + self.repo.offset
|
|
226
|
+
title, ref, owner = pulls[self.pulls % len(pulls)]
|
|
227
|
+
number = self.repo.first_pull + self.pulls
|
|
228
|
+
fails = phase % FAILS_EVERY == FAILS_EVERY - 1
|
|
229
|
+
ticks = CHECK_TICKS[phase % len(CHECK_TICKS)]
|
|
186
230
|
run = self.start_run("CI", title, ref, ticks, self.tick, "failure" if fails else "success")
|
|
187
231
|
self.queue.append(Entry(number, title, ref, owner, self.tick, run, fails, since=self.tick))
|
|
188
|
-
self.next_arrival = self.tick + ARRIVAL_GAPS[
|
|
232
|
+
self.next_arrival = self.tick + ARRIVAL_GAPS[phase % len(ARRIVAL_GAPS)]
|
|
189
233
|
self.pulls += 1
|
|
190
234
|
|
|
191
235
|
def run_record(self, run: Run) -> dict:
|
|
@@ -214,6 +258,7 @@ class Simulation:
|
|
|
214
258
|
return {
|
|
215
259
|
"id": str(run.id),
|
|
216
260
|
"kind": "run",
|
|
261
|
+
"repo": self.repo.name,
|
|
217
262
|
"state": state,
|
|
218
263
|
"raw_state": {"running": "in_progress", "blocked": "waiting"}.get(state, state),
|
|
219
264
|
"title": run.workflow,
|
|
@@ -222,7 +267,7 @@ class Simulation:
|
|
|
222
267
|
"sha": run.sha,
|
|
223
268
|
"created_at": iso(self.at(run.start)),
|
|
224
269
|
"updated_at": iso(self.at(min(self.tick, run.end))),
|
|
225
|
-
"url": f"{
|
|
270
|
+
"url": f"{self.repo.url}/actions/runs/{run.id}",
|
|
226
271
|
"fields": {"run_id": run.id, "attempt": 1},
|
|
227
272
|
"children": jobs,
|
|
228
273
|
}
|
|
@@ -240,6 +285,7 @@ class Simulation:
|
|
|
240
285
|
return {
|
|
241
286
|
"id": f"entry-{entry.number}",
|
|
242
287
|
"kind": "queue_entry",
|
|
288
|
+
"repo": self.repo.name,
|
|
243
289
|
"state": state,
|
|
244
290
|
"raw_state": raw,
|
|
245
291
|
"title": f"#{entry.number} {entry.title}",
|
|
@@ -248,7 +294,7 @@ class Simulation:
|
|
|
248
294
|
"owner": entry.owner,
|
|
249
295
|
"position": position,
|
|
250
296
|
"created_at": iso(self.at(entry.joined)),
|
|
251
|
-
"url": f"{
|
|
297
|
+
"url": f"{self.repo.url}/pull/{entry.number}",
|
|
252
298
|
"fields": fields,
|
|
253
299
|
}
|
|
254
300
|
|
|
@@ -275,7 +321,7 @@ class Simulation:
|
|
|
275
321
|
"schema": SCHEMA,
|
|
276
322
|
"observed_at": iso(now),
|
|
277
323
|
"records": records + self.attempts[::-1],
|
|
278
|
-
"note":
|
|
324
|
+
"note": f"Demo data: {self.repo.name} is a simulated repository. Nothing here is real.",
|
|
279
325
|
}
|
|
280
326
|
|
|
281
327
|
|
|
@@ -298,9 +344,20 @@ class DemoMergeQueue(DemoCI):
|
|
|
298
344
|
return self.simulation.merge_queue()
|
|
299
345
|
|
|
300
346
|
|
|
301
|
-
def sources(clock: Clock) -> list[Source]:
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
347
|
+
def sources(clock: Clock, repos: int = 1) -> list[Source]:
|
|
348
|
+
"""One simulated repository, or two watched together the way a profile would."""
|
|
349
|
+
if repos == 1:
|
|
350
|
+
simulation = Simulation(clock)
|
|
351
|
+
return [
|
|
352
|
+
Source("ci", DemoCI.label, 2, DemoCI(simulation)),
|
|
353
|
+
Source("merge-queue", DemoMergeQueue.label, 2, DemoMergeQueue(simulation)),
|
|
354
|
+
]
|
|
355
|
+
result = []
|
|
356
|
+
for repo in (WIDGETS, GADGETS)[:repos]:
|
|
357
|
+
simulation = Simulation(clock, repo)
|
|
358
|
+
prefix, name = repo.name.replace("/", "-"), repo.name.split("/")[1]
|
|
359
|
+
result += [
|
|
360
|
+
Source(f"{prefix}-ci", f"{name} CI", 2, DemoCI(simulation)),
|
|
361
|
+
Source(f"{prefix}-merge-queue", f"{name} merge queue", 2, DemoMergeQueue(simulation)),
|
|
362
|
+
]
|
|
363
|
+
return result
|
|
@@ -29,10 +29,10 @@ def over_ssh(host: str, argv: list[str], cwd: str | None = None) -> list[str]:
|
|
|
29
29
|
return ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=8", host, "--", command]
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
async def
|
|
32
|
+
async def capture(
|
|
33
33
|
argv: list[str], *, stdin: str | None = None, timeout: float = 25, cwd: str | None = None
|
|
34
|
-
) ->
|
|
35
|
-
"""Run ``argv`` and
|
|
34
|
+
) -> tuple[int, bytes, bytes]:
|
|
35
|
+
"""Run ``argv`` and return ``(returncode, stdout, stderr)``.
|
|
36
36
|
|
|
37
37
|
The command gets its own process group so a timeout also stops children that
|
|
38
38
|
inherited its output pipes; otherwise they would hold the read open.
|
|
@@ -56,9 +56,21 @@ async def run_json(
|
|
|
56
56
|
with contextlib.suppress(TimeoutError):
|
|
57
57
|
await asyncio.wait_for(process.communicate(), 2)
|
|
58
58
|
raise
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
59
|
+
return process.returncode, stdout, stderr
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def failure(argv: list[str], returncode: int, stderr: bytes) -> RuntimeError:
|
|
63
|
+
message = stderr.decode(errors="replace").strip()[-1200:]
|
|
64
|
+
return RuntimeError(message or f"{argv[0]} exited {returncode}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def run_json(
|
|
68
|
+
argv: list[str], *, stdin: str | None = None, timeout: float = 25, cwd: str | None = None
|
|
69
|
+
) -> Any:
|
|
70
|
+
"""Run ``argv`` and parse stdout as JSON; a nonzero exit raises RuntimeError."""
|
|
71
|
+
returncode, stdout, stderr = await capture(argv, stdin=stdin, timeout=timeout, cwd=cwd)
|
|
72
|
+
if returncode:
|
|
73
|
+
raise failure(argv, returncode, stderr)
|
|
62
74
|
return json.loads(stdout)
|
|
63
75
|
|
|
64
76
|
|
|
@@ -12,6 +12,7 @@ import time
|
|
|
12
12
|
from collections import deque
|
|
13
13
|
from dataclasses import dataclass
|
|
14
14
|
from datetime import datetime
|
|
15
|
+
from itertools import pairwise
|
|
15
16
|
from typing import Any
|
|
16
17
|
from zoneinfo import ZoneInfo
|
|
17
18
|
|
|
@@ -391,6 +392,7 @@ class QueueChip(Static):
|
|
|
391
392
|
|
|
392
393
|
DEPTH_SAMPLES = 120 # one per fetch: half an hour at the default 15 s interval
|
|
393
394
|
SPARK_CELLS = 20
|
|
395
|
+
SPARK_MIN = 4 # narrower than this a sparkline shows no trend: drop it instead
|
|
394
396
|
|
|
395
397
|
|
|
396
398
|
class QueueStrip(Vertical):
|
|
@@ -408,6 +410,8 @@ class QueueStrip(Vertical):
|
|
|
408
410
|
self.built: tuple | None = None
|
|
409
411
|
# Queue length after each fetch this session; sources keep no history of it.
|
|
410
412
|
self.depth: deque[int] = deque(maxlen=DEPTH_SAMPLES)
|
|
413
|
+
# Cell widths of the head row's text, for fit(): title, eta, rate, stats.
|
|
414
|
+
self.text_cells = (0, 0, 0, 0)
|
|
411
415
|
|
|
412
416
|
def compose(self) -> ComposeResult:
|
|
413
417
|
with Horizontal(classes="queue-head"):
|
|
@@ -447,40 +451,87 @@ class QueueStrip(Vertical):
|
|
|
447
451
|
if self.merges:
|
|
448
452
|
last = short_age(now() - max(self.merges))
|
|
449
453
|
status += f" {SYMBOLS.dot} last merge {last} ago"
|
|
450
|
-
|
|
454
|
+
title = Content.assemble((status, "b"))
|
|
455
|
+
self.query_one(".queue-title", Static).update(title)
|
|
451
456
|
clears = max((eta_of(e) or 0 for e in self.entries), default=0)
|
|
452
|
-
|
|
453
|
-
Content.assemble(muted("clears in "), (eta_text(clears), "b")) if clears else
|
|
457
|
+
eta = (
|
|
458
|
+
Content.assemble(muted("clears in "), (eta_text(clears), "b")) if clears else Content()
|
|
454
459
|
)
|
|
455
|
-
|
|
456
|
-
self.set_class(len(self.depth) > 1, "-depth")
|
|
460
|
+
self.query_one(".queue-eta", Static).update(eta)
|
|
457
461
|
if len(self.depth) > 1:
|
|
458
|
-
|
|
459
|
-
depth.data = list(self.depth)
|
|
460
|
-
depth.styles.width = min(len(self.depth), SPARK_CELLS)
|
|
462
|
+
self.query_one(".depth.spark", Sparkline).data = list(self.depth)
|
|
461
463
|
merged = sum(now() - finished <= 3600 for finished in self.merges)
|
|
462
|
-
|
|
464
|
+
rate = (
|
|
463
465
|
Content.assemble(glyph("success"), muted(f" {merged} merged/hr"))
|
|
464
466
|
if self.history
|
|
465
|
-
else
|
|
467
|
+
else Content()
|
|
466
468
|
)
|
|
469
|
+
self.query_one(".rate", Static).update(rate)
|
|
467
470
|
took = [elapsed(a) for a in self.attempts[-30:]]
|
|
468
471
|
failed = sum(a["state"] == "failure" for a in self.attempts[-30:])
|
|
469
|
-
|
|
472
|
+
stats = Content()
|
|
470
473
|
if len(took) > 1:
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
),
|
|
480
|
-
glyph("failure"),
|
|
481
|
-
muted(f" {failed}/{len(took)}"),
|
|
482
|
-
)
|
|
474
|
+
self.query_one(".took.spark", Sparkline).data = took
|
|
475
|
+
stats = Content.assemble(
|
|
476
|
+
muted(
|
|
477
|
+
f" median {human_seconds(statistics.median(took))} {SYMBOLS.dot} "
|
|
478
|
+
f"max {human_seconds(max(took))} {SYMBOLS.dot} "
|
|
479
|
+
),
|
|
480
|
+
glyph("failure"),
|
|
481
|
+
muted(f" {failed}/{len(took)}"),
|
|
483
482
|
)
|
|
483
|
+
self.query_one(".took-stats", Static).update(stats)
|
|
484
|
+
self.text_cells = tuple(c.cell_length for c in (title, eta, rate, stats))
|
|
485
|
+
self.fit()
|
|
486
|
+
|
|
487
|
+
def head_width(self, depth: int | None, took: int | None, stats: bool) -> int:
|
|
488
|
+
"""Cells the head row needs with sparklines of these widths (None: hidden).
|
|
489
|
+
|
|
490
|
+
Mirrors the ``.queue-head`` rules in the app CSS: widths, margins (adjacent
|
|
491
|
+
margins collapse to the larger) and the ETA's ``min-width``."""
|
|
492
|
+
title, eta, rate, stats_cells = self.text_cells
|
|
493
|
+
boxes = [(title, 0, 0), (max(18, eta + 2), 0, 0)] # (width, margin-left, margin-right)
|
|
494
|
+
if depth is not None:
|
|
495
|
+
boxes += [(5, 2, 0), (depth, 1, 1)] # "depth" label, sparkline
|
|
496
|
+
boxes.append((rate, 2, 0))
|
|
497
|
+
if stats:
|
|
498
|
+
boxes.append((4, 2, 0)) # "took" label
|
|
499
|
+
if took is not None:
|
|
500
|
+
boxes.append((took, 1, 1))
|
|
501
|
+
boxes.append((stats_cells, 0, 0))
|
|
502
|
+
gaps = sum(max(left[2], right[1]) for left, right in pairwise(boxes))
|
|
503
|
+
return sum(box[0] for box in boxes) + boxes[0][1] + boxes[-1][2] + gaps
|
|
504
|
+
|
|
505
|
+
def fit(self) -> None:
|
|
506
|
+
"""Size the sparklines to the room the head row's text leaves, so the counts
|
|
507
|
+
never clip: shrink both, then drop the depth trend, then the took trend."""
|
|
508
|
+
room = self.query_one(".queue-head").size.width or self.app.size.width - 4
|
|
509
|
+
stats = len(self.attempts[-30:]) > 1
|
|
510
|
+
want_depth = min(len(self.depth), SPARK_CELLS) if len(self.depth) > 1 else None
|
|
511
|
+
want_took = min(len(self.attempts), SPARK_CELLS) if stats else None
|
|
512
|
+
for depth, took in ((want_depth, want_took), (None, want_took), (None, None)):
|
|
513
|
+
spare = room - self.head_width(
|
|
514
|
+
None if depth is None else 0, None if took is None else 0, stats
|
|
515
|
+
)
|
|
516
|
+
if depth is not None and took is not None:
|
|
517
|
+
depth = min(depth, max(spare // 2, spare - took))
|
|
518
|
+
took = min(took, spare - depth)
|
|
519
|
+
elif depth is not None:
|
|
520
|
+
depth = min(depth, spare)
|
|
521
|
+
elif took is not None:
|
|
522
|
+
took = min(took, spare)
|
|
523
|
+
# A short history keeps its full length; only a shrunk trend has a floor.
|
|
524
|
+
fits = ((depth, want_depth), (took, want_took))
|
|
525
|
+
if all(cells is None or cells >= min(want, SPARK_MIN) for cells, want in fits):
|
|
526
|
+
break
|
|
527
|
+
# Visibility is by class so that the narrow layout's CSS can still hide sparklines.
|
|
528
|
+
self.set_class(depth is not None, "-depth")
|
|
529
|
+
if depth is not None:
|
|
530
|
+
self.query_one(".depth.spark").styles.width = depth
|
|
531
|
+
self.set_class(stats, "-took")
|
|
532
|
+
self.set_class(stats and took is None, "-took-flat")
|
|
533
|
+
if took is not None:
|
|
534
|
+
self.query_one(".took.spark").styles.width = took
|
|
484
535
|
|
|
485
536
|
async def chain(self) -> None:
|
|
486
537
|
chain = self.query_one(".chain", Horizontal)
|
|
@@ -513,6 +564,7 @@ class QueueStrip(Vertical):
|
|
|
513
564
|
chain.display = bool(widgets)
|
|
514
565
|
|
|
515
566
|
async def on_resize(self) -> None:
|
|
567
|
+
self.fit()
|
|
516
568
|
if self.built is not None and self.built[0] != self.query_one(".chain").size.width:
|
|
517
569
|
await self.chain()
|
|
518
570
|
|
|
@@ -367,3 +367,42 @@ def test_markup_in_adapter_strings_is_shown_literally() -> None:
|
|
|
367
367
|
assert "[/oops]" in str(app.query_one("#tabs").get_tab("pane0-tab").label)
|
|
368
368
|
|
|
369
369
|
asyncio.run(run())
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_queue_head_sizes_sparklines_so_counts_never_clip() -> None:
|
|
373
|
+
attempts = [
|
|
374
|
+
{
|
|
375
|
+
"id": f"a{i}",
|
|
376
|
+
"kind": "attempt",
|
|
377
|
+
"state": "failure" if i % 3 == 0 else "success",
|
|
378
|
+
"title": f"#{i} Change",
|
|
379
|
+
"ref": f"change-{i}",
|
|
380
|
+
"started_at": iso(60 - i),
|
|
381
|
+
"finished_at": iso(59.5 - i - (i % 4) / 4), # 30 to 75 seconds
|
|
382
|
+
}
|
|
383
|
+
for i in range(30)
|
|
384
|
+
]
|
|
385
|
+
queue_view = {"kind": "queue_entry", "label": "Queue", "columns": [{"key": "title"}]}
|
|
386
|
+
|
|
387
|
+
async def run(records: list[dict], width: int) -> list[int]:
|
|
388
|
+
app = QueueWatch([Source("mq", "MQ", 60, Fake(records, [queue_view]))])
|
|
389
|
+
async with app.run_test(size=(width, 40)) as pilot:
|
|
390
|
+
await loaded(pilot)
|
|
391
|
+
strip = app.strips["mq"]
|
|
392
|
+
strip.depth.extend([3, 5, 2, 8] * 30) # a long session's depth history
|
|
393
|
+
strip.head()
|
|
394
|
+
await pilot.pause()
|
|
395
|
+
head = strip.query_one(".queue-head")
|
|
396
|
+
stats = strip.query_one(".took-stats", Static)
|
|
397
|
+
text = stats.render()
|
|
398
|
+
assert str(text).endswith("✗ 10/30")
|
|
399
|
+
assert stats.region.width == text.cell_length
|
|
400
|
+
assert stats.region.right <= head.region.right
|
|
401
|
+
sparks = [s.region.width for s in head.query(".spark") if s.display]
|
|
402
|
+
assert all(4 <= cells <= 20 for cells in sparks)
|
|
403
|
+
return sparks
|
|
404
|
+
|
|
405
|
+
# Queued and empty queues: the empty one's longer title leaves less room.
|
|
406
|
+
for records in (QUEUE + attempts, attempts):
|
|
407
|
+
assert asyncio.run(run(records, 120)) # at least one trend still fits
|
|
408
|
+
assert asyncio.run(run(records, 200)) == [20, 20]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from queue_watch import demo, widgets
|
|
6
|
+
from queue_watch.app import QueueWatch
|
|
7
|
+
from queue_watch.changes import correlate
|
|
8
|
+
from queue_watch.model import validate_snapshot
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Clock:
|
|
12
|
+
now_value = 1_800_000_000.0
|
|
13
|
+
|
|
14
|
+
def now(self) -> float:
|
|
15
|
+
return self.now_value
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize("repo", [demo.WIDGETS, demo.GADGETS], ids=lambda r: r.name)
|
|
19
|
+
def test_demo_queue_keeps_moving_with_valid_snapshots(repo: demo.Repo) -> None:
|
|
20
|
+
clock = Clock()
|
|
21
|
+
simulation = demo.Simulation(clock, repo)
|
|
22
|
+
lengths, merged = [], set()
|
|
23
|
+
for _ in range(400): # 400 ticks: well past every pattern's period
|
|
24
|
+
clock.now_value += demo.STEP
|
|
25
|
+
queue = validate_snapshot(simulation.merge_queue())
|
|
26
|
+
validate_snapshot(simulation.ci())
|
|
27
|
+
entries = [r for r in queue["records"] if r["kind"] == "queue_entry"]
|
|
28
|
+
assert [e["position"] for e in entries] == list(range(1, len(entries) + 1))
|
|
29
|
+
assert sum(e["raw_state"] == "merging" for e in entries) <= 1
|
|
30
|
+
lengths.append(len(entries))
|
|
31
|
+
merged |= {r["id"] for r in queue["records"] if r["kind"] == "attempt"}
|
|
32
|
+
assert len(merged) > 100
|
|
33
|
+
assert max(lengths) <= 8 # arrivals and merges balance; the queue never runs away
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_two_repo_demo_keeps_shared_branches_apart() -> None:
|
|
37
|
+
clock = Clock()
|
|
38
|
+
clock.now_value += 400 * demo.STEP # both repositories have run every pull request
|
|
39
|
+
sources = demo.sources(clock, 2)
|
|
40
|
+
assert len({s.id for s in sources}) == 4
|
|
41
|
+
snapshots = {s.id: validate_snapshot(asyncio.run(s.adapter.snapshot())) for s in sources}
|
|
42
|
+
repos = {(c.repo, c.ref) for c in correlate(snapshots)}
|
|
43
|
+
for ref in ("main", "chore/lockfile", "fix/errors"):
|
|
44
|
+
assert {("acme/widgets", ref), ("acme/gadgets", ref)} <= repos
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_two_repo_demo_loads_every_queue(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
48
|
+
# Four sources answer together; strips mounted mid-rebuild once crashed the app.
|
|
49
|
+
clock = demo.Clock()
|
|
50
|
+
monkeypatch.setattr(widgets, "now", clock.now)
|
|
51
|
+
|
|
52
|
+
async def run() -> None:
|
|
53
|
+
app = QueueWatch(demo.sources(clock, 2))
|
|
54
|
+
async with app.run_test(size=(112, 40)) as pilot:
|
|
55
|
+
for _ in range(100):
|
|
56
|
+
await pilot.pause(0.02)
|
|
57
|
+
if len(app.updated) == len(app.sources):
|
|
58
|
+
break
|
|
59
|
+
await app.workers.wait_for_complete()
|
|
60
|
+
assert set(app.strips) == {"acme-widgets-merge-queue", "acme-gadgets-merge-queue"}
|
|
61
|
+
|
|
62
|
+
asyncio.run(run())
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""GitHub Actions paging, precedence and state normalization; subprocess limits."""
|
|
1
|
+
"""GitHub Actions paging, precedence, revalidation and state normalization; subprocess limits."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -15,9 +15,26 @@ from queue_watch.adapters.github_actions import GitHubActions
|
|
|
15
15
|
from queue_watch.model import validate_snapshot
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def http(body, *, etag=None, link=None) -> tuple[int, bytes, bytes]:
|
|
19
|
+
"""What ``gh api --include`` prints for a 200."""
|
|
20
|
+
head = ["HTTP/2.0 200 OK"] + [f"Etag: {etag}"] * bool(etag) + [f"Link: {link}"] * bool(link)
|
|
21
|
+
return 0, ("\r\n".join(head) + "\r\n\r\n" + json.dumps(body)).encode(), b""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def not_modified(etag) -> tuple[int, bytes, bytes]:
|
|
25
|
+
"""``gh api --include`` on a 304: headers only, and a nonzero exit."""
|
|
26
|
+
return 1, f"HTTP/2.0 304 Not Modified\r\nEtag: {etag}\r\n\r\n".encode(), b"gh: HTTP 304\n"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def endpoint_of(argv) -> str:
|
|
30
|
+
return next(arg for arg in argv if arg.startswith("repos/"))
|
|
31
|
+
|
|
32
|
+
|
|
18
33
|
def test_old_active_runs_survive_recent_page_and_newer_state_wins(monkeypatch):
|
|
19
34
|
async def response(argv):
|
|
20
|
-
|
|
35
|
+
return http(body(endpoint_of(argv)))
|
|
36
|
+
|
|
37
|
+
def body(endpoint):
|
|
21
38
|
if "status=queued" in endpoint:
|
|
22
39
|
return {
|
|
23
40
|
"total_count": 101,
|
|
@@ -52,7 +69,7 @@ def test_old_active_runs_survive_recent_page_and_newer_state_wins(monkeypatch):
|
|
|
52
69
|
],
|
|
53
70
|
}
|
|
54
71
|
|
|
55
|
-
monkeypatch.setattr(github_actions.process, "
|
|
72
|
+
monkeypatch.setattr(github_actions.process, "capture", response)
|
|
56
73
|
snapshot = validate_snapshot(asyncio.run(GitHubActions(repo="owner/repo").snapshot()))
|
|
57
74
|
assert [(r["id"], r["state"], r["raw_state"]) for r in snapshot["records"]] == [
|
|
58
75
|
("5", "blocked", "waiting"),
|
|
@@ -62,9 +79,15 @@ def test_old_active_runs_survive_recent_page_and_newer_state_wins(monkeypatch):
|
|
|
62
79
|
assert snapshot["warnings"] == ["queued: showing first 100 of 101 runs"]
|
|
63
80
|
|
|
64
81
|
|
|
65
|
-
def
|
|
82
|
+
def test_jobs_become_child_records_with_steps_across_pages(monkeypatch):
|
|
83
|
+
async def first_page(argv):
|
|
84
|
+
if 'If-None-Match: W/"j"' in argv:
|
|
85
|
+
return not_modified('W/"j"')
|
|
86
|
+
return http(
|
|
87
|
+
{"jobs": []}, etag='W/"j"', link='<https://api.github.com/x?page=2>; rel="next"'
|
|
88
|
+
)
|
|
89
|
+
|
|
66
90
|
async def response(argv):
|
|
67
|
-
assert "--paginate" in argv
|
|
68
91
|
return [
|
|
69
92
|
{
|
|
70
93
|
"jobs": [
|
|
@@ -88,15 +111,61 @@ def test_jobs_become_child_records_with_steps(monkeypatch):
|
|
|
88
111
|
{"jobs": [{"id": 8, "name": "lint", "status": "completed", "conclusion": "skipped"}]},
|
|
89
112
|
]
|
|
90
113
|
|
|
114
|
+
monkeypatch.setattr(github_actions.process, "capture", first_page)
|
|
91
115
|
monkeypatch.setattr(github_actions.process, "run_json", response)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
("
|
|
95
|
-
|
|
96
|
-
|
|
116
|
+
adapter = GitHubActions(repo="owner/repo")
|
|
117
|
+
for _ in range(2): # the second first-page request answers 304
|
|
118
|
+
jobs = asyncio.run(adapter.children({"id": "3"}))
|
|
119
|
+
# A 304 says nothing about later pages, so every page is still fetched.
|
|
120
|
+
assert [(j["title"], j["state"], j["fields"]["runner"]) for j in jobs] == [
|
|
121
|
+
("test", "running", "linux-1"),
|
|
122
|
+
("lint", "neutral", None),
|
|
123
|
+
]
|
|
97
124
|
assert [(s["title"], s["state"]) for s in jobs[0]["children"]] == [("Run", "failure")]
|
|
98
125
|
|
|
99
126
|
|
|
127
|
+
def test_unchanged_lists_are_revalidated_and_reuse_the_cached_body(monkeypatch):
|
|
128
|
+
etags = {}
|
|
129
|
+
queued = [{"id": 1, "status": "queued", "updated_at": "2026-10-20T03:00:00Z"}]
|
|
130
|
+
sent = []
|
|
131
|
+
|
|
132
|
+
async def response(argv):
|
|
133
|
+
endpoint = endpoint_of(argv)
|
|
134
|
+
header = next((arg for arg in argv if arg.startswith("If-None-Match: ")), None)
|
|
135
|
+
sent.append(header)
|
|
136
|
+
etag = etags.setdefault(endpoint, 'W/"1"')
|
|
137
|
+
if header == f"If-None-Match: {etag}":
|
|
138
|
+
return not_modified(etag)
|
|
139
|
+
runs = queued if endpoint.endswith("status=queued") else []
|
|
140
|
+
# The unfiltered list always has older pages; only its first page is read.
|
|
141
|
+
link = None if "status=" in endpoint else '<https://api.github.com/x?page=2>; rel="next"'
|
|
142
|
+
return http({"total_count": len(runs), "workflow_runs": runs}, etag=etag, link=link)
|
|
143
|
+
|
|
144
|
+
monkeypatch.setattr(github_actions.process, "capture", response)
|
|
145
|
+
adapter = GitHubActions(repo="owner/repo")
|
|
146
|
+
|
|
147
|
+
def ids():
|
|
148
|
+
return [r["id"] for r in asyncio.run(adapter.snapshot())["records"]]
|
|
149
|
+
|
|
150
|
+
assert ids() == ["1"]
|
|
151
|
+
assert sent == [None] * 6
|
|
152
|
+
sent.clear()
|
|
153
|
+
assert ids() == ["1"] # every list answered 304
|
|
154
|
+
assert all(header and header.startswith("If-None-Match: ") for header in sent)
|
|
155
|
+
etags["repos/owner/repo/actions/runs?per_page=100&status=queued"] = 'W/"2"'
|
|
156
|
+
queued.append({"id": 2, "status": "queued", "updated_at": "2026-10-20T03:01:00Z"})
|
|
157
|
+
assert ids() == ["2", "1"] # the changed list replaces its cached body
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_failed_request_raises_gh_message(monkeypatch):
|
|
161
|
+
async def response(argv):
|
|
162
|
+
return 1, b"HTTP/2.0 404 Not Found\r\n\r\n{}", b"gh: Not Found (HTTP 404)\n"
|
|
163
|
+
|
|
164
|
+
monkeypatch.setattr(github_actions.process, "capture", response)
|
|
165
|
+
with pytest.raises(RuntimeError, match="Not Found"):
|
|
166
|
+
asyncio.run(GitHubActions(repo="owner/repo").snapshot())
|
|
167
|
+
|
|
168
|
+
|
|
100
169
|
def test_repo_must_be_owner_slash_name():
|
|
101
170
|
with pytest.raises(ValueError, match="owner/repo"):
|
|
102
171
|
GitHubActions(repo="owner/repo; rm -rf /")
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
from queue_watch import demo
|
|
2
|
-
from queue_watch.model import validate_snapshot
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class Clock:
|
|
6
|
-
now_value = 1_800_000_000.0
|
|
7
|
-
|
|
8
|
-
def now(self) -> float:
|
|
9
|
-
return self.now_value
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
def test_demo_queue_keeps_moving_with_valid_snapshots() -> None:
|
|
13
|
-
clock = Clock()
|
|
14
|
-
simulation = demo.Simulation(clock)
|
|
15
|
-
lengths, merged = [], set()
|
|
16
|
-
for _ in range(400): # 400 ticks: well past every pattern's period
|
|
17
|
-
clock.now_value += demo.STEP
|
|
18
|
-
queue = validate_snapshot(simulation.merge_queue())
|
|
19
|
-
validate_snapshot(simulation.ci())
|
|
20
|
-
entries = [r for r in queue["records"] if r["kind"] == "queue_entry"]
|
|
21
|
-
assert [e["position"] for e in entries] == list(range(1, len(entries) + 1))
|
|
22
|
-
assert sum(e["raw_state"] == "merging" for e in entries) <= 1
|
|
23
|
-
lengths.append(len(entries))
|
|
24
|
-
merged |= {r["id"] for r in queue["records"] if r["kind"] == "attempt"}
|
|
25
|
-
assert len(merged) > 100
|
|
26
|
-
assert max(lengths) <= 8 # arrivals and merges balance; the queue never runs away
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw
RENAMED
|
File without changes
|
{queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw
RENAMED
|
File without changes
|
{queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|