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.
Files changed (43) hide show
  1. {queue_watch-0.1.0 → queue_watch-0.3.0}/PKG-INFO +38 -7
  2. {queue_watch-0.1.0 → queue_watch-0.3.0}/README.md +37 -6
  3. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/__init__.py +1 -1
  4. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_actions.py +1 -1
  5. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_merge_queue.py +1 -1
  6. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/app.py +52 -8
  7. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/changes.py +23 -8
  8. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/cli.py +74 -8
  9. queue_watch-0.3.0/src/queue_watch/demo.py +306 -0
  10. queue_watch-0.3.0/src/queue_watch/discovery.py +250 -0
  11. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/model.py +7 -6
  12. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/widgets.py +9 -7
  13. queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +262 -0
  14. queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +199 -0
  15. queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_layout[short].raw +162 -0
  16. queue_watch-0.3.0/tests/__snapshots__/test_snapshots/test_layout[wide].raw +262 -0
  17. {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_app.py +1 -1
  18. {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_changes.py +35 -0
  19. queue_watch-0.3.0/tests/test_demo.py +26 -0
  20. queue_watch-0.3.0/tests/test_discovery.py +148 -0
  21. queue_watch-0.1.0/src/queue_watch/discovery.py +0 -149
  22. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -257
  23. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -195
  24. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -158
  25. queue_watch-0.1.0/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -257
  26. queue_watch-0.1.0/tests/test_discovery.py +0 -81
  27. {queue_watch-0.1.0 → queue_watch-0.3.0}/.gitignore +0 -0
  28. {queue_watch-0.1.0 → queue_watch-0.3.0}/LICENSE +0 -0
  29. {queue_watch-0.1.0 → queue_watch-0.3.0}/examples/queue-status.py +0 -0
  30. {queue_watch-0.1.0 → queue_watch-0.3.0}/examples/queue.json +0 -0
  31. {queue_watch-0.1.0 → queue_watch-0.3.0}/examples/sources.toml +0 -0
  32. {queue_watch-0.1.0 → queue_watch-0.3.0}/pyproject.toml +0 -0
  33. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/__init__.py +0 -0
  34. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/command.py +0 -0
  35. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/adapters/flock_queue.py +0 -0
  36. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/config.py +0 -0
  37. {queue_watch-0.1.0 → queue_watch-0.3.0}/src/queue_watch/process.py +0 -0
  38. {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_command.py +0 -0
  39. {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_contract.py +0 -0
  40. {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_flock_queue.py +0 -0
  41. {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_github_actions.py +0 -0
  42. {queue_watch-0.1.0 → queue_watch-0.3.0}/tests/test_github_merge_queue.py +0 -0
  43. {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.1.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
- ![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
 
@@ -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 --theme nord --ascii # any Textual theme; ASCII glyphs only
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. `--profile NAME` reads `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`).
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
+ ![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)
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
- ![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
 
@@ -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 --theme nord --ascii # any Textual theme; ASCII glyphs only
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. `--profile NAME` reads `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`).
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
+ ![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)
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
 
@@ -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.3.0"
@@ -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: $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
@@ -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.ref or change.key)]
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. Kinds with a role in a change: ``run`` (CI),
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 most recent."""
137
- by_ref: dict[str, Change] = {}
138
- by_sha: dict[str, Change] = {}
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
- change = (ref and by_ref.get(ref)) or (sha and by_sha.get(sha))
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
- change = Change(key=ref or sha)
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, and otherwise the current checkout's GitHub repository:
9
- Actions, plus the merge queue when the default branch has one. Nothing is ever
10
- changed: no reruns, cancellations, merges, lock acquisition or remote installs.
11
- Last good data stays visible on failure and is labelled stale. --once prints one
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 CONFIG_NAME, detect, github_sources, profile_path, repo_config, trust
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="tokyo-night", help="Textual theme (default tokyo-night)"
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.config:
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))