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.
Files changed (38) hide show
  1. {queue_watch-0.3.0 → queue_watch-0.4.0}/PKG-INFO +5 -2
  2. {queue_watch-0.3.0 → queue_watch-0.4.0}/README.md +4 -1
  3. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/__init__.py +1 -1
  4. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_actions.py +45 -11
  5. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/app.py +7 -2
  6. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/cli.py +9 -2
  7. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/demo.py +91 -34
  8. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/process.py +18 -6
  9. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/widgets.py +75 -23
  10. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_app.py +39 -0
  11. queue_watch-0.4.0/tests/test_demo.py +62 -0
  12. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_github_actions.py +79 -10
  13. queue_watch-0.3.0/tests/test_demo.py +0 -26
  14. {queue_watch-0.3.0 → queue_watch-0.4.0}/.gitignore +0 -0
  15. {queue_watch-0.3.0 → queue_watch-0.4.0}/LICENSE +0 -0
  16. {queue_watch-0.3.0 → queue_watch-0.4.0}/examples/queue-status.py +0 -0
  17. {queue_watch-0.3.0 → queue_watch-0.4.0}/examples/queue.json +0 -0
  18. {queue_watch-0.3.0 → queue_watch-0.4.0}/examples/sources.toml +0 -0
  19. {queue_watch-0.3.0 → queue_watch-0.4.0}/pyproject.toml +0 -0
  20. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/__init__.py +0 -0
  21. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/command.py +0 -0
  22. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/flock_queue.py +0 -0
  23. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_merge_queue.py +0 -0
  24. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/changes.py +0 -0
  25. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/config.py +0 -0
  26. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/discovery.py +0 -0
  27. {queue_watch-0.3.0 → queue_watch-0.4.0}/src/queue_watch/model.py +0 -0
  28. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -0
  29. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -0
  30. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -0
  31. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -0
  32. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_changes.py +0 -0
  33. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_command.py +0 -0
  34. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_contract.py +0 -0
  35. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_discovery.py +0 -0
  36. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_flock_queue.py +0 -0
  37. {queue_watch-0.3.0 → queue_watch-0.4.0}/tests/test_github_merge_queue.py +0 -0
  38. {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.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
+ ![queue-watch --demo 2: two simulated repositories in one dashboard, each with its own merge queue strip, and rows that keep main and other shared branch names apart by repository](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/demo-profile.gif)
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` (needs 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.
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
+ ![queue-watch --demo 2: two simulated repositories in one dashboard, each with its own merge queue strip, and rows that keep main and other shared branch names apart by repository](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/demo-profile.gif)
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` (needs 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.
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
 
@@ -1,3 +1,3 @@
1
1
  """Read-only terminal dashboard for CI runs and merge queues."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.0"
@@ -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
- return await process.run_json(["gh", "api", endpoint])
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
- # 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
- )
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
- for source_id, strip in self.strips.items():
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
- if not self.rendering and event.data_table.id == self.active_pane():
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", action="store_true", help="simulated repository; no network or login"
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``: a made-up repository whose CI and merge queue move on their own.
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
- REPO = "https://github.com/acme/widgets"
23
- NOTE = "Demo data: a simulated repository. Nothing here is real."
24
-
25
- PULLS = (
26
- ("Stream large uploads", "feat/stream-upload", "dana"),
27
- ("Cache parsed headers", "feat/parser-cache", "lee"),
28
- ("Document merge queue", "docs/merge-queue", "sam"),
29
- ("Fix font fallback on Windows", "fix/font-fallback", "ari"),
30
- ("Faster diff rendering", "feat/diff-speed", "noor"),
31
- ("Retry flaky socket reads", "fix/socket-retry", "kai"),
32
- ("Bump lockfile", "chore/lockfile", "lee"),
33
- ("Add CSV exporter", "feat/csv-export", "dana"),
34
- ("Tidy error messages", "fix/errors", "sam"),
35
- ("Theme tokens for dark mode", "feat/theme", "noor"),
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 = self.start_run("Release", "v2.4.0", "main", 2, 0, "blocked")
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
- 9100 + self.started, workflow, title, ref, sha(ref, salt), self.tick,
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"{REPO}/pull/{head.number}",
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
- title, ref, owner = PULLS[self.pulls % len(PULLS)]
183
- number = 482 + self.pulls
184
- fails = self.pulls % FAILS_EVERY == FAILS_EVERY - 1
185
- ticks = CHECK_TICKS[self.pulls % len(CHECK_TICKS)]
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[self.pulls % len(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"{REPO}/actions/runs/{run.id}",
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"{REPO}/pull/{entry.number}",
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": 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
- simulation = Simulation(clock)
303
- return [
304
- Source("ci", DemoCI.label, 2, DemoCI(simulation)),
305
- Source("merge-queue", DemoMergeQueue.label, 2, DemoMergeQueue(simulation)),
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 run_json(
32
+ async def capture(
33
33
  argv: list[str], *, stdin: str | None = None, timeout: float = 25, cwd: str | None = None
34
- ) -> Any:
35
- """Run ``argv`` and parse stdout as JSON; a nonzero exit raises RuntimeError.
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
- if process.returncode:
60
- message = stderr.decode(errors="replace").strip()[-1200:]
61
- raise RuntimeError(message or f"{argv[0]} exited {process.returncode}")
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
- self.query_one(".queue-title", Static).update(Content.assemble((status, "b")))
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
- self.query_one(".queue-eta", Static).update(
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
- # Visibility is by class so that the narrow layout's CSS can still hide sparklines.
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
- depth = self.query_one(".depth.spark", Sparkline)
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
- self.query_one(".rate", Static).update(
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
- self.set_class(len(took) > 1, "-took")
472
+ stats = Content()
470
473
  if len(took) > 1:
471
- trend = self.query_one(".took.spark", Sparkline)
472
- trend.data = took
473
- trend.styles.width = min(len(took), SPARK_CELLS)
474
- self.query_one(".took-stats", Static).update(
475
- 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)}"),
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
- endpoint = argv[-1]
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, "run_json", response)
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 test_jobs_become_child_records_with_steps(monkeypatch):
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
- jobs = asyncio.run(GitHubActions(repo="owner/repo").children({"id": "3"}))
93
- assert [(j["title"], j["state"], j["fields"]["runner"]) for j in jobs] == [
94
- ("test", "running", "linux-1"),
95
- ("lint", "neutral", None),
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