queue-watch 0.2.0__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queue_watch-0.2.0 → queue_watch-0.4.0}/PKG-INFO +21 -4
- {queue_watch-0.2.0 → queue_watch-0.4.0}/README.md +20 -3
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/__init__.py +1 -1
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_actions.py +46 -12
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_merge_queue.py +1 -1
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/app.py +9 -3
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/changes.py +23 -8
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/cli.py +71 -8
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/demo.py +91 -34
- queue_watch-0.4.0/src/queue_watch/discovery.py +250 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/model.py +7 -6
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/process.py +18 -6
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/widgets.py +79 -25
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_app.py +39 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_changes.py +35 -0
- queue_watch-0.4.0/tests/test_demo.py +62 -0
- queue_watch-0.4.0/tests/test_discovery.py +148 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_github_actions.py +79 -10
- queue_watch-0.2.0/src/queue_watch/discovery.py +0 -149
- queue_watch-0.2.0/tests/test_demo.py +0 -26
- queue_watch-0.2.0/tests/test_discovery.py +0 -81
- {queue_watch-0.2.0 → queue_watch-0.4.0}/.gitignore +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/LICENSE +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/examples/queue-status.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/examples/queue.json +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/examples/sources.toml +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/pyproject.toml +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/__init__.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/command.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/flock_queue.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/config.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_command.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_contract.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_flock_queue.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_github_merge_queue.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_snapshots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: queue-watch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Read-only terminal dashboard for CI runs and merge queues, with mouse support
|
|
5
5
|
Project-URL: Homepage, https://github.com/wolfiesch/queue-watch
|
|
6
6
|
Project-URL: Issues, https://github.com/wolfiesch/queue-watch/issues
|
|
@@ -58,14 +58,16 @@ Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](htt
|
|
|
58
58
|
queue-watch # inside a GitHub checkout
|
|
59
59
|
queue-watch --repo owner/repo # Actions plus the merge queue, if any
|
|
60
60
|
queue-watch --profile work # ~/.config/queue-watch/work.toml
|
|
61
|
+
queue-watch profile add work --repo acme/api --repo acme/web
|
|
61
62
|
queue-watch --config sources.toml # any combination of sources
|
|
62
63
|
queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
|
|
63
64
|
queue-watch --demo # a simulated repository
|
|
65
|
+
queue-watch --demo 2 # two simulated repositories, as in a profile
|
|
64
66
|
queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
|
|
65
67
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
66
68
|
```
|
|
67
69
|
|
|
68
|
-
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.
|
|
70
|
+
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`.
|
|
69
71
|
|
|
70
72
|
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.
|
|
71
73
|
|
|
@@ -77,6 +79,21 @@ Keyboard: arrows to move, `1` to `9` for tabs, `/` to search (esc to clear), `o`
|
|
|
77
79
|
|
|
78
80
|
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
81
|
|
|
82
|
+
### Profiles
|
|
83
|
+
|
|
84
|
+
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:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
queue-watch profile add work --repo acme/api --repo acme/web # creates or extends work.toml
|
|
88
|
+
queue-watch profile list # every profile and its sources
|
|
89
|
+
queue-watch --profile work
|
|
90
|
+
queue-watch profile add default --repo acme/api # opens outside any checkout
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`profile add` writes Actions for each repository, plus its merge queue when the default branch has one, with the repository's name in each label. Repositories already in the profile are left alone, and anything written by hand stays. A profile is ordinary [source config](#sources), so you can add `command` and `flock-queue` sources too. When a dashboard spans several repositories, a branch name such as `main` in two of them is two rows, each labelled with its repository.
|
|
94
|
+
|
|
95
|
+

|
|
96
|
+
|
|
80
97
|
### Repository configs need trust
|
|
81
98
|
|
|
82
99
|
A config can run commands and open SSH connections, so a `.queue-watch.toml` that arrives with a clone does nothing until you review it and run:
|
|
@@ -141,7 +158,7 @@ The program must exit zero and print one snapshot (below) on stdout within `time
|
|
|
141
158
|
|
|
142
159
|
## Snapshot contract
|
|
143
160
|
|
|
144
|
-
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.
|
|
161
|
+
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.
|
|
145
162
|
|
|
146
163
|
## Development
|
|
147
164
|
|
|
@@ -151,7 +168,7 @@ uv run pytest -q # includes SVG snapshot tests
|
|
|
151
168
|
uv run pytest -q --snapshot-update # after an intended layout change
|
|
152
169
|
```
|
|
153
170
|
|
|
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` (
|
|
171
|
+
`docs/screenshot.png` is the wide layout snapshot rendered in a browser; `uv run python docs/record_demo.py` re-records `docs/demo.gif`, and `--repos 2` records `docs/demo-profile.gif` (both need Chrome and ffmpeg). To release, bump `__version__` in `src/queue_watch/__init__.py`, merge, and push a matching `vX.Y.Z` tag; the release workflow tests, builds, publishes to PyPI with trusted publishing, and creates the GitHub release.
|
|
155
172
|
|
|
156
173
|
## License
|
|
157
174
|
|
|
@@ -31,14 +31,16 @@ Requires Python 3.11+. The GitHub adapters use an authenticated [GitHub CLI](htt
|
|
|
31
31
|
queue-watch # inside a GitHub checkout
|
|
32
32
|
queue-watch --repo owner/repo # Actions plus the merge queue, if any
|
|
33
33
|
queue-watch --profile work # ~/.config/queue-watch/work.toml
|
|
34
|
+
queue-watch profile add work --repo acme/api --repo acme/web
|
|
34
35
|
queue-watch --config sources.toml # any combination of sources
|
|
35
36
|
queue-watch --repo owner/repo --once # one JSON snapshot, no TUI
|
|
36
37
|
queue-watch --demo # a simulated repository
|
|
38
|
+
queue-watch --demo 2 # two simulated repositories, as in a profile
|
|
37
39
|
queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs only
|
|
38
40
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
39
41
|
```
|
|
40
42
|
|
|
41
|
-
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.
|
|
43
|
+
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`.
|
|
42
44
|
|
|
43
45
|
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.
|
|
44
46
|
|
|
@@ -50,6 +52,21 @@ Keyboard: arrows to move, `1` to `9` for tabs, `/` to search (esc to clear), `o`
|
|
|
50
52
|
|
|
51
53
|
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
54
|
|
|
55
|
+
### Profiles
|
|
56
|
+
|
|
57
|
+
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:
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
queue-watch profile add work --repo acme/api --repo acme/web # creates or extends work.toml
|
|
61
|
+
queue-watch profile list # every profile and its sources
|
|
62
|
+
queue-watch --profile work
|
|
63
|
+
queue-watch profile add default --repo acme/api # opens outside any checkout
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`profile add` writes Actions for each repository, plus its merge queue when the default branch has one, with the repository's name in each label. Repositories already in the profile are left alone, and anything written by hand stays. A profile is ordinary [source config](#sources), so you can add `command` and `flock-queue` sources too. When a dashboard spans several repositories, a branch name such as `main` in two of them is two rows, each labelled with its repository.
|
|
67
|
+
|
|
68
|
+

|
|
69
|
+
|
|
53
70
|
### Repository configs need trust
|
|
54
71
|
|
|
55
72
|
A config can run commands and open SSH connections, so a `.queue-watch.toml` that arrives with a clone does nothing until you review it and run:
|
|
@@ -114,7 +131,7 @@ The program must exit zero and print one snapshot (below) on stdout within `time
|
|
|
114
131
|
|
|
115
132
|
## Snapshot contract
|
|
116
133
|
|
|
117
|
-
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.
|
|
134
|
+
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.
|
|
118
135
|
|
|
119
136
|
## Development
|
|
120
137
|
|
|
@@ -124,7 +141,7 @@ uv run pytest -q # includes SVG snapshot tests
|
|
|
124
141
|
uv run pytest -q --snapshot-update # after an intended layout change
|
|
125
142
|
```
|
|
126
143
|
|
|
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` (
|
|
144
|
+
`docs/screenshot.png` is the wide layout snapshot rendered in a browser; `uv run python docs/record_demo.py` re-records `docs/demo.gif`, and `--repos 2` records `docs/demo-profile.gif` (both need Chrome and ffmpeg). To release, bump `__version__` in `src/queue_watch/__init__.py`, merge, and push a matching `vX.Y.Z` tag; the release workflow tests, builds, publishes to PyPI with trusted publishing, and creates the GitHub release.
|
|
128
145
|
|
|
129
146
|
## License
|
|
130
147
|
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import json
|
|
6
7
|
import re
|
|
7
8
|
from datetime import UTC, datetime
|
|
8
9
|
|
|
@@ -28,6 +29,7 @@ STATES = {
|
|
|
28
29
|
"stale": "neutral",
|
|
29
30
|
}
|
|
30
31
|
PAGE = 100
|
|
32
|
+
ETAGS = 200 # cached responses kept for revalidation: the run lists plus recent job lists
|
|
31
33
|
|
|
32
34
|
|
|
33
35
|
def state_of(item: dict) -> tuple[str, str]:
|
|
@@ -44,6 +46,41 @@ class GitHubActions(Adapter):
|
|
|
44
46
|
if not re.fullmatch(r"[\w.-]+/[\w.-]+", repo):
|
|
45
47
|
raise ValueError(f"repo must be owner/repo, got {repo!r}")
|
|
46
48
|
self.repo = repo
|
|
49
|
+
self.etags: dict[str, tuple[str, object, bool]] = {}
|
|
50
|
+
|
|
51
|
+
async def get(self, endpoint: str) -> tuple[object, bool]:
|
|
52
|
+
"""GET one page, sending back the last ETag; returns ``(body, more_pages)``.
|
|
53
|
+
|
|
54
|
+
GitHub does not count a 304 against the rate limit, so a page that has not
|
|
55
|
+
changed since the last poll costs nothing. The cache is per page: a 304 says
|
|
56
|
+
nothing about later pages, so callers that need them still fetch them.
|
|
57
|
+
"""
|
|
58
|
+
argv = ["gh", "api", "--include", endpoint]
|
|
59
|
+
cached = self.etags.get(endpoint)
|
|
60
|
+
if cached:
|
|
61
|
+
argv += ["--header", f"If-None-Match: {cached[0]}"]
|
|
62
|
+
returncode, stdout, stderr = await process.capture(argv)
|
|
63
|
+
head, _, body = stdout.decode(errors="replace").replace("\r\n", "\n").partition("\n\n")
|
|
64
|
+
status, *lines = head.split("\n")
|
|
65
|
+
if cached and status.split()[1:2] == ["304"]:
|
|
66
|
+
if endpoint in self.etags: # evictions can run while this request was out
|
|
67
|
+
self.etags[endpoint] = self.etags.pop(endpoint) # most recently used last
|
|
68
|
+
return cached[1], cached[2]
|
|
69
|
+
if returncode:
|
|
70
|
+
raise process.failure(argv, returncode, stderr)
|
|
71
|
+
headers = {}
|
|
72
|
+
for line in lines:
|
|
73
|
+
name, sep, value = line.partition(":")
|
|
74
|
+
if sep:
|
|
75
|
+
headers[name.strip().lower()] = value.strip()
|
|
76
|
+
data = json.loads(body)
|
|
77
|
+
more = 'rel="next"' in headers.get("link", "")
|
|
78
|
+
self.etags.pop(endpoint, None)
|
|
79
|
+
if headers.get("etag"):
|
|
80
|
+
self.etags[endpoint] = (headers["etag"], data, more)
|
|
81
|
+
while len(self.etags) > ETAGS:
|
|
82
|
+
self.etags.pop(next(iter(self.etags)))
|
|
83
|
+
return data, more
|
|
47
84
|
|
|
48
85
|
async def snapshot(self) -> dict:
|
|
49
86
|
# Fetch each active state separately: an old queued run must not disappear
|
|
@@ -52,7 +89,8 @@ class GitHubActions(Adapter):
|
|
|
52
89
|
endpoint = f"repos/{self.repo}/actions/runs?per_page={PAGE}"
|
|
53
90
|
if status:
|
|
54
91
|
endpoint += "&status=" + status
|
|
55
|
-
|
|
92
|
+
page, _ = await self.get(endpoint)
|
|
93
|
+
return page
|
|
56
94
|
|
|
57
95
|
statuses = (*ACTIVE_STATUSES, None)
|
|
58
96
|
pages = await asyncio.gather(*(fetch(status) for status in statuses))
|
|
@@ -76,7 +114,7 @@ class GitHubActions(Adapter):
|
|
|
76
114
|
return {
|
|
77
115
|
"schema": SCHEMA,
|
|
78
116
|
"observed_at": datetime.now(UTC).isoformat(),
|
|
79
|
-
"records": [self.record(run) for run in ordered],
|
|
117
|
+
"records": [{**self.record(run), "repo": self.repo} for run in ordered],
|
|
80
118
|
"views": [
|
|
81
119
|
{
|
|
82
120
|
"kind": "run",
|
|
@@ -113,16 +151,12 @@ class GitHubActions(Adapter):
|
|
|
113
151
|
}
|
|
114
152
|
|
|
115
153
|
async def children(self, record: dict) -> list[dict]:
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"--slurp",
|
|
123
|
-
f"repos/{self.repo}/actions/runs/{int(record['id'])}/jobs?per_page={PAGE}",
|
|
124
|
-
]
|
|
125
|
-
)
|
|
154
|
+
endpoint = f"repos/{self.repo}/actions/runs/{int(record['id'])}/jobs?per_page={PAGE}"
|
|
155
|
+
first, more = await self.get(endpoint)
|
|
156
|
+
pages = [first]
|
|
157
|
+
if more:
|
|
158
|
+
# Paginate so large platform matrices are not silently truncated.
|
|
159
|
+
pages = await process.run_json(["gh", "api", "--paginate", "--slurp", endpoint])
|
|
126
160
|
jobs = []
|
|
127
161
|
for job in (job for page in pages for job in page["jobs"]):
|
|
128
162
|
state, raw = state_of(job)
|
|
@@ -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(),
|
|
@@ -132,6 +132,7 @@ Screen { background: $surface; }
|
|
|
132
132
|
.queue-head .took-stats { width: auto; }
|
|
133
133
|
.queue-head .depth, .queue-head .took { display: none; }
|
|
134
134
|
.queue.-depth .queue-head .depth, .queue.-took .queue-head .took { display: block; }
|
|
135
|
+
.queue.-took.-took-flat .queue-head .took.spark { display: none; }
|
|
135
136
|
.-narrow .queue .queue-head .spark, .-narrow .queue .queue-head .depth { display: none; }
|
|
136
137
|
Sparkline > .sparkline--max-color { color: $error; }
|
|
137
138
|
Sparkline > .sparkline--min-color { color: $success; }
|
|
@@ -473,7 +474,8 @@ class QueueWatch(App):
|
|
|
473
474
|
self.query_one(ChangeTable).show(visible, queue, self.selected, empty)
|
|
474
475
|
tabs = self.query_one("#tabs", TabbedContent)
|
|
475
476
|
tabs.get_tab(CHANGES).label = f"Changes {len(visible)}"
|
|
476
|
-
|
|
477
|
+
# Snapshot: awaiting show() lets a concurrent fetch mount a new strip.
|
|
478
|
+
for source_id, strip in list(self.strips.items()):
|
|
477
479
|
records = snapshots.get(source_id, {}).get("records", [])
|
|
478
480
|
mine = {
|
|
479
481
|
rid: change for (sid, rid), change in self.by_record.items() if sid == source_id
|
|
@@ -636,7 +638,8 @@ class QueueWatch(App):
|
|
|
636
638
|
return bool(record.get("children")) or type(adapter).children is not Adapter.children
|
|
637
639
|
|
|
638
640
|
def change_view(self, change: Change):
|
|
639
|
-
meta = [clean(change.
|
|
641
|
+
meta = [clean(change.repo)] if change.repo else []
|
|
642
|
+
meta.append(clean(change.ref or change.sha or change.key))
|
|
640
643
|
if change.sha:
|
|
641
644
|
meta.append(clean(change.sha)[:7])
|
|
642
645
|
if change.pr:
|
|
@@ -841,7 +844,10 @@ class QueueWatch(App):
|
|
|
841
844
|
|
|
842
845
|
@on(DataTable.RowHighlighted)
|
|
843
846
|
async def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
|
844
|
-
|
|
847
|
+
# Highlights still arrive while the app shuts down and its widgets go away.
|
|
848
|
+
if not self.is_running or self.rendering:
|
|
849
|
+
return
|
|
850
|
+
if event.data_table.id == self.active_pane():
|
|
845
851
|
await self.show_selection()
|
|
846
852
|
|
|
847
853
|
@on(TabbedContent.TabActivated)
|
|
@@ -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
|
|
@@ -18,6 +18,9 @@ Examples:
|
|
|
18
18
|
queue-watch --config sources.toml --once
|
|
19
19
|
queue-watch trust # after reviewing .queue-watch.toml
|
|
20
20
|
queue-watch --demo # simulated repository, no login needed
|
|
21
|
+
queue-watch --demo 2 # two simulated repositories, like a profile
|
|
22
|
+
queue-watch profile add work --repo acme/api --repo acme/web
|
|
23
|
+
queue-watch profile list
|
|
21
24
|
"""
|
|
22
25
|
|
|
23
26
|
from __future__ import annotations
|
|
@@ -31,7 +34,18 @@ from pathlib import Path
|
|
|
31
34
|
|
|
32
35
|
from . import __version__
|
|
33
36
|
from .config import ConfigError, Source, load
|
|
34
|
-
from .discovery import
|
|
37
|
+
from .discovery import (
|
|
38
|
+
CONFIG_NAME,
|
|
39
|
+
DEFAULT_PROFILE,
|
|
40
|
+
add_to_profile,
|
|
41
|
+
describe_profile,
|
|
42
|
+
detect,
|
|
43
|
+
github_sources,
|
|
44
|
+
profile_path,
|
|
45
|
+
profiles,
|
|
46
|
+
repo_config,
|
|
47
|
+
trust,
|
|
48
|
+
)
|
|
35
49
|
|
|
36
50
|
|
|
37
51
|
async def once(sources: list[Source]) -> int:
|
|
@@ -73,10 +87,53 @@ def trust_command(argv: list[str]) -> int:
|
|
|
73
87
|
return 0
|
|
74
88
|
|
|
75
89
|
|
|
90
|
+
def profile_command(argv: list[str]) -> int:
|
|
91
|
+
parser = argparse.ArgumentParser(
|
|
92
|
+
prog="queue-watch profile",
|
|
93
|
+
description="Manage profiles: named source lists under ~/.config/queue-watch. "
|
|
94
|
+
f"The {DEFAULT_PROFILE!r} profile is used outside a GitHub checkout.",
|
|
95
|
+
)
|
|
96
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
97
|
+
add = commands.add_parser(
|
|
98
|
+
"add",
|
|
99
|
+
help="add GitHub repositories to a profile, creating it if needed",
|
|
100
|
+
description="Append Actions, plus the merge queue when the default branch has "
|
|
101
|
+
"one, for each repository. Repositories already in the profile are kept as they are.",
|
|
102
|
+
)
|
|
103
|
+
add.add_argument("name", help=f"profile name; {DEFAULT_PROFILE!r} is used outside a checkout")
|
|
104
|
+
add.add_argument(
|
|
105
|
+
"--repo", action="append", required=True, metavar="OWNER/REPO", help="repeatable"
|
|
106
|
+
)
|
|
107
|
+
commands.add_parser("list", help="list profiles and their sources")
|
|
108
|
+
args = parser.parse_args(argv)
|
|
109
|
+
if args.command == "list":
|
|
110
|
+
found = profiles()
|
|
111
|
+
if not found:
|
|
112
|
+
print(f"No profiles in {profile_path(DEFAULT_PROFILE).parent}.")
|
|
113
|
+
print("Create one: queue-watch profile add NAME --repo owner/repo")
|
|
114
|
+
for path in found:
|
|
115
|
+
note = " (used outside a GitHub checkout)" if path.stem == DEFAULT_PROFILE else ""
|
|
116
|
+
print(f"{path.stem}{note}")
|
|
117
|
+
for line in describe_profile(path):
|
|
118
|
+
print(f" {line}")
|
|
119
|
+
return 0
|
|
120
|
+
try:
|
|
121
|
+
path, added, skipped = asyncio.run(add_to_profile(args.name, args.repo))
|
|
122
|
+
except (ConfigError, OSError, UnicodeDecodeError) as error:
|
|
123
|
+
parser.error(str(error))
|
|
124
|
+
for repo in added:
|
|
125
|
+
print(f"Added {repo} to {path}")
|
|
126
|
+
for repo in skipped:
|
|
127
|
+
print(f"{repo} is already in {path}")
|
|
128
|
+
return 0
|
|
129
|
+
|
|
130
|
+
|
|
76
131
|
def main(argv: list[str] | None = None) -> int:
|
|
77
132
|
argv = sys.argv[1:] if argv is None else argv
|
|
78
133
|
if argv[:1] == ["trust"]:
|
|
79
134
|
return trust_command(argv[1:])
|
|
135
|
+
if argv[:1] == ["profile"]:
|
|
136
|
+
return profile_command(argv[1:])
|
|
80
137
|
parser = argparse.ArgumentParser(
|
|
81
138
|
prog="queue-watch",
|
|
82
139
|
description=__doc__,
|
|
@@ -88,7 +145,13 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
88
145
|
group.add_argument("--config", type=Path, help="TOML file with [[source]] tables")
|
|
89
146
|
group.add_argument("--profile", help="NAME for ~/.config/queue-watch/NAME.toml")
|
|
90
147
|
group.add_argument(
|
|
91
|
-
"--demo",
|
|
148
|
+
"--demo",
|
|
149
|
+
nargs="?",
|
|
150
|
+
const=1,
|
|
151
|
+
type=int,
|
|
152
|
+
choices=(1, 2),
|
|
153
|
+
metavar="REPOS",
|
|
154
|
+
help="simulated repository, or 2 for two watched like a profile; no network or login",
|
|
92
155
|
)
|
|
93
156
|
parser.add_argument("--once", action="store_true", help="print JSON once; no TUI")
|
|
94
157
|
parser.add_argument(
|
|
@@ -109,7 +172,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
109
172
|
|
|
110
173
|
clock = demo.Clock()
|
|
111
174
|
widgets.now = clock.now # ages and estimates follow the simulated clock
|
|
112
|
-
sources = demo.sources(clock)
|
|
175
|
+
sources = demo.sources(clock, args.demo)
|
|
113
176
|
elif args.config:
|
|
114
177
|
sources = load(args.config.expanduser())
|
|
115
178
|
elif args.profile:
|