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.
Files changed (40) hide show
  1. {queue_watch-0.2.0 → queue_watch-0.4.0}/PKG-INFO +21 -4
  2. {queue_watch-0.2.0 → queue_watch-0.4.0}/README.md +20 -3
  3. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/__init__.py +1 -1
  4. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_actions.py +46 -12
  5. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/github_merge_queue.py +1 -1
  6. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/app.py +9 -3
  7. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/changes.py +23 -8
  8. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/cli.py +71 -8
  9. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/demo.py +91 -34
  10. queue_watch-0.4.0/src/queue_watch/discovery.py +250 -0
  11. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/model.py +7 -6
  12. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/process.py +18 -6
  13. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/widgets.py +79 -25
  14. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_app.py +39 -0
  15. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_changes.py +35 -0
  16. queue_watch-0.4.0/tests/test_demo.py +62 -0
  17. queue_watch-0.4.0/tests/test_discovery.py +148 -0
  18. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_github_actions.py +79 -10
  19. queue_watch-0.2.0/src/queue_watch/discovery.py +0 -149
  20. queue_watch-0.2.0/tests/test_demo.py +0 -26
  21. queue_watch-0.2.0/tests/test_discovery.py +0 -81
  22. {queue_watch-0.2.0 → queue_watch-0.4.0}/.gitignore +0 -0
  23. {queue_watch-0.2.0 → queue_watch-0.4.0}/LICENSE +0 -0
  24. {queue_watch-0.2.0 → queue_watch-0.4.0}/examples/queue-status.py +0 -0
  25. {queue_watch-0.2.0 → queue_watch-0.4.0}/examples/queue.json +0 -0
  26. {queue_watch-0.2.0 → queue_watch-0.4.0}/examples/sources.toml +0 -0
  27. {queue_watch-0.2.0 → queue_watch-0.4.0}/pyproject.toml +0 -0
  28. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/__init__.py +0 -0
  29. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/command.py +0 -0
  30. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/adapters/flock_queue.py +0 -0
  31. {queue_watch-0.2.0 → queue_watch-0.4.0}/src/queue_watch/config.py +0 -0
  32. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -0
  33. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -0
  34. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -0
  35. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -0
  36. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_command.py +0 -0
  37. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_contract.py +0 -0
  38. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_flock_queue.py +0 -0
  39. {queue_watch-0.2.0 → queue_watch-0.4.0}/tests/test_github_merge_queue.py +0 -0
  40. {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.2.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. `--profile NAME` reads `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`).
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
+ ![queue-watch --demo 2: two simulated repositories in one dashboard, each with its own merge queue strip, and rows that keep main and other shared branch names apart by repository](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/demo-profile.gif)
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` (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.
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. `--profile NAME` reads `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`).
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
+ ![queue-watch --demo 2: two simulated repositories in one dashboard, each with its own merge queue strip, and rows that keep main and other shared branch names apart by repository](https://raw.githubusercontent.com/wolfiesch/queue-watch/main/docs/demo-profile.gif)
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` (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.
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
 
@@ -1,3 +1,3 @@
1
1
  """Read-only terminal dashboard for CI runs and merge queues."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.4.0"
@@ -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
- return await process.run_json(["gh", "api", endpoint])
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
- # Paginate so large platform matrices are not silently truncated.
117
- pages = await process.run_json(
118
- [
119
- "gh",
120
- "api",
121
- "--paginate",
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
- for source_id, strip in self.strips.items():
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.ref or change.key)]
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
- if not self.rendering and event.data_table.id == self.active_pane():
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. 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
@@ -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 CONFIG_NAME, detect, github_sources, profile_path, repo_config, trust
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", action="store_true", help="simulated repository; no network or login"
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: