queue-watch 0.1.0__tar.gz → 0.2.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 (41) hide show
  1. {queue_watch-0.1.0 → queue_watch-0.2.0}/PKG-INFO +22 -5
  2. {queue_watch-0.1.0 → queue_watch-0.2.0}/README.md +21 -4
  3. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/__init__.py +1 -1
  4. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/app.py +50 -7
  5. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/cli.py +12 -2
  6. queue_watch-0.2.0/src/queue_watch/demo.py +306 -0
  7. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/widgets.py +5 -5
  8. queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +262 -0
  9. queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +199 -0
  10. queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_layout[short].raw +162 -0
  11. queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_layout[wide].raw +262 -0
  12. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_app.py +1 -1
  13. queue_watch-0.2.0/tests/test_demo.py +26 -0
  14. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -257
  15. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -195
  16. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -158
  17. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -257
  18. {queue_watch-0.1.0 → queue_watch-0.2.0}/.gitignore +0 -0
  19. {queue_watch-0.1.0 → queue_watch-0.2.0}/LICENSE +0 -0
  20. {queue_watch-0.1.0 → queue_watch-0.2.0}/examples/queue-status.py +0 -0
  21. {queue_watch-0.1.0 → queue_watch-0.2.0}/examples/queue.json +0 -0
  22. {queue_watch-0.1.0 → queue_watch-0.2.0}/examples/sources.toml +0 -0
  23. {queue_watch-0.1.0 → queue_watch-0.2.0}/pyproject.toml +0 -0
  24. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/__init__.py +0 -0
  25. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/command.py +0 -0
  26. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/flock_queue.py +0 -0
  27. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/github_actions.py +0 -0
  28. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/github_merge_queue.py +0 -0
  29. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/changes.py +0 -0
  30. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/config.py +0 -0
  31. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/discovery.py +0 -0
  32. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/model.py +0 -0
  33. {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/process.py +0 -0
  34. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_changes.py +0 -0
  35. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_command.py +0 -0
  36. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_contract.py +0 -0
  37. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_discovery.py +0 -0
  38. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_flock_queue.py +0 -0
  39. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_github_actions.py +0 -0
  40. {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_github_merge_queue.py +0 -0
  41. {queue_watch-0.1.0 → queue_watch-0.2.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.1.0
3
+ Version: 0.2.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
@@ -29,7 +29,19 @@ Description-Content-Type: text/markdown
29
29
 
30
30
  Read-only terminal dashboard for CI runs and merge queues. It joins records from every source into one row per change, so a branch's CI runs and its merge-queue entry read together, and draws each merge queue as a chain of chips in queue order. queue-watch never reruns, cancels, merges or takes a lock.
31
31
 
32
- ![queue-watch showing a merge queue of four changes, the Changes table grouped by state, and the inspector for a failed change](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/screenshot.png)
32
+ ![queue-watch --demo: a merge queue landing changes one after another while CI runs finish, one fails and a fix goes through](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/demo.gif)
33
+
34
+ Try it without a GitHub login:
35
+
36
+ ```sh
37
+ uvx queue-watch --demo
38
+ ```
39
+
40
+ ## Why
41
+
42
+ `gh run watch` follows one run, and GitHub's web UI shows Actions and the merge queue on separate pages. When several pull requests are queued at once, the questions are about the whole set: which one is merging now, which are waiting on checks, which failure is holding the queue up, and when the last one should land.
43
+
44
+ queue-watch answers them on one screen for a whole repository and keeps refreshing on its own. It joins each change's CI runs and queue entry into one row, puts the attention cases first, and shows the queue in merge order with GitHub's estimates. It also watches queues GitHub never sees: a merge lock on a build host, or anything a short script can describe as JSON.
33
45
 
34
46
  ## Install
35
47
 
@@ -38,7 +50,7 @@ uv tool install queue-watch # or: pipx install queue-watch
38
50
  uvx queue-watch # run once without installing
39
51
  ```
40
52
 
41
- Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](https://cli.github.com/) (`gh auth login`).
53
+ Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](https://cli.github.com/) (`gh auth login`); `--demo` needs neither.
42
54
 
43
55
  ## Use
44
56
 
@@ -48,7 +60,8 @@ queue-watch --repo owner/repo # Actions plus the merge queue, if any
48
60
  queue-watch --profile work # ~/.config/queue-watch/work.toml
49
61
  queue-watch --config sources.toml # any combination of sources
50
62
  queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
51
- queue-watch --theme nord --ascii # any Textual theme; ASCII glyphs only
63
+ queue-watch --demo # a simulated repository
64
+ queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
52
65
  queue-watch --density comfortable # title, owner and PR on a second line
53
66
  ```
54
67
 
@@ -56,10 +69,14 @@ With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the cu
56
69
 
57
70
  The Changes tab groups changes into Needs attention, Running, Queued and Done; every view a source reports also gets its own tab. Done starts folded while any other group has changes; press enter on a group row, or click it, to fold or unfold it. Select a change or a queue chip to see its CI checks, the jobs of the most notable one, its queue position and ETA, and recent history in the inspector.
58
71
 
72
+ ![queue-watch showing a merge queue of four changes, the Changes table grouped by state, and the inspector for a failed change](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/screenshot.png)
73
+
59
74
  Each merge queue gets a strip above the tabs: its entries in merge order, when it should clear, merges in the last hour, and how long recent attempts took. Queue depth over the session appears as a sparkline once two fetches have answered; sources report only the current queue, so the history starts when queue-watch does. On terminals under 30 rows the entries shrink to one line each.
60
75
 
61
76
  Keyboard: arrows to move, `1` to `9` for tabs, `/` to search (esc to clear), `o` to open the selection in a browser, `c` to copy its SHA, `d` to switch row density, `r` to refresh, `p` to pause, `q` to quit. The mouse works too: click tabs, rows, chips and the inspector buttons, and scroll with the wheel. When a source fails, its last good data stays on screen and its status pill turns stale.
62
77
 
78
+ The default `queue-watch` theme draws on a black background and gives each region its own coloured box. Pass `--theme` with any Textual theme name, or pick one live from the command palette (ctrl+p).
79
+
63
80
  ### Repository configs need trust
64
81
 
65
82
  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:
@@ -134,7 +151,7 @@ uv run pytest -q # includes SVG snapshot tests
134
151
  uv run pytest -q --snapshot-update # after an intended layout change
135
152
  ```
136
153
 
137
- `docs/screenshot.png` is the wide layout snapshot rendered in a browser. 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.
154
+ `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.
138
155
 
139
156
  ## License
140
157
 
@@ -2,7 +2,19 @@
2
2
 
3
3
  Read-only terminal dashboard for CI runs and merge queues. It joins records from every source into one row per change, so a branch's CI runs and its merge-queue entry read together, and draws each merge queue as a chain of chips in queue order. queue-watch never reruns, cancels, merges or takes a lock.
4
4
 
5
- ![queue-watch showing a merge queue of four changes, the Changes table grouped by state, and the inspector for a failed change](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/screenshot.png)
5
+ ![queue-watch --demo: a merge queue landing changes one after another while CI runs finish, one fails and a fix goes through](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/demo.gif)
6
+
7
+ Try it without a GitHub login:
8
+
9
+ ```sh
10
+ uvx queue-watch --demo
11
+ ```
12
+
13
+ ## Why
14
+
15
+ `gh run watch` follows one run, and GitHub's web UI shows Actions and the merge queue on separate pages. When several pull requests are queued at once, the questions are about the whole set: which one is merging now, which are waiting on checks, which failure is holding the queue up, and when the last one should land.
16
+
17
+ queue-watch answers them on one screen for a whole repository and keeps refreshing on its own. It joins each change's CI runs and queue entry into one row, puts the attention cases first, and shows the queue in merge order with GitHub's estimates. It also watches queues GitHub never sees: a merge lock on a build host, or anything a short script can describe as JSON.
6
18
 
7
19
  ## Install
8
20
 
@@ -11,7 +23,7 @@ uv tool install queue-watch # or: pipx install queue-watch
11
23
  uvx queue-watch # run once without installing
12
24
  ```
13
25
 
14
- Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](https://cli.github.com/) (`gh auth login`).
26
+ Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](https://cli.github.com/) (`gh auth login`); `--demo` needs neither.
15
27
 
16
28
  ## Use
17
29
 
@@ -21,7 +33,8 @@ queue-watch --repo owner/repo # Actions plus the merge queue, if any
21
33
  queue-watch --profile work # ~/.config/queue-watch/work.toml
22
34
  queue-watch --config sources.toml # any combination of sources
23
35
  queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
24
- queue-watch --theme nord --ascii # any Textual theme; ASCII glyphs only
36
+ queue-watch --demo # a simulated repository
37
+ queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
25
38
  queue-watch --density comfortable # title, owner and PR on a second line
26
39
  ```
27
40
 
@@ -29,10 +42,14 @@ With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the cu
29
42
 
30
43
  The Changes tab groups changes into Needs attention, Running, Queued and Done; every view a source reports also gets its own tab. Done starts folded while any other group has changes; press enter on a group row, or click it, to fold or unfold it. Select a change or a queue chip to see its CI checks, the jobs of the most notable one, its queue position and ETA, and recent history in the inspector.
31
44
 
45
+ ![queue-watch showing a merge queue of four changes, the Changes table grouped by state, and the inspector for a failed change](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/screenshot.png)
46
+
32
47
  Each merge queue gets a strip above the tabs: its entries in merge order, when it should clear, merges in the last hour, and how long recent attempts took. Queue depth over the session appears as a sparkline once two fetches have answered; sources report only the current queue, so the history starts when queue-watch does. On terminals under 30 rows the entries shrink to one line each.
33
48
 
34
49
  Keyboard: arrows to move, `1` to `9` for tabs, `/` to search (esc to clear), `o` to open the selection in a browser, `c` to copy its SHA, `d` to switch row density, `r` to refresh, `p` to pause, `q` to quit. The mouse works too: click tabs, rows, chips and the inspector buttons, and scroll with the wheel. When a source fails, its last good data stays on screen and its status pill turns stale.
35
50
 
51
+ The default `queue-watch` theme draws on a black background and gives each region its own coloured box. Pass `--theme` with any Textual theme name, or pick one live from the command palette (ctrl+p).
52
+
36
53
  ### Repository configs need trust
37
54
 
38
55
  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:
@@ -107,7 +124,7 @@ uv run pytest -q # includes SVG snapshot tests
107
124
  uv run pytest -q --snapshot-update # after an intended layout change
108
125
  ```
109
126
 
110
- `docs/screenshot.png` is the wide layout snapshot rendered in a browser. 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.
127
+ `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.
111
128
 
112
129
  ## License
113
130
 
@@ -1,3 +1,3 @@
1
1
  """Read-only terminal dashboard for CI runs and merge queues."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
@@ -22,6 +22,7 @@ from textual.binding import Binding
22
22
  from textual.containers import Horizontal, Vertical
23
23
  from textual.content import Content
24
24
  from textual.events import Resize
25
+ from textual.theme import Theme
25
26
  from textual.widgets import Button, DataTable, Footer, Input, Static, TabbedContent, TabPane
26
27
 
27
28
  from . import widgets
@@ -65,10 +66,40 @@ CHANGES = "changes-tab"
65
66
  CHILD_TTL = 15
66
67
  BAR = 8 # job duration bar cells; a job line fits the narrowest inspector
67
68
 
69
+ # The default theme: a black backdrop like most terminals, with one hue per box (queue
70
+ # strips use accent, the tabs primary, the inspector secondary) to give the screen structure.
71
+ THEME = Theme(
72
+ name="queue-watch",
73
+ primary="#5FAFFF",
74
+ secondary="#AF87FF",
75
+ accent="#FF875F",
76
+ warning="#FFD75F",
77
+ error="#FF5F6D",
78
+ success="#5FD787",
79
+ foreground="#D4D4D4",
80
+ background="#000000",
81
+ surface="#000000",
82
+ panel="#000000",
83
+ dark=True,
84
+ variables={
85
+ "footer-background": "#000000",
86
+ "footer-key-foreground": "#FF875F",
87
+ "footer-description-foreground": "#9E9E9E",
88
+ "scrollbar": "#3A3A3A",
89
+ "scrollbar-hover": "#5A5A5A",
90
+ "scrollbar-active": "#5FAFFF",
91
+ "scrollbar-background": "#000000",
92
+ "scrollbar-background-hover": "#000000",
93
+ "scrollbar-background-active": "#000000",
94
+ "scrollbar-corner-color": "#000000",
95
+ "button-color-foreground": "#D4D4D4",
96
+ },
97
+ )
98
+
68
99
  CSS = """
69
100
  Screen { background: $surface; }
70
101
  #status { height: 1; padding: 0 1; background: $panel; color: $foreground; }
71
- #status #brand { width: auto; margin-right: 2; color: $primary; }
102
+ #status #brand { width: auto; margin-right: 2; color: $accent; }
72
103
  #status .pill { width: auto; padding: 0 1; margin-right: 1; }
73
104
  #status .pill.live { background: $success 25%; color: $text-success; }
74
105
  #status .pill.paused { background: $primary 20%; color: $text-primary; }
@@ -80,8 +111,18 @@ Screen { background: $surface; }
80
111
  .-narrow #status #counts-long { display: none; }
81
112
  .-narrow #status #counts-short { display: block; }
82
113
 
114
+ /* One coloured, rounded box per region, titled in its top border. */
115
+ .queue, #tabs, #inspector { border-title-style: bold; border-title-align: left; }
116
+ .queue { border: round $accent 70%; border-title-color: $accent; }
117
+ #tabs { border: round $primary 70%; border-title-color: $primary; }
118
+ #inspector { border: round $secondary 70%; border-title-color: $secondary; }
119
+ .-ascii .queue { border: ascii $accent 70%; }
120
+ .-ascii #tabs { border: ascii $primary 70%; }
121
+ .-ascii #inspector { border: ascii $secondary 70%; }
122
+ .-short .queue { border: none; border-top: solid $accent 70%; }
123
+
83
124
  #queues { height: auto; }
84
- .queue { height: auto; padding: 0 1; border-bottom: solid $foreground 20%; }
125
+ .queue { height: auto; padding: 0 1; }
85
126
  .queue-head { height: 1; }
86
127
  .queue-head .queue-title { width: auto; }
87
128
  .queue-head .queue-eta { width: 1fr; min-width: 18; padding-left: 2; }
@@ -121,7 +162,8 @@ Tabs { height: 2; }
121
162
  OptionList {
122
163
  height: 1fr; background: $surface; border: none; padding: 0; scrollbar-size-vertical: 1;
123
164
  }
124
- OptionList:focus { border: none; }
165
+ OptionList:focus { border: none; background-tint: $foreground 0%; }
166
+ DataTable:focus { background-tint: $foreground 0%; }
125
167
  OptionList > .option-list--option { padding: 0 1; }
126
168
  OptionList > .option-list--option-highlighted { background: $primary 28%; color: $foreground; text-style: none; }
127
169
  OptionList:focus > .option-list--option-highlighted { background: $primary 40%; color: $foreground; text-style: none; }
@@ -132,14 +174,14 @@ DataTable > .datatable--header { background: $panel; color: $text-muted; text-st
132
174
  DataTable > .datatable--cursor { background: $primary 30%; color: $foreground; }
133
175
 
134
176
  #inspector {
135
- width: 32%; min-width: 40; max-width: 64; height: 1fr; padding: 0 1;
136
- border-left: solid $foreground 20%; background: $surface; scrollbar-size-vertical: 1;
177
+ width: 32%; min-width: 42; max-width: 66; height: 1fr; padding: 0 1;
178
+ background: $surface; scrollbar-size-vertical: 1;
137
179
  }
138
180
  #insp-head { height: auto; padding-bottom: 1; }
139
181
  #insp-actions { height: auto; padding-bottom: 1; }
140
182
  #insp-actions Button { margin-right: 1; background: $primary 30%; color: $foreground; }
141
183
  #sections { height: auto; }
142
- .section-title { background: $panel; padding: 0 1; margin-top: 1; height: 1; }
184
+ .section-title { background: $panel; color: $secondary; text-style: bold; padding: 0 1; margin-top: 1; height: 1; }
143
185
  .section-body { height: auto; padding: 0 1; }
144
186
 
145
187
  #notices { display: none; height: auto; max-height: 4; padding: 0 1; color: $text-warning; background: $panel; }
@@ -249,11 +291,12 @@ class QueueWatch(App):
249
291
  self,
250
292
  sources: list[Source],
251
293
  *,
252
- theme: str = "tokyo-night",
294
+ theme: str = THEME.name,
253
295
  ascii: bool = False,
254
296
  density: str = "compact",
255
297
  ):
256
298
  super().__init__()
299
+ self.register_theme(THEME)
257
300
  self.sources = {source.id: source for source in sources}
258
301
  self.order = list(self.sources)
259
302
  self.initial_theme = theme
@@ -17,6 +17,7 @@ Examples:
17
17
  queue-watch --profile work # ~/.config/queue-watch/work.toml
18
18
  queue-watch --config sources.toml --once
19
19
  queue-watch trust # after reviewing .queue-watch.toml
20
+ queue-watch --demo # simulated repository, no login needed
20
21
  """
21
22
 
22
23
  from __future__ import annotations
@@ -86,9 +87,12 @@ def main(argv: list[str] | None = None) -> int:
86
87
  group.add_argument("--repo", help="GitHub owner/repo: Actions plus any merge queue")
87
88
  group.add_argument("--config", type=Path, help="TOML file with [[source]] tables")
88
89
  group.add_argument("--profile", help="NAME for ~/.config/queue-watch/NAME.toml")
90
+ group.add_argument(
91
+ "--demo", action="store_true", help="simulated repository; no network or login"
92
+ )
89
93
  parser.add_argument("--once", action="store_true", help="print JSON once; no TUI")
90
94
  parser.add_argument(
91
- "--theme", default="tokyo-night", help="Textual theme (default tokyo-night)"
95
+ "--theme", default="queue-watch", help="Textual theme (default queue-watch: black)"
92
96
  )
93
97
  parser.add_argument("--ascii", action="store_true", help="ASCII glyphs and borders only")
94
98
  parser.add_argument(
@@ -100,7 +104,13 @@ def main(argv: list[str] | None = None) -> int:
100
104
  )
101
105
  args = parser.parse_args(argv)
102
106
  try:
103
- if args.config:
107
+ if args.demo:
108
+ from . import demo, widgets
109
+
110
+ clock = demo.Clock()
111
+ widgets.now = clock.now # ages and estimates follow the simulated clock
112
+ sources = demo.sources(clock)
113
+ elif args.config:
104
114
  sources = load(args.config.expanduser())
105
115
  elif args.profile:
106
116
  sources = load(profile_path(args.profile))
@@ -0,0 +1,306 @@
1
+ """``--demo``: a made-up repository whose CI and merge queue move on their own.
2
+
3
+ Nothing touches the network. Simulated time runs at ``SPEED`` times the wall clock
4
+ so a minute of watching shows several merges, a failed check and its fix. Events
5
+ come from fixed patterns, so every run of the demo tells the same story.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from datetime import UTC, datetime
14
+
15
+ from .adapters import Adapter
16
+ from .config import Source
17
+ from .model import SCHEMA
18
+
19
+ SPEED = 15 # simulated seconds per wall-clock second
20
+ STEP = 30 # simulated seconds per tick
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"),
36
+ )
37
+ ARRIVAL_GAPS = (2, 1, 3, 6, 7, 2, 3, 8) # ticks between arrivals: bursts, then lulls
38
+ CHECK_TICKS = (3, 5, 4, 6, 3) # how long CI takes on a pull request
39
+ FAILS_EVERY = 4 # every fourth pull request fails CI once, then gets a fix pushed
40
+ FIX_AFTER = 5 # ticks between a failure and the fix being pushed
41
+ MERGE_TICKS = (3, 4, 2, 3, 5) # how long merging takes, by pull request
42
+ MERGE_FAILS_EVERY = 6 # every sixth merge attempt fails and leaves the queue
43
+
44
+
45
+ class Clock:
46
+ """Simulated epoch seconds, starting at the wall clock and running ``SPEED`` times faster."""
47
+
48
+ def __init__(self) -> None:
49
+ self.start = time.time()
50
+ self.origin = time.monotonic()
51
+
52
+ def now(self) -> float:
53
+ return self.start + (time.monotonic() - self.origin) * SPEED
54
+
55
+
56
+ def iso(seconds: float) -> str:
57
+ return datetime.fromtimestamp(seconds, UTC).isoformat(timespec="seconds")
58
+
59
+
60
+ def sha(*parts: object) -> str:
61
+ return hashlib.sha1("-".join(map(str, parts)).encode()).hexdigest()[:10]
62
+
63
+
64
+ @dataclass
65
+ class Run:
66
+ id: int
67
+ workflow: str
68
+ title: str
69
+ ref: str
70
+ sha: str
71
+ start: int # tick
72
+ end: int # tick the run finishes
73
+ outcome: str # final state: success or failure
74
+
75
+ def state(self, tick: int) -> str:
76
+ return "running" if tick < self.end else self.outcome
77
+
78
+
79
+ @dataclass
80
+ class Entry:
81
+ number: int
82
+ title: str
83
+ ref: str
84
+ owner: str
85
+ joined: int
86
+ run: Run
87
+ fails: bool # CI fails once before a fix lands
88
+ state: str = "checks" # checks, queued, merging, blocked
89
+ since: int = 0 # tick the current state began
90
+
91
+ @property
92
+ def merge_ticks(self) -> int:
93
+ return MERGE_TICKS[self.number % len(MERGE_TICKS)]
94
+
95
+
96
+ @dataclass
97
+ class Simulation:
98
+ clock: Clock
99
+ epoch: float = 0.0 # simulated time of tick 0
100
+ tick: int = 0
101
+ pulls: int = 0
102
+ started: int = 0 # runs started, for unique run ids
103
+ merges: int = 0
104
+ next_arrival: int = 0
105
+ queue: list[Entry] = field(default_factory=list)
106
+ runs: list[Run] = field(default_factory=list)
107
+ attempts: list[dict] = field(default_factory=list)
108
+
109
+ def __post_init__(self) -> None:
110
+ self.epoch = self.clock.now() - WARMUP * STEP
111
+ self.release = self.start_run("Release", "v2.4.0", "main", 2, 0, "blocked")
112
+ self.advance()
113
+
114
+ def at(self, tick: int) -> float:
115
+ return self.epoch + tick * STEP
116
+
117
+ def advance(self) -> None:
118
+ target = int((self.clock.now() - self.epoch) // STEP)
119
+ while self.tick < target:
120
+ self.tick += 1
121
+ self.step()
122
+
123
+ def start_run(self, workflow: str, title: str, ref: str, ticks: int, salt: int, outcome: str):
124
+ run = Run(
125
+ 9100 + self.started, workflow, title, ref, sha(ref, salt), self.tick,
126
+ self.tick + ticks, outcome,
127
+ ) # fmt: skip
128
+ self.started += 1
129
+ self.runs.append(run)
130
+ return run
131
+
132
+ def step(self) -> None:
133
+ tick = self.tick
134
+ if tick >= self.next_arrival:
135
+ self.arrive()
136
+ for entry in self.queue:
137
+ if entry.state == "checks" and tick >= entry.run.end:
138
+ entry.state = "blocked" if entry.run.outcome == "failure" else "queued"
139
+ entry.since = tick
140
+ elif entry.state == "blocked" and tick - entry.since >= FIX_AFTER:
141
+ entry.fails = False
142
+ entry.run = self.start_run(
143
+ "CI", entry.title, entry.ref, CHECK_TICKS[entry.number % len(CHECK_TICKS)],
144
+ tick, "success",
145
+ ) # fmt: skip
146
+ entry.state, entry.since = "checks", tick
147
+ head = next((e for e in self.queue if e.state == "merging"), None)
148
+ if head and tick - head.since >= head.merge_ticks:
149
+ self.merges += 1
150
+ failed = self.merges % MERGE_FAILS_EVERY == 0
151
+ self.attempts.append(
152
+ {
153
+ "id": f"attempt-{self.merges}",
154
+ "kind": "attempt",
155
+ "state": "failure" if failed else "success",
156
+ "raw_state": "failed" if failed else "merged",
157
+ "title": f"#{head.number} {head.title}",
158
+ "ref": head.ref,
159
+ "sha": head.run.sha,
160
+ "owner": head.owner,
161
+ "started_at": iso(self.at(head.since)),
162
+ "finished_at": iso(self.at(tick)),
163
+ "url": f"{REPO}/pull/{head.number}",
164
+ "fields": {"pull_request": head.number},
165
+ }
166
+ )
167
+ self.queue.remove(head)
168
+ if not failed:
169
+ self.start_run("CI", f"Merge #{head.number}", "main", 3, tick, "success")
170
+ head = None
171
+ if head is None:
172
+ # Merges go in queue order; failed entries wait aside for their fix.
173
+ first = next((e for e in self.queue if e.state != "blocked"), None)
174
+ if first and first.state == "queued":
175
+ first.state, first.since = "merging", tick
176
+ del self.runs[: max(0, len(self.runs) - 14)]
177
+ del self.attempts[: max(0, len(self.attempts) - 40)] # about an hour of merges
178
+ if self.release not in self.runs:
179
+ self.runs.insert(0, self.release)
180
+
181
+ 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)]
186
+ run = self.start_run("CI", title, ref, ticks, self.tick, "failure" if fails else "success")
187
+ 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)]
189
+ self.pulls += 1
190
+
191
+ def run_record(self, run: Run) -> dict:
192
+ state = run.outcome if run.outcome == "blocked" else run.state(self.tick)
193
+ lint_end = min(run.start + 1, run.end)
194
+ jobs = []
195
+ for name, start, end, outcome in (
196
+ ("lint", run.start, lint_end, "success"),
197
+ ("test (3.12)", run.start, run.end, run.outcome),
198
+ ):
199
+ done = self.tick >= end and outcome != "blocked"
200
+ job_state = outcome if done else ("queued" if outcome == "blocked" else "running")
201
+ job = {
202
+ "id": f"{run.id}-{name}",
203
+ "kind": "job",
204
+ "state": job_state,
205
+ "raw_state": job_state,
206
+ "title": name,
207
+ "started_at": iso(self.at(start)),
208
+ "fields": {"runner": "ubuntu-24.04"},
209
+ }
210
+ if done:
211
+ job["finished_at"] = iso(self.at(end))
212
+ job["fields"]["duration_seconds"] = (end - start) * STEP
213
+ jobs.append(job)
214
+ return {
215
+ "id": str(run.id),
216
+ "kind": "run",
217
+ "state": state,
218
+ "raw_state": {"running": "in_progress", "blocked": "waiting"}.get(state, state),
219
+ "title": run.workflow,
220
+ "subtitle": run.title,
221
+ "ref": run.ref,
222
+ "sha": run.sha,
223
+ "created_at": iso(self.at(run.start)),
224
+ "updated_at": iso(self.at(min(self.tick, run.end))),
225
+ "url": f"{REPO}/actions/runs/{run.id}",
226
+ "fields": {"run_id": run.id, "attempt": 1},
227
+ "children": jobs,
228
+ }
229
+
230
+ def entry_record(self, entry: Entry, position: int, eta: float | None) -> dict:
231
+ state, raw = {
232
+ "checks": ("queued", "awaiting_checks"),
233
+ "queued": ("queued", "queued"),
234
+ "merging": ("running", "merging"),
235
+ "blocked": ("blocked", "unmergeable"),
236
+ }[entry.state]
237
+ fields: dict = {"pull_request": entry.number}
238
+ if eta is not None:
239
+ fields["estimated_seconds"] = round(eta)
240
+ return {
241
+ "id": f"entry-{entry.number}",
242
+ "kind": "queue_entry",
243
+ "state": state,
244
+ "raw_state": raw,
245
+ "title": f"#{entry.number} {entry.title}",
246
+ "ref": entry.ref,
247
+ "sha": entry.run.sha,
248
+ "owner": entry.owner,
249
+ "position": position,
250
+ "created_at": iso(self.at(entry.joined)),
251
+ "url": f"{REPO}/pull/{entry.number}",
252
+ "fields": fields,
253
+ }
254
+
255
+ def ci(self) -> dict:
256
+ self.advance()
257
+ records = [self.run_record(run) for run in reversed(self.runs)]
258
+ return {"schema": SCHEMA, "observed_at": iso(self.clock.now()), "records": records}
259
+
260
+ def merge_queue(self) -> dict:
261
+ self.advance()
262
+ now = self.clock.now()
263
+ records, ahead = [], 0.0
264
+ for position, entry in enumerate(self.queue, 1):
265
+ eta = None
266
+ if entry.state == "merging":
267
+ ahead = max(0.0, self.at(entry.since + entry.merge_ticks) - now)
268
+ eta = ahead
269
+ elif entry.state != "blocked":
270
+ checks = max(0.0, self.at(entry.run.end) - now)
271
+ ahead = max(ahead, checks) + entry.merge_ticks * STEP
272
+ eta = ahead
273
+ records.append(self.entry_record(entry, position, eta))
274
+ return {
275
+ "schema": SCHEMA,
276
+ "observed_at": iso(now),
277
+ "records": records + self.attempts[::-1],
278
+ "note": NOTE,
279
+ }
280
+
281
+
282
+ class DemoCI(Adapter):
283
+ name = "demo-ci"
284
+ label = "CI"
285
+
286
+ def __init__(self, simulation: Simulation) -> None:
287
+ self.simulation = simulation
288
+
289
+ async def snapshot(self) -> dict:
290
+ return self.simulation.ci()
291
+
292
+
293
+ class DemoMergeQueue(DemoCI):
294
+ name = "demo-merge-queue"
295
+ label = "Merge queue"
296
+
297
+ async def snapshot(self) -> dict:
298
+ return self.simulation.merge_queue()
299
+
300
+
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
+ ]
@@ -399,6 +399,7 @@ class QueueStrip(Vertical):
399
399
  def __init__(self, source: str, label: str) -> None:
400
400
  super().__init__(classes="queue")
401
401
  self.source, self.label = source, label
402
+ self.border_title = Content(clean(label))
402
403
  self.entries: list[dict] = []
403
404
  self.attempts: list[dict] = [] # with a duration: the "took" trend
404
405
  self.merges: list[float] = [] # finish times of every successful attempt
@@ -440,15 +441,13 @@ class QueueStrip(Vertical):
440
441
 
441
442
  def head(self) -> None:
442
443
  waiting = len(self.entries)
443
- status = f" {waiting} waiting"
444
+ status = f"{waiting} waiting"
444
445
  if not waiting:
445
- status = " nothing waiting"
446
+ status = "nothing waiting"
446
447
  if self.merges:
447
448
  last = short_age(now() - max(self.merges))
448
449
  status += f" {SYMBOLS.dot} last merge {last} ago"
449
- self.query_one(".queue-title", Static).update(
450
- Content.assemble((clean(self.label).upper(), "b"), muted(status))
451
- )
450
+ self.query_one(".queue-title", Static).update(Content.assemble((status, "b")))
452
451
  clears = max((eta_of(e) or 0 for e in self.entries), default=0)
453
452
  self.query_one(".queue-eta", Static).update(
454
453
  Content.assemble(muted("clears in "), (eta_text(clears), "b")) if clears else ""
@@ -733,6 +732,7 @@ class Inspector(VerticalScroll):
733
732
 
734
733
  def __init__(self) -> None:
735
734
  super().__init__(id="inspector")
735
+ self.border_title = Content("inspector")
736
736
  self.pairs: list[tuple[Static, Static]] = []
737
737
 
738
738
  def compose(self) -> ComposeResult: