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.
- {queue_watch-0.1.0 → queue_watch-0.2.0}/PKG-INFO +22 -5
- {queue_watch-0.1.0 → queue_watch-0.2.0}/README.md +21 -4
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/__init__.py +1 -1
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/app.py +50 -7
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/cli.py +12 -2
- queue_watch-0.2.0/src/queue_watch/demo.py +306 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/widgets.py +5 -5
- queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +262 -0
- queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +199 -0
- queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_layout[short].raw +162 -0
- queue_watch-0.2.0/tests/__snapshots__/test_snapshots/test_layout[wide].raw +262 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_app.py +1 -1
- queue_watch-0.2.0/tests/test_demo.py +26 -0
- queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -257
- queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -195
- queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -158
- queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -257
- {queue_watch-0.1.0 → queue_watch-0.2.0}/.gitignore +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/LICENSE +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/examples/queue-status.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/examples/queue.json +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/examples/sources.toml +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/pyproject.toml +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/__init__.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/command.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/flock_queue.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/github_actions.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/adapters/github_merge_queue.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/changes.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/config.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/discovery.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/model.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/src/queue_watch/process.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_changes.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_command.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_contract.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_discovery.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_flock_queue.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_github_actions.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.2.0}/tests/test_github_merge_queue.py +0 -0
- {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.
|
|
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
|
-

|
|
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 --
|
|
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
|
+

|
|
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
|
-

|
|
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 --
|
|
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
|
+

|
|
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
|
|
|
@@ -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: $
|
|
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;
|
|
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:
|
|
136
|
-
|
|
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 =
|
|
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="
|
|
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.
|
|
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"
|
|
444
|
+
status = f"{waiting} waiting"
|
|
444
445
|
if not waiting:
|
|
445
|
-
status = "
|
|
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:
|