queue-watch 0.1.0__tar.gz → 0.3.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.3.0}/PKG-INFO +38 -7
- {queue_watch-0.1.0 → queue_watch-0.3.0}/README.md +37 -6
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/__init__.py +1 -1
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_actions.py +1 -1
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_merge_queue.py +1 -1
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/app.py +52 -8
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/changes.py +23 -8
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/cli.py +74 -8
- queue_watch-0.3.0/src/queue_watch/demo.py +306 -0
- queue_watch-0.3.0/src/queue_watch/discovery.py +250 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/model.py +7 -6
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/widgets.py +9 -7
- queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +262 -0
- queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +199 -0
- queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_layout[short].raw +162 -0
- queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_layout[wide].raw +262 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_app.py +1 -1
- {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_changes.py +35 -0
- queue_watch-0.3.0/tests/test_demo.py +26 -0
- queue_watch-0.3.0/tests/test_discovery.py +148 -0
- queue_watch-0.1.0/src/queue_watch/discovery.py +0 -149
- 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/tests/test_discovery.py +0 -81
- {queue_watch-0.1.0 → queue_watch-0.3.0}/.gitignore +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/LICENSE +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/examples/queue-status.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/examples/queue.json +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/examples/sources.toml +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/pyproject.toml +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/__init__.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/command.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/flock_queue.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/config.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/process.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_command.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_contract.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_flock_queue.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_github_actions.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_github_merge_queue.py +0 -0
- {queue_watch-0.1.0 → queue_watch-0.3.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.3.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
|
|
|
@@ -46,20 +58,39 @@ Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](htt
|
|
|
46
58
|
queue-watch # inside a GitHub checkout
|
|
47
59
|
queue-watch --repo owner/repo # Actions plus the merge queue, if any
|
|
48
60
|
queue-watch --profile work # ~/.config/queue-watch/work.toml
|
|
61
|
+
queue-watch profile add work --repo acme/api --repo acme/web
|
|
49
62
|
queue-watch --config sources.toml # any combination of sources
|
|
50
63
|
queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
|
|
51
|
-
queue-watch --
|
|
64
|
+
queue-watch --demo # a simulated repository
|
|
65
|
+
queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
|
|
52
66
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
53
67
|
```
|
|
54
68
|
|
|
55
|
-
With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the current Git checkout. Without one, it watches the checkout's GitHub repository: Actions runs, plus the merge queue when the default branch has one.
|
|
69
|
+
With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the current Git checkout. Without one, it watches the checkout's GitHub repository: Actions runs, plus the merge queue when the default branch has one. Outside a GitHub checkout it opens the profile named `default`.
|
|
56
70
|
|
|
57
71
|
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
72
|
|
|
73
|
+

|
|
74
|
+
|
|
59
75
|
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
76
|
|
|
61
77
|
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
78
|
|
|
79
|
+
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).
|
|
80
|
+
|
|
81
|
+
### Profiles
|
|
82
|
+
|
|
83
|
+
A profile is a named list of sources in `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`), so one dashboard can watch a group of repositories:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
queue-watch profile add work --repo acme/api --repo acme/web # creates or extends work.toml
|
|
87
|
+
queue-watch profile list # every profile and its sources
|
|
88
|
+
queue-watch --profile work
|
|
89
|
+
queue-watch profile add default --repo acme/api # opens outside any checkout
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`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
|
+
|
|
63
94
|
### Repository configs need trust
|
|
64
95
|
|
|
65
96
|
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:
|
|
@@ -124,7 +155,7 @@ The program must exit zero and print one snapshot (below) on stdout within `time
|
|
|
124
155
|
|
|
125
156
|
## Snapshot contract
|
|
126
157
|
|
|
127
|
-
Every adapter emits the same versioned snapshot (`queue-watch.snapshot/1`): a list of records with `id`, `kind`, `state` and `title`, plus optional `ref`, `sha`, `owner`, `position`, timestamps, `url`, `fields` and `children`. `state` is one of `queued`, `running`, `blocked`, `success`, `failure`, `cancelled`, `neutral` or `unknown`; the adapter's own term is kept in `raw_state`. Records that share a `ref` or `sha` describe the same change across sources. [`model.py`](https://github.com/wolfiesch/queue-watch/blob/main/src/queue_watch/model.py) defines and validates the contract, and `--once` prints it.
|
|
158
|
+
Every adapter emits the same versioned snapshot (`queue-watch.snapshot/1`): a list of records with `id`, `kind`, `state` and `title`, plus optional `ref`, `sha`, `repo`, `owner`, `position`, timestamps, `url`, `fields` and `children`. `state` is one of `queued`, `running`, `blocked`, `success`, `failure`, `cancelled`, `neutral` or `unknown`; the adapter's own term is kept in `raw_state`. Records that share a `ref` or `sha` describe the same change across sources. Once records name more than one `repo` (`owner/name`, set by the GitHub adapters), a change also needs the same `repo`, and records without one join only each other; a `command` source watching several repositories should set it. [`model.py`](https://github.com/wolfiesch/queue-watch/blob/main/src/queue_watch/model.py) defines and validates the contract, and `--once` prints it.
|
|
128
159
|
|
|
129
160
|
## Development
|
|
130
161
|
|
|
@@ -134,7 +165,7 @@ uv run pytest -q # includes SVG snapshot tests
|
|
|
134
165
|
uv run pytest -q --snapshot-update # after an intended layout change
|
|
135
166
|
```
|
|
136
167
|
|
|
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.
|
|
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.
|
|
138
169
|
|
|
139
170
|
## License
|
|
140
171
|
|
|
@@ -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
|
|
|
@@ -19,20 +31,39 @@ Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](htt
|
|
|
19
31
|
queue-watch # inside a GitHub checkout
|
|
20
32
|
queue-watch --repo owner/repo # Actions plus the merge queue, if any
|
|
21
33
|
queue-watch --profile work # ~/.config/queue-watch/work.toml
|
|
34
|
+
queue-watch profile add work --repo acme/api --repo acme/web
|
|
22
35
|
queue-watch --config sources.toml # any combination of sources
|
|
23
36
|
queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
|
|
24
|
-
queue-watch --
|
|
37
|
+
queue-watch --demo # a simulated repository
|
|
38
|
+
queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
|
|
25
39
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
26
40
|
```
|
|
27
41
|
|
|
28
|
-
With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the current Git checkout. Without one, it watches the checkout's GitHub repository: Actions runs, plus the merge queue when the default branch has one.
|
|
42
|
+
With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the current Git checkout. Without one, it watches the checkout's GitHub repository: Actions runs, plus the merge queue when the default branch has one. Outside a GitHub checkout it opens the profile named `default`.
|
|
29
43
|
|
|
30
44
|
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
45
|
|
|
46
|
+

|
|
47
|
+
|
|
32
48
|
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
49
|
|
|
34
50
|
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
51
|
|
|
52
|
+
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).
|
|
53
|
+
|
|
54
|
+
### Profiles
|
|
55
|
+
|
|
56
|
+
A profile is a named list of sources in `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`), so one dashboard can watch a group of repositories:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
queue-watch profile add work --repo acme/api --repo acme/web # creates or extends work.toml
|
|
60
|
+
queue-watch profile list # every profile and its sources
|
|
61
|
+
queue-watch --profile work
|
|
62
|
+
queue-watch profile add default --repo acme/api # opens outside any checkout
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`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
|
+
|
|
36
67
|
### Repository configs need trust
|
|
37
68
|
|
|
38
69
|
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:
|
|
@@ -97,7 +128,7 @@ The program must exit zero and print one snapshot (below) on stdout within `time
|
|
|
97
128
|
|
|
98
129
|
## Snapshot contract
|
|
99
130
|
|
|
100
|
-
Every adapter emits the same versioned snapshot (`queue-watch.snapshot/1`): a list of records with `id`, `kind`, `state` and `title`, plus optional `ref`, `sha`, `owner`, `position`, timestamps, `url`, `fields` and `children`. `state` is one of `queued`, `running`, `blocked`, `success`, `failure`, `cancelled`, `neutral` or `unknown`; the adapter's own term is kept in `raw_state`. Records that share a `ref` or `sha` describe the same change across sources. [`model.py`](https://github.com/wolfiesch/queue-watch/blob/main/src/queue_watch/model.py) defines and validates the contract, and `--once` prints it.
|
|
131
|
+
Every adapter emits the same versioned snapshot (`queue-watch.snapshot/1`): a list of records with `id`, `kind`, `state` and `title`, plus optional `ref`, `sha`, `repo`, `owner`, `position`, timestamps, `url`, `fields` and `children`. `state` is one of `queued`, `running`, `blocked`, `success`, `failure`, `cancelled`, `neutral` or `unknown`; the adapter's own term is kept in `raw_state`. Records that share a `ref` or `sha` describe the same change across sources. Once records name more than one `repo` (`owner/name`, set by the GitHub adapters), a change also needs the same `repo`, and records without one join only each other; a `command` source watching several repositories should set it. [`model.py`](https://github.com/wolfiesch/queue-watch/blob/main/src/queue_watch/model.py) defines and validates the contract, and `--once` prints it.
|
|
101
132
|
|
|
102
133
|
## Development
|
|
103
134
|
|
|
@@ -107,7 +138,7 @@ uv run pytest -q # includes SVG snapshot tests
|
|
|
107
138
|
uv run pytest -q --snapshot-update # after an intended layout change
|
|
108
139
|
```
|
|
109
140
|
|
|
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.
|
|
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.
|
|
111
142
|
|
|
112
143
|
## License
|
|
113
144
|
|
|
@@ -76,7 +76,7 @@ class GitHubActions(Adapter):
|
|
|
76
76
|
return {
|
|
77
77
|
"schema": SCHEMA,
|
|
78
78
|
"observed_at": datetime.now(UTC).isoformat(),
|
|
79
|
-
"records": [self.record(run) for run in ordered],
|
|
79
|
+
"records": [{**self.record(run), "repo": self.repo} for run in ordered],
|
|
80
80
|
"views": [
|
|
81
81
|
{
|
|
82
82
|
"kind": "run",
|
|
@@ -82,7 +82,7 @@ class GitHubMergeQueue(Adapter):
|
|
|
82
82
|
warnings.append(
|
|
83
83
|
f"showing first {len(entries['nodes'])} of {entries['totalCount']} entries"
|
|
84
84
|
)
|
|
85
|
-
records = [self.record(entry) for entry in entries["nodes"]]
|
|
85
|
+
records = [{**self.record(entry), "repo": self.repo} for entry in entries["nodes"]]
|
|
86
86
|
return {
|
|
87
87
|
"schema": SCHEMA,
|
|
88
88
|
"observed_at": datetime.now(UTC).isoformat(),
|
|
@@ -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
|
|
@@ -593,7 +636,8 @@ class QueueWatch(App):
|
|
|
593
636
|
return bool(record.get("children")) or type(adapter).children is not Adapter.children
|
|
594
637
|
|
|
595
638
|
def change_view(self, change: Change):
|
|
596
|
-
meta = [clean(change.
|
|
639
|
+
meta = [clean(change.repo)] if change.repo else []
|
|
640
|
+
meta.append(clean(change.ref or change.sha or change.key))
|
|
597
641
|
if change.sha:
|
|
598
642
|
meta.append(clean(change.sha)[:7])
|
|
599
643
|
if change.pr:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Changes: records from every source that describe the same branch or commit.
|
|
2
2
|
|
|
3
3
|
Records correlate by ``ref`` and fall back to ``sha``; records with neither stay
|
|
4
|
-
in their source's own tab.
|
|
4
|
+
in their source's own tab. When records name more than one ``repo``, the same ref
|
|
5
|
+
in two repositories is two changes, and records without a ``repo`` form changes of
|
|
6
|
+
their own. Kinds with a role in a change: ``run`` (CI),
|
|
5
7
|
``queue_entry`` (live merge queue position) and ``attempt`` (a finished merge
|
|
6
8
|
attempt). Other kinds are only shown per source.
|
|
7
9
|
"""
|
|
@@ -51,6 +53,7 @@ class Change:
|
|
|
51
53
|
key: str
|
|
52
54
|
ref: str | None = None
|
|
53
55
|
sha: str | None = None
|
|
56
|
+
repo: str | None = None # set only when the snapshots span several repositories
|
|
54
57
|
runs: list[Hit] = field(default_factory=list) # newest first
|
|
55
58
|
entries: list[Hit] = field(default_factory=list) # queue order
|
|
56
59
|
attempts: list[Hit] = field(default_factory=list) # newest first
|
|
@@ -133,24 +136,36 @@ class Change:
|
|
|
133
136
|
|
|
134
137
|
|
|
135
138
|
def correlate(snapshots: dict[str, dict]) -> list[Change]:
|
|
136
|
-
"""One Change per ref (falling back to sha), in merge order, then
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
"""One Change per repository and ref (falling back to sha), in merge order, then
|
|
140
|
+
most recent. Repositories only separate changes when more than one appears."""
|
|
141
|
+
# GitHub repository names are case-insensitive.
|
|
142
|
+
repos = {
|
|
143
|
+
record["repo"].casefold()
|
|
144
|
+
for snapshot in snapshots.values()
|
|
145
|
+
for record in snapshot.get("records", [])
|
|
146
|
+
if record.get("repo")
|
|
147
|
+
}
|
|
148
|
+
several = len(repos) > 1
|
|
149
|
+
by_ref: dict[tuple[str | None, str], Change] = {}
|
|
150
|
+
by_sha: dict[tuple[str | None, str], Change] = {}
|
|
139
151
|
changes: list[Change] = []
|
|
140
152
|
for source, snapshot in snapshots.items():
|
|
141
153
|
for record in snapshot.get("records", []):
|
|
142
154
|
ref, sha = record.get("ref"), record.get("sha")
|
|
143
155
|
if record["kind"] not in ROLES or not (ref or sha):
|
|
144
156
|
continue
|
|
145
|
-
|
|
157
|
+
repo = (record.get("repo") or None) if several else None
|
|
158
|
+
scope = repo and repo.casefold()
|
|
159
|
+
change = (ref and by_ref.get((scope, ref))) or (sha and by_sha.get((scope, sha)))
|
|
146
160
|
if not change:
|
|
147
|
-
|
|
161
|
+
name = ref or sha
|
|
162
|
+
change = Change(key=f"{scope}:{name}" if scope else name, repo=repo)
|
|
148
163
|
changes.append(change)
|
|
149
164
|
if ref:
|
|
150
|
-
by_ref.setdefault(ref, change)
|
|
165
|
+
by_ref.setdefault((scope, ref), change)
|
|
151
166
|
change.ref = change.ref or ref
|
|
152
167
|
if sha:
|
|
153
|
-
by_sha.setdefault(sha, change)
|
|
168
|
+
by_sha.setdefault((scope, sha), change)
|
|
154
169
|
change.sha = change.sha or sha
|
|
155
170
|
hit = Hit(source, record)
|
|
156
171
|
match record["kind"]:
|
|
@@ -5,11 +5,11 @@ with the wheel. Keyboard: tab/arrows, / search, r refresh, p pause, o open the
|
|
|
5
5
|
selected record, q quit. Times are shown in America/Los_Angeles.
|
|
6
6
|
|
|
7
7
|
With no source flags, queue-watch uses the repository's .queue-watch.toml once
|
|
8
|
-
you have trusted it,
|
|
9
|
-
|
|
10
|
-
changed: no reruns, cancellations, merges, lock acquisition or
|
|
11
|
-
Last good data stays visible on failure and is labelled stale.
|
|
12
|
-
JSON snapshot per source and exits nonzero when any source fails.
|
|
8
|
+
you have trusted it, then the current checkout's GitHub repository (Actions, plus
|
|
9
|
+
the merge queue when the default branch has one), then the profile named default.
|
|
10
|
+
Nothing is ever changed: no reruns, cancellations, merges, lock acquisition or
|
|
11
|
+
remote installs. Last good data stays visible on failure and is labelled stale.
|
|
12
|
+
--once prints one JSON snapshot per source and exits nonzero when any source fails.
|
|
13
13
|
|
|
14
14
|
Examples:
|
|
15
15
|
queue-watch # inside a GitHub checkout
|
|
@@ -17,6 +17,9 @@ 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
|
|
21
|
+
queue-watch profile add work --repo acme/api --repo acme/web
|
|
22
|
+
queue-watch profile list
|
|
20
23
|
"""
|
|
21
24
|
|
|
22
25
|
from __future__ import annotations
|
|
@@ -30,7 +33,18 @@ from pathlib import Path
|
|
|
30
33
|
|
|
31
34
|
from . import __version__
|
|
32
35
|
from .config import ConfigError, Source, load
|
|
33
|
-
from .discovery import
|
|
36
|
+
from .discovery import (
|
|
37
|
+
CONFIG_NAME,
|
|
38
|
+
DEFAULT_PROFILE,
|
|
39
|
+
add_to_profile,
|
|
40
|
+
describe_profile,
|
|
41
|
+
detect,
|
|
42
|
+
github_sources,
|
|
43
|
+
profile_path,
|
|
44
|
+
profiles,
|
|
45
|
+
repo_config,
|
|
46
|
+
trust,
|
|
47
|
+
)
|
|
34
48
|
|
|
35
49
|
|
|
36
50
|
async def once(sources: list[Source]) -> int:
|
|
@@ -72,10 +86,53 @@ def trust_command(argv: list[str]) -> int:
|
|
|
72
86
|
return 0
|
|
73
87
|
|
|
74
88
|
|
|
89
|
+
def profile_command(argv: list[str]) -> int:
|
|
90
|
+
parser = argparse.ArgumentParser(
|
|
91
|
+
prog="queue-watch profile",
|
|
92
|
+
description="Manage profiles: named source lists under ~/.config/queue-watch. "
|
|
93
|
+
f"The {DEFAULT_PROFILE!r} profile is used outside a GitHub checkout.",
|
|
94
|
+
)
|
|
95
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
96
|
+
add = commands.add_parser(
|
|
97
|
+
"add",
|
|
98
|
+
help="add GitHub repositories to a profile, creating it if needed",
|
|
99
|
+
description="Append Actions, plus the merge queue when the default branch has "
|
|
100
|
+
"one, for each repository. Repositories already in the profile are kept as they are.",
|
|
101
|
+
)
|
|
102
|
+
add.add_argument("name", help=f"profile name; {DEFAULT_PROFILE!r} is used outside a checkout")
|
|
103
|
+
add.add_argument(
|
|
104
|
+
"--repo", action="append", required=True, metavar="OWNER/REPO", help="repeatable"
|
|
105
|
+
)
|
|
106
|
+
commands.add_parser("list", help="list profiles and their sources")
|
|
107
|
+
args = parser.parse_args(argv)
|
|
108
|
+
if args.command == "list":
|
|
109
|
+
found = profiles()
|
|
110
|
+
if not found:
|
|
111
|
+
print(f"No profiles in {profile_path(DEFAULT_PROFILE).parent}.")
|
|
112
|
+
print("Create one: queue-watch profile add NAME --repo owner/repo")
|
|
113
|
+
for path in found:
|
|
114
|
+
note = " (used outside a GitHub checkout)" if path.stem == DEFAULT_PROFILE else ""
|
|
115
|
+
print(f"{path.stem}{note}")
|
|
116
|
+
for line in describe_profile(path):
|
|
117
|
+
print(f" {line}")
|
|
118
|
+
return 0
|
|
119
|
+
try:
|
|
120
|
+
path, added, skipped = asyncio.run(add_to_profile(args.name, args.repo))
|
|
121
|
+
except (ConfigError, OSError, UnicodeDecodeError) as error:
|
|
122
|
+
parser.error(str(error))
|
|
123
|
+
for repo in added:
|
|
124
|
+
print(f"Added {repo} to {path}")
|
|
125
|
+
for repo in skipped:
|
|
126
|
+
print(f"{repo} is already in {path}")
|
|
127
|
+
return 0
|
|
128
|
+
|
|
129
|
+
|
|
75
130
|
def main(argv: list[str] | None = None) -> int:
|
|
76
131
|
argv = sys.argv[1:] if argv is None else argv
|
|
77
132
|
if argv[:1] == ["trust"]:
|
|
78
133
|
return trust_command(argv[1:])
|
|
134
|
+
if argv[:1] == ["profile"]:
|
|
135
|
+
return profile_command(argv[1:])
|
|
79
136
|
parser = argparse.ArgumentParser(
|
|
80
137
|
prog="queue-watch",
|
|
81
138
|
description=__doc__,
|
|
@@ -86,9 +143,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
86
143
|
group.add_argument("--repo", help="GitHub owner/repo: Actions plus any merge queue")
|
|
87
144
|
group.add_argument("--config", type=Path, help="TOML file with [[source]] tables")
|
|
88
145
|
group.add_argument("--profile", help="NAME for ~/.config/queue-watch/NAME.toml")
|
|
146
|
+
group.add_argument(
|
|
147
|
+
"--demo", action="store_true", help="simulated repository; no network or login"
|
|
148
|
+
)
|
|
89
149
|
parser.add_argument("--once", action="store_true", help="print JSON once; no TUI")
|
|
90
150
|
parser.add_argument(
|
|
91
|
-
"--theme", default="
|
|
151
|
+
"--theme", default="queue-watch", help="Textual theme (default queue-watch: black)"
|
|
92
152
|
)
|
|
93
153
|
parser.add_argument("--ascii", action="store_true", help="ASCII glyphs and borders only")
|
|
94
154
|
parser.add_argument(
|
|
@@ -100,7 +160,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
100
160
|
)
|
|
101
161
|
args = parser.parse_args(argv)
|
|
102
162
|
try:
|
|
103
|
-
if args.
|
|
163
|
+
if args.demo:
|
|
164
|
+
from . import demo, widgets
|
|
165
|
+
|
|
166
|
+
clock = demo.Clock()
|
|
167
|
+
widgets.now = clock.now # ages and estimates follow the simulated clock
|
|
168
|
+
sources = demo.sources(clock)
|
|
169
|
+
elif args.config:
|
|
104
170
|
sources = load(args.config.expanduser())
|
|
105
171
|
elif args.profile:
|
|
106
172
|
sources = load(profile_path(args.profile))
|