queue-watch 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queue_watch-0.2.0 → queue_watch-0.3.0}/PKG-INFO +17 -3
- {queue_watch-0.2.0 → queue_watch-0.3.0}/README.md +16 -2
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/__init__.py +1 -1
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_actions.py +1 -1
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_merge_queue.py +1 -1
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/app.py +2 -1
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/changes.py +23 -8
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/cli.py +62 -6
- queue_watch-0.3.0/src/queue_watch/discovery.py +250 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/model.py +7 -6
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/widgets.py +4 -2
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_changes.py +35 -0
- queue_watch-0.3.0/tests/test_discovery.py +148 -0
- queue_watch-0.2.0/src/queue_watch/discovery.py +0 -149
- queue_watch-0.2.0/tests/test_discovery.py +0 -81
- {queue_watch-0.2.0 → queue_watch-0.3.0}/.gitignore +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/LICENSE +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/examples/queue-status.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/examples/queue.json +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/examples/sources.toml +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/pyproject.toml +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/__init__.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/command.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/flock_queue.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/config.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/demo.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/process.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_app.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_command.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_contract.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_demo.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_flock_queue.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_github_actions.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_github_merge_queue.py +0 -0
- {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_snapshots.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: queue-watch
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Read-only terminal dashboard for CI runs and merge queues, with mouse support
|
|
5
5
|
Project-URL: Homepage, https://github.com/wolfiesch/queue-watch
|
|
6
6
|
Project-URL: Issues, https://github.com/wolfiesch/queue-watch/issues
|
|
@@ -58,6 +58,7 @@ 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,7 +66,7 @@ queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs o
|
|
|
65
66
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
66
67
|
```
|
|
67
68
|
|
|
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.
|
|
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`.
|
|
69
70
|
|
|
70
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.
|
|
71
72
|
|
|
@@ -77,6 +78,19 @@ Keyboard: arrows to move, `1` to `9` for tabs, `/` to search (esc to clear), `o`
|
|
|
77
78
|
|
|
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).
|
|
79
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
|
+
|
|
80
94
|
### Repository configs need trust
|
|
81
95
|
|
|
82
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:
|
|
@@ -141,7 +155,7 @@ The program must exit zero and print one snapshot (below) on stdout within `time
|
|
|
141
155
|
|
|
142
156
|
## Snapshot contract
|
|
143
157
|
|
|
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.
|
|
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.
|
|
145
159
|
|
|
146
160
|
## Development
|
|
147
161
|
|
|
@@ -31,6 +31,7 @@ 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,7 +39,7 @@ queue-watch --theme nord --ascii # another Textual theme; ASCII glyphs o
|
|
|
38
39
|
queue-watch --density comfortable # title, owner and PR on a second line
|
|
39
40
|
```
|
|
40
41
|
|
|
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.
|
|
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`.
|
|
42
43
|
|
|
43
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.
|
|
44
45
|
|
|
@@ -50,6 +51,19 @@ Keyboard: arrows to move, `1` to `9` for tabs, `/` to search (esc to clear), `o`
|
|
|
50
51
|
|
|
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).
|
|
52
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
|
+
|
|
53
67
|
### Repository configs need trust
|
|
54
68
|
|
|
55
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:
|
|
@@ -114,7 +128,7 @@ The program must exit zero and print one snapshot (below) on stdout within `time
|
|
|
114
128
|
|
|
115
129
|
## Snapshot contract
|
|
116
130
|
|
|
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.
|
|
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.
|
|
118
132
|
|
|
119
133
|
## Development
|
|
120
134
|
|
|
@@ -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(),
|
|
@@ -636,7 +636,8 @@ class QueueWatch(App):
|
|
|
636
636
|
return bool(record.get("children")) or type(adapter).children is not Adapter.children
|
|
637
637
|
|
|
638
638
|
def change_view(self, change: Change):
|
|
639
|
-
meta = [clean(change.
|
|
639
|
+
meta = [clean(change.repo)] if change.repo else []
|
|
640
|
+
meta.append(clean(change.ref or change.sha or change.key))
|
|
640
641
|
if change.sha:
|
|
641
642
|
meta.append(clean(change.sha)[:7])
|
|
642
643
|
if change.pr:
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"""Changes: records from every source that describe the same branch or commit.
|
|
2
2
|
|
|
3
3
|
Records correlate by ``ref`` and fall back to ``sha``; records with neither stay
|
|
4
|
-
in their source's own tab.
|
|
4
|
+
in their source's own tab. When records name more than one ``repo``, the same ref
|
|
5
|
+
in two repositories is two changes, and records without a ``repo`` form changes of
|
|
6
|
+
their own. Kinds with a role in a change: ``run`` (CI),
|
|
5
7
|
``queue_entry`` (live merge queue position) and ``attempt`` (a finished merge
|
|
6
8
|
attempt). Other kinds are only shown per source.
|
|
7
9
|
"""
|
|
@@ -51,6 +53,7 @@ class Change:
|
|
|
51
53
|
key: str
|
|
52
54
|
ref: str | None = None
|
|
53
55
|
sha: str | None = None
|
|
56
|
+
repo: str | None = None # set only when the snapshots span several repositories
|
|
54
57
|
runs: list[Hit] = field(default_factory=list) # newest first
|
|
55
58
|
entries: list[Hit] = field(default_factory=list) # queue order
|
|
56
59
|
attempts: list[Hit] = field(default_factory=list) # newest first
|
|
@@ -133,24 +136,36 @@ class Change:
|
|
|
133
136
|
|
|
134
137
|
|
|
135
138
|
def correlate(snapshots: dict[str, dict]) -> list[Change]:
|
|
136
|
-
"""One Change per ref (falling back to sha), in merge order, then
|
|
137
|
-
|
|
138
|
-
|
|
139
|
+
"""One Change per repository and ref (falling back to sha), in merge order, then
|
|
140
|
+
most recent. Repositories only separate changes when more than one appears."""
|
|
141
|
+
# GitHub repository names are case-insensitive.
|
|
142
|
+
repos = {
|
|
143
|
+
record["repo"].casefold()
|
|
144
|
+
for snapshot in snapshots.values()
|
|
145
|
+
for record in snapshot.get("records", [])
|
|
146
|
+
if record.get("repo")
|
|
147
|
+
}
|
|
148
|
+
several = len(repos) > 1
|
|
149
|
+
by_ref: dict[tuple[str | None, str], Change] = {}
|
|
150
|
+
by_sha: dict[tuple[str | None, str], Change] = {}
|
|
139
151
|
changes: list[Change] = []
|
|
140
152
|
for source, snapshot in snapshots.items():
|
|
141
153
|
for record in snapshot.get("records", []):
|
|
142
154
|
ref, sha = record.get("ref"), record.get("sha")
|
|
143
155
|
if record["kind"] not in ROLES or not (ref or sha):
|
|
144
156
|
continue
|
|
145
|
-
|
|
157
|
+
repo = (record.get("repo") or None) if several else None
|
|
158
|
+
scope = repo and repo.casefold()
|
|
159
|
+
change = (ref and by_ref.get((scope, ref))) or (sha and by_sha.get((scope, sha)))
|
|
146
160
|
if not change:
|
|
147
|
-
|
|
161
|
+
name = ref or sha
|
|
162
|
+
change = Change(key=f"{scope}:{name}" if scope else name, repo=repo)
|
|
148
163
|
changes.append(change)
|
|
149
164
|
if ref:
|
|
150
|
-
by_ref.setdefault(ref, change)
|
|
165
|
+
by_ref.setdefault((scope, ref), change)
|
|
151
166
|
change.ref = change.ref or ref
|
|
152
167
|
if sha:
|
|
153
|
-
by_sha.setdefault(sha, change)
|
|
168
|
+
by_sha.setdefault((scope, sha), change)
|
|
154
169
|
change.sha = change.sha or sha
|
|
155
170
|
hit = Hit(source, record)
|
|
156
171
|
match record["kind"]:
|
|
@@ -5,11 +5,11 @@ with the wheel. Keyboard: tab/arrows, / search, r refresh, p pause, o open the
|
|
|
5
5
|
selected record, q quit. Times are shown in America/Los_Angeles.
|
|
6
6
|
|
|
7
7
|
With no source flags, queue-watch uses the repository's .queue-watch.toml once
|
|
8
|
-
you have trusted it,
|
|
9
|
-
|
|
10
|
-
changed: no reruns, cancellations, merges, lock acquisition or
|
|
11
|
-
Last good data stays visible on failure and is labelled stale.
|
|
12
|
-
JSON snapshot per source and exits nonzero when any source fails.
|
|
8
|
+
you have trusted it, then the current checkout's GitHub repository (Actions, plus
|
|
9
|
+
the merge queue when the default branch has one), then the profile named default.
|
|
10
|
+
Nothing is ever changed: no reruns, cancellations, merges, lock acquisition or
|
|
11
|
+
remote installs. Last good data stays visible on failure and is labelled stale.
|
|
12
|
+
--once prints one JSON snapshot per source and exits nonzero when any source fails.
|
|
13
13
|
|
|
14
14
|
Examples:
|
|
15
15
|
queue-watch # inside a GitHub checkout
|
|
@@ -18,6 +18,8 @@ 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 profile add work --repo acme/api --repo acme/web
|
|
22
|
+
queue-watch profile list
|
|
21
23
|
"""
|
|
22
24
|
|
|
23
25
|
from __future__ import annotations
|
|
@@ -31,7 +33,18 @@ from pathlib import Path
|
|
|
31
33
|
|
|
32
34
|
from . import __version__
|
|
33
35
|
from .config import ConfigError, Source, load
|
|
34
|
-
from .discovery import
|
|
36
|
+
from .discovery import (
|
|
37
|
+
CONFIG_NAME,
|
|
38
|
+
DEFAULT_PROFILE,
|
|
39
|
+
add_to_profile,
|
|
40
|
+
describe_profile,
|
|
41
|
+
detect,
|
|
42
|
+
github_sources,
|
|
43
|
+
profile_path,
|
|
44
|
+
profiles,
|
|
45
|
+
repo_config,
|
|
46
|
+
trust,
|
|
47
|
+
)
|
|
35
48
|
|
|
36
49
|
|
|
37
50
|
async def once(sources: list[Source]) -> int:
|
|
@@ -73,10 +86,53 @@ def trust_command(argv: list[str]) -> int:
|
|
|
73
86
|
return 0
|
|
74
87
|
|
|
75
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
|
+
|
|
76
130
|
def main(argv: list[str] | None = None) -> int:
|
|
77
131
|
argv = sys.argv[1:] if argv is None else argv
|
|
78
132
|
if argv[:1] == ["trust"]:
|
|
79
133
|
return trust_command(argv[1:])
|
|
134
|
+
if argv[:1] == ["profile"]:
|
|
135
|
+
return profile_command(argv[1:])
|
|
80
136
|
parser = argparse.ArgumentParser(
|
|
81
137
|
prog="queue-watch",
|
|
82
138
|
description=__doc__,
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Where sources come from when no --repo or --config is given, and profiles.
|
|
2
|
+
|
|
3
|
+
Order: a repository's own ``.queue-watch.toml`` (only once trusted), then the
|
|
4
|
+
GitHub repository of the current checkout (Actions, plus its merge queue when the
|
|
5
|
+
default branch has one), then the ``default`` profile.
|
|
6
|
+
|
|
7
|
+
Profiles are the user's own TOML files under ``$XDG_CONFIG_HOME/queue-watch``.
|
|
8
|
+
``queue-watch profile add NAME --repo owner/repo`` appends sources for each
|
|
9
|
+
repository to one, keeping anything already written there.
|
|
10
|
+
|
|
11
|
+
A repository config can run commands and open SSH connections, so a fresh clone
|
|
12
|
+
must not be able to do either on its own. ``queue-watch trust`` records the
|
|
13
|
+
SHA-256 of the file's exact bytes; any later edit needs trusting again, like
|
|
14
|
+
``direnv allow``. Files passed with --config or --profile are the user's own and
|
|
15
|
+
are trusted implicitly.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
import tomllib
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .config import ConfigError, Source, build, load, parse
|
|
29
|
+
|
|
30
|
+
CONFIG_NAME = ".queue-watch.toml"
|
|
31
|
+
DEFAULT_PROFILE = "default"
|
|
32
|
+
PROFILE_HEADER = """\
|
|
33
|
+
# queue-watch profile: run with `queue-watch --profile {name}`.
|
|
34
|
+
# Add repositories with `queue-watch profile add {name} --repo owner/repo`, or edit
|
|
35
|
+
# by hand; https://github.com/wolfiesch/queue-watch lists every adapter's options.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def state_dir() -> Path:
|
|
40
|
+
base = os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state"
|
|
41
|
+
return Path(base) / "queue-watch"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def profile_dir() -> Path:
|
|
45
|
+
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
46
|
+
return Path(base) / "queue-watch"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def profile_path(name: str) -> Path:
|
|
50
|
+
if not name or "/" in name or name.startswith("."):
|
|
51
|
+
raise ConfigError(f"profile must be a plain name, got {name!r}")
|
|
52
|
+
return profile_dir() / f"{name}.toml"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def profiles() -> list[Path]:
|
|
56
|
+
return sorted(profile_dir().glob("*.toml"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def git_root(cwd: Path) -> Path | None:
|
|
60
|
+
try:
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
check=False,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=10,
|
|
67
|
+
)
|
|
68
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
69
|
+
return None
|
|
70
|
+
return Path(result.stdout.strip()) if result.returncode == 0 else None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def repo_config(cwd: Path) -> Path | None:
|
|
74
|
+
root = git_root(cwd)
|
|
75
|
+
if root is None:
|
|
76
|
+
return None
|
|
77
|
+
path = root / CONFIG_NAME
|
|
78
|
+
return path if path.is_file() else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _store() -> Path:
|
|
82
|
+
return state_dir() / "trusted.json"
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _trusted() -> dict[str, str]:
|
|
86
|
+
try:
|
|
87
|
+
data = json.loads(_store().read_text())
|
|
88
|
+
except (OSError, ValueError):
|
|
89
|
+
return {}
|
|
90
|
+
return data if isinstance(data, dict) else {}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def digest(data: bytes) -> str:
|
|
94
|
+
return hashlib.sha256(data).hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def trust(path: Path) -> list[Source]:
|
|
98
|
+
"""Validate ``path`` and record its current bytes as trusted."""
|
|
99
|
+
path = path.resolve()
|
|
100
|
+
data = path.read_bytes()
|
|
101
|
+
sources = parse(data.decode(), path) # Never trust a file that cannot load.
|
|
102
|
+
trusted = _trusted()
|
|
103
|
+
trusted[str(path)] = digest(data)
|
|
104
|
+
store = _store()
|
|
105
|
+
store.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
with tempfile.NamedTemporaryFile("w", dir=store.parent, delete=False) as handle:
|
|
107
|
+
json.dump(trusted, handle, indent=2, sort_keys=True)
|
|
108
|
+
os.replace(handle.name, store)
|
|
109
|
+
return sources
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_trusted(path: Path) -> list[Source]:
|
|
113
|
+
"""Load a repository config only if these exact bytes were trusted."""
|
|
114
|
+
path = path.resolve()
|
|
115
|
+
data = path.read_bytes() # Check and parse the same bytes.
|
|
116
|
+
recorded = _trusted().get(str(path))
|
|
117
|
+
if recorded != digest(data):
|
|
118
|
+
state = "changed since it was trusted" if recorded else "is not trusted yet"
|
|
119
|
+
raise ConfigError(
|
|
120
|
+
f"{path} {state}. It can run commands and open SSH connections. "
|
|
121
|
+
"Review it, then run: queue-watch trust"
|
|
122
|
+
)
|
|
123
|
+
return parse(data.decode(), path)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def github_repo(cwd: Path) -> str | None:
|
|
127
|
+
try:
|
|
128
|
+
result = subprocess.run(
|
|
129
|
+
["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
130
|
+
capture_output=True,
|
|
131
|
+
check=False,
|
|
132
|
+
text=True,
|
|
133
|
+
timeout=20,
|
|
134
|
+
cwd=cwd,
|
|
135
|
+
)
|
|
136
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
137
|
+
return None
|
|
138
|
+
name = result.stdout.strip()
|
|
139
|
+
return name if result.returncode == 0 and name else None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def detect(cwd: Path) -> list[Source]:
|
|
143
|
+
path = repo_config(cwd)
|
|
144
|
+
if path is not None:
|
|
145
|
+
return load_trusted(path)
|
|
146
|
+
repo = github_repo(cwd)
|
|
147
|
+
if repo is not None:
|
|
148
|
+
return await github_sources(repo)
|
|
149
|
+
default = profile_path(DEFAULT_PROFILE)
|
|
150
|
+
if default.is_file():
|
|
151
|
+
return load(default)
|
|
152
|
+
raise ConfigError(
|
|
153
|
+
"no sources found: not inside a GitHub checkout with an authenticated gh, "
|
|
154
|
+
f"no {CONFIG_NAME} and no default profile. Pass --repo, --config or --profile, "
|
|
155
|
+
"or create one: queue-watch profile add default --repo owner/repo"
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def github_entries(repo: str, prefix: str = "") -> list[dict]:
|
|
160
|
+
"""Source tables for Actions on ``repo``, plus its merge queue when the default
|
|
161
|
+
branch has one. ``prefix`` names the repository in ids and labels."""
|
|
162
|
+
from .adapters.github_merge_queue import has_queue
|
|
163
|
+
|
|
164
|
+
entries = [{"id": f"{prefix}ci", "adapter": "github-actions", "repo": repo}]
|
|
165
|
+
build(entries[0], repo) # Reject a malformed repo before asking GitHub.
|
|
166
|
+
try:
|
|
167
|
+
queued = await has_queue(repo)
|
|
168
|
+
except (OSError, RuntimeError, ValueError, KeyError) as error:
|
|
169
|
+
raise ConfigError(f"cannot check {repo} for a merge queue: {error}") from error
|
|
170
|
+
if queued:
|
|
171
|
+
entries.append(
|
|
172
|
+
{"id": f"{prefix}merge-queue", "adapter": "github-merge-queue", "repo": repo}
|
|
173
|
+
)
|
|
174
|
+
if prefix:
|
|
175
|
+
name = repo.split("/")[1]
|
|
176
|
+
entries[0]["label"] = f"{name} CI"
|
|
177
|
+
if queued:
|
|
178
|
+
entries[1]["label"] = f"{name} merge queue"
|
|
179
|
+
return entries
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def github_sources(repo: str) -> list[Source]:
|
|
183
|
+
"""Actions for ``repo``, plus its merge queue when the default branch has one."""
|
|
184
|
+
return [build(entry, repo) for entry in await github_entries(repo)]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def toml_table(entry: dict) -> str:
|
|
188
|
+
# JSON strings are valid TOML basic strings.
|
|
189
|
+
return "\n[[source]]\n" + "".join(
|
|
190
|
+
f"{key} = {json.dumps(value)}\n" for key, value in entry.items()
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def add_to_profile(name: str, repos: list[str]) -> tuple[Path, list[str], list[str]]:
|
|
195
|
+
"""Append GitHub sources for each new repository in ``repos`` to profile ``name``.
|
|
196
|
+
|
|
197
|
+
Returns the profile's path, the repositories added, and those it already had.
|
|
198
|
+
"""
|
|
199
|
+
path = profile_path(name)
|
|
200
|
+
text = path.read_text() if path.exists() else PROFILE_HEADER.format(name=name)
|
|
201
|
+
try:
|
|
202
|
+
existing = tomllib.loads(text).get("source") or []
|
|
203
|
+
except tomllib.TOMLDecodeError as error:
|
|
204
|
+
raise ConfigError(f"{path}: {error}") from error
|
|
205
|
+
present = {
|
|
206
|
+
entry["repo"].casefold()
|
|
207
|
+
for entry in existing
|
|
208
|
+
if isinstance(entry, dict) and isinstance(entry.get("repo"), str)
|
|
209
|
+
}
|
|
210
|
+
ids = {entry.get("id") for entry in existing if isinstance(entry, dict)}
|
|
211
|
+
added, skipped = [], []
|
|
212
|
+
for repo in repos:
|
|
213
|
+
if repo.casefold() in present:
|
|
214
|
+
skipped.append(repo)
|
|
215
|
+
continue
|
|
216
|
+
prefix = base = repo.replace("/", "-").lower() + "-"
|
|
217
|
+
number = 2
|
|
218
|
+
while f"{prefix}ci" in ids or f"{prefix}merge-queue" in ids: # a-b/c and a/b-c
|
|
219
|
+
prefix, number = f"{base}{number}-", number + 1
|
|
220
|
+
entries = await github_entries(repo, prefix)
|
|
221
|
+
text += "".join(map(toml_table, entries))
|
|
222
|
+
ids.update(entry["id"] for entry in entries)
|
|
223
|
+
present.add(repo.casefold())
|
|
224
|
+
added.append(repo)
|
|
225
|
+
if added:
|
|
226
|
+
parse(text, path) # Never write a profile that cannot load.
|
|
227
|
+
target = path.resolve() # Keep a symlinked profile (dotfiles) a symlink.
|
|
228
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
mode = target.stat().st_mode & 0o777 if target.exists() else 0o644
|
|
230
|
+
with tempfile.NamedTemporaryFile("w", dir=target.parent, delete=False) as handle:
|
|
231
|
+
handle.write(text)
|
|
232
|
+
os.chmod(handle.name, mode)
|
|
233
|
+
os.replace(handle.name, target)
|
|
234
|
+
return path, added, skipped
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def describe_profile(path: Path) -> list[str]:
|
|
238
|
+
"""One line per source in a profile, without building any adapter."""
|
|
239
|
+
try:
|
|
240
|
+
entries = tomllib.loads(path.read_text()).get("source") or []
|
|
241
|
+
except (OSError, UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
|
|
242
|
+
return [f"cannot read: {error}"]
|
|
243
|
+
lines = []
|
|
244
|
+
for entry in entries:
|
|
245
|
+
if not isinstance(entry, dict):
|
|
246
|
+
continue
|
|
247
|
+
target = entry.get("repo") or entry.get("host") or ""
|
|
248
|
+
label = entry.get("label") or entry.get("id") or "?"
|
|
249
|
+
lines.append(f"{label} ({entry.get('adapter', '?')}{' ' + str(target) if target else ''})")
|
|
250
|
+
return lines or ["no sources"]
|
|
@@ -13,11 +13,12 @@ A snapshot is one observation of one source:
|
|
|
13
13
|
|
|
14
14
|
Record fields: ``id``, ``kind``, ``state`` and ``title`` are required. ``state`` is
|
|
15
15
|
one of STATES; adapters keep their own vocabulary in ``raw_state``. ``ref`` and
|
|
16
|
-
``sha`` identify the change so records from different sources can be correlated
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
``
|
|
20
|
-
for
|
|
16
|
+
``sha`` identify the change so records from different sources can be correlated;
|
|
17
|
+
``repo`` ("owner/name") keeps equal refs from different repositories apart.
|
|
18
|
+
Optional: ``raw_state``, ``subtitle``, ``ref``, ``sha``, ``repo``, ``owner``,
|
|
19
|
+
``position`` (int), ``created_at``, ``updated_at``, ``started_at``, ``finished_at``
|
|
20
|
+
(ISO 8601), ``url``, ``fields`` (JSON object for the inspector) and ``children``
|
|
21
|
+
(records, for example jobs and steps).
|
|
21
22
|
|
|
22
23
|
View: ``{"kind", "label", "columns": [{"key", "label", "width", "format"}]}``.
|
|
23
24
|
A column key is a record field name or ``fields.<name>``; FORMATS lists formats.
|
|
@@ -43,7 +44,7 @@ ACTIVE = frozenset({"queued", "running", "blocked"})
|
|
|
43
44
|
ATTENTION = frozenset({"failure", "blocked"})
|
|
44
45
|
FORMATS = ("text", "state", "clock", "age", "seconds", "duration", "sha")
|
|
45
46
|
TIME_FIELDS = ("created_at", "updated_at", "started_at", "finished_at")
|
|
46
|
-
TEXT_FIELDS = ("raw_state", "subtitle", "ref", "sha", "owner", "url")
|
|
47
|
+
TEXT_FIELDS = ("raw_state", "subtitle", "ref", "sha", "repo", "owner", "url")
|
|
47
48
|
|
|
48
49
|
DEFAULT_VIEWS: dict[str, dict] = {
|
|
49
50
|
"run": {
|
|
@@ -565,8 +565,10 @@ def change_cell(col: Column, change: Change, compact: bool) -> Content:
|
|
|
565
565
|
case "glyph":
|
|
566
566
|
return Content.assemble(glyph(change.state))
|
|
567
567
|
case "ref":
|
|
568
|
-
name = change.ref or change.key[:7]
|
|
569
|
-
|
|
568
|
+
name = clean(change.ref or (change.sha or change.key)[:7])
|
|
569
|
+
if change.repo: # several repositories: name the one this change is in
|
|
570
|
+
return Content.assemble(dim(clean(change.repo.split("/")[-1]) + " "), (name, "b"))
|
|
571
|
+
return Content.assemble((name, "b"))
|
|
570
572
|
case "pr":
|
|
571
573
|
return dim(f"#{change.pr}" if change.pr else "")
|
|
572
574
|
case "title":
|
|
@@ -81,3 +81,38 @@ def test_queue_state_outranks_green_ci_and_finished_changes_fall_to_done() -> No
|
|
|
81
81
|
"queued": ["first"],
|
|
82
82
|
"done": ["gone", "merged"],
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_same_branch_in_different_repositories_stays_apart() -> None:
|
|
87
|
+
api = snap(
|
|
88
|
+
record("r1", "run", "success", ref="main", sha="a1", repo="acme/api"),
|
|
89
|
+
record("r2", "run", "failure", ref="feat/x", sha="a2", repo="acme/api"),
|
|
90
|
+
)
|
|
91
|
+
web = snap(record("r3", "run", "failure", ref="main", sha="b1", repo="acme/web"))
|
|
92
|
+
# A source without repositories (a command adapter) cannot be placed in either.
|
|
93
|
+
local = snap(record("q1", "queue_entry", "queued", ref="main", position=1))
|
|
94
|
+
changes = correlate({"api": api, "web": web, "local": local})
|
|
95
|
+
by_key = {change.key: change for change in changes}
|
|
96
|
+
assert set(by_key) == {"acme/api:main", "acme/api:feat/x", "acme/web:main", "main"}
|
|
97
|
+
assert by_key["acme/web:main"].state == "failure"
|
|
98
|
+
assert by_key["acme/api:main"].state == "success"
|
|
99
|
+
assert (by_key["acme/web:main"].repo, by_key["main"].repo) == ("acme/web", None)
|
|
100
|
+
|
|
101
|
+
# Repository names are case-insensitive; an empty one is no repository.
|
|
102
|
+
mixed = snap(
|
|
103
|
+
record("q2", "queue_entry", "queued", ref="main", repo="Acme/Web", position=1),
|
|
104
|
+
record("q3", "queue_entry", "queued", ref="main", repo="", position=2),
|
|
105
|
+
)
|
|
106
|
+
changes = correlate({"api": api, "web": web, "mixed": mixed})
|
|
107
|
+
assert sorted((c.key, len(c.runs), len(c.entries)) for c in changes) == [
|
|
108
|
+
("acme/api:feat/x", 1, 0),
|
|
109
|
+
("acme/api:main", 1, 0),
|
|
110
|
+
("acme/web:main", 1, 1),
|
|
111
|
+
("main", 0, 1),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
# With one repository, records without one still join it, and keys stay plain.
|
|
115
|
+
changes = correlate({"api": snap(api["records"][0]), "local": local})
|
|
116
|
+
assert [(change.key, change.repo, len(change.entries)) for change in changes] == [
|
|
117
|
+
("main", None, 1)
|
|
118
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Source discovery: repository config trust, profiles, GitHub fallback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import subprocess
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from queue_watch import cli, discovery
|
|
11
|
+
from queue_watch.adapters import github_merge_queue
|
|
12
|
+
from queue_watch.config import ConfigError
|
|
13
|
+
|
|
14
|
+
CONFIG = '[[source]]\nid = "ci"\nadapter = "github-actions"\nrepo = "o/r"\n'
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def checkout(tmp_path, monkeypatch):
|
|
19
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
|
20
|
+
repo = tmp_path / "repo"
|
|
21
|
+
(repo / "sub").mkdir(parents=True)
|
|
22
|
+
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
|
23
|
+
return repo
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_repo_config_needs_trust_and_retrust_after_any_edit(checkout):
|
|
27
|
+
path = checkout / discovery.CONFIG_NAME
|
|
28
|
+
path.write_text(CONFIG)
|
|
29
|
+
with pytest.raises(ConfigError, match="is not trusted yet.*queue-watch trust"):
|
|
30
|
+
asyncio.run(discovery.detect(checkout / "sub"))
|
|
31
|
+
|
|
32
|
+
discovery.trust(path)
|
|
33
|
+
(source,) = asyncio.run(discovery.detect(checkout / "sub"))
|
|
34
|
+
assert source.id == "ci"
|
|
35
|
+
|
|
36
|
+
path.write_text(CONFIG.replace('"ci"', '"build"'))
|
|
37
|
+
with pytest.raises(ConfigError, match="changed since it was trusted"):
|
|
38
|
+
asyncio.run(discovery.detect(checkout))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_a_config_that_cannot_load_is_never_trusted(checkout):
|
|
42
|
+
path = checkout / discovery.CONFIG_NAME
|
|
43
|
+
path.write_text('[[source]]\nid = "a"\nadapter = "jenkins"\n')
|
|
44
|
+
with pytest.raises(ConfigError, match="adapter must be one of"):
|
|
45
|
+
discovery.trust(path)
|
|
46
|
+
assert not (checkout.parent / "state" / "queue-watch" / "trusted.json").exists()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_trust_command_trusts_the_checkout_config(checkout, monkeypatch, capsys):
|
|
50
|
+
(checkout / discovery.CONFIG_NAME).write_text(CONFIG)
|
|
51
|
+
monkeypatch.chdir(checkout / "sub")
|
|
52
|
+
assert cli.main(["trust"]) == 0
|
|
53
|
+
assert "ci: github-actions" in capsys.readouterr().out
|
|
54
|
+
assert len(asyncio.run(discovery.detect(checkout))) == 1
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("queued", [True, False])
|
|
58
|
+
def test_github_fallback_adds_merge_queue_only_when_present(checkout, monkeypatch, queued):
|
|
59
|
+
async def has_queue(repo):
|
|
60
|
+
assert repo == "o/r"
|
|
61
|
+
return queued
|
|
62
|
+
|
|
63
|
+
monkeypatch.setattr(discovery, "github_repo", lambda cwd: "o/r")
|
|
64
|
+
monkeypatch.setattr(github_merge_queue, "has_queue", has_queue)
|
|
65
|
+
sources = asyncio.run(discovery.detect(checkout))
|
|
66
|
+
expected = ["github-actions", "github-merge-queue"] if queued else ["github-actions"]
|
|
67
|
+
assert [source.adapter.name for source in sources] == expected
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_no_checkout_and_no_config_names_every_option(tmp_path, monkeypatch):
|
|
71
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
|
72
|
+
monkeypatch.setattr(discovery, "github_repo", lambda cwd: None)
|
|
73
|
+
with pytest.raises(ConfigError, match="--repo, --config or --profile.*profile add default"):
|
|
74
|
+
asyncio.run(discovery.detect(tmp_path))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_default_profile_is_used_only_outside_a_github_checkout(tmp_path, monkeypatch):
|
|
78
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
|
79
|
+
(tmp_path / "config" / "queue-watch").mkdir(parents=True)
|
|
80
|
+
discovery.profile_path("default").write_text(CONFIG.replace("o/r", "acme/default"))
|
|
81
|
+
monkeypatch.setattr(github_merge_queue, "has_queue", lambda repo: asyncio.sleep(0, False))
|
|
82
|
+
|
|
83
|
+
monkeypatch.setattr(discovery, "github_repo", lambda cwd: None)
|
|
84
|
+
assert [s.adapter.repo for s in asyncio.run(discovery.detect(tmp_path))] == ["acme/default"]
|
|
85
|
+
monkeypatch.setattr(discovery, "github_repo", lambda cwd: "acme/here")
|
|
86
|
+
assert [s.adapter.repo for s in asyncio.run(discovery.detect(tmp_path))] == ["acme/here"]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_profiles_live_under_xdg_config_home(tmp_path, monkeypatch):
|
|
90
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
91
|
+
assert discovery.profile_path("work") == tmp_path / "queue-watch" / "work.toml"
|
|
92
|
+
for bad in ("../work", ".hidden", ""):
|
|
93
|
+
with pytest.raises(ConfigError, match="plain name"):
|
|
94
|
+
discovery.profile_path(bad)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_profile_add_appends_new_repositories_and_keeps_hand_edits(tmp_path, monkeypatch, capsys):
|
|
98
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
99
|
+
|
|
100
|
+
async def has_queue(repo):
|
|
101
|
+
return repo == "acme/api"
|
|
102
|
+
|
|
103
|
+
monkeypatch.setattr(github_merge_queue, "has_queue", has_queue)
|
|
104
|
+
assert cli.main(["profile", "add", "work", "--repo", "acme/api", "--repo", "acme/web"]) == 0
|
|
105
|
+
path = discovery.profile_path("work")
|
|
106
|
+
path.write_text(path.read_text() + "# keep me\n")
|
|
107
|
+
assert cli.main(["profile", "add", "work", "--repo", "acme/web", "--repo", "Acme/Tools"]) == 0
|
|
108
|
+
out = capsys.readouterr().out
|
|
109
|
+
assert "acme/web is already in" in out and "Added Acme/Tools" in out
|
|
110
|
+
|
|
111
|
+
assert "# keep me" in path.read_text()
|
|
112
|
+
sources = discovery.load(path)
|
|
113
|
+
assert [(s.id, s.label, s.adapter.name, s.adapter.repo) for s in sources] == [
|
|
114
|
+
("acme-api-ci", "api CI", "github-actions", "acme/api"),
|
|
115
|
+
("acme-api-merge-queue", "api merge queue", "github-merge-queue", "acme/api"),
|
|
116
|
+
("acme-web-ci", "web CI", "github-actions", "acme/web"),
|
|
117
|
+
("acme-tools-ci", "Tools CI", "github-actions", "Acme/Tools"),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
assert cli.main(["profile", "list"]) == 0
|
|
121
|
+
listing = capsys.readouterr().out.splitlines()
|
|
122
|
+
assert listing[0] == "work" and " api merge queue (github-merge-queue acme/api)" in listing
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_profile_add_rejects_a_bad_repository_without_writing(tmp_path, monkeypatch, capsys):
|
|
126
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
127
|
+
with pytest.raises(SystemExit):
|
|
128
|
+
cli.main(["profile", "add", "work", "--repo", "not-a-repo"])
|
|
129
|
+
assert "owner/repo" in capsys.readouterr().err
|
|
130
|
+
assert not discovery.profile_path("work").exists()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_profile_add_keeps_ids_unique_and_symlinks_and_mode_intact(tmp_path, monkeypatch):
|
|
134
|
+
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
|
135
|
+
monkeypatch.setattr(github_merge_queue, "has_queue", lambda repo: asyncio.sleep(0, False))
|
|
136
|
+
real = tmp_path / "dotfiles" / "work.toml"
|
|
137
|
+
real.parent.mkdir()
|
|
138
|
+
real.write_text(CONFIG)
|
|
139
|
+
real.chmod(0o640)
|
|
140
|
+
discovery.profile_dir().mkdir(parents=True)
|
|
141
|
+
discovery.profile_path("work").symlink_to(real)
|
|
142
|
+
|
|
143
|
+
path, added, skipped = asyncio.run(
|
|
144
|
+
discovery.add_to_profile("work", ["a-b/c", "a/b-c", "O/R", "a/B-C"])
|
|
145
|
+
)
|
|
146
|
+
assert (added, skipped) == (["a-b/c", "a/b-c"], ["O/R", "a/B-C"])
|
|
147
|
+
assert path.is_symlink() and real.stat().st_mode & 0o777 == 0o640
|
|
148
|
+
assert [s.id for s in discovery.load(path)] == ["ci", "a-b-c-ci", "a-b-c-2-ci"]
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
"""Where sources come from when no --repo or --config is given.
|
|
2
|
-
|
|
3
|
-
Order: a repository's own ``.queue-watch.toml`` (only once trusted), then the
|
|
4
|
-
GitHub repository of the current checkout (Actions, plus its merge queue when the
|
|
5
|
-
default branch has one).
|
|
6
|
-
|
|
7
|
-
A repository config can run commands and open SSH connections, so a fresh clone
|
|
8
|
-
must not be able to do either on its own. ``queue-watch trust`` records the
|
|
9
|
-
SHA-256 of the file's exact bytes; any later edit needs trusting again, like
|
|
10
|
-
``direnv allow``. Files passed with --config or --profile are the user's own and
|
|
11
|
-
are trusted implicitly.
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import hashlib
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import subprocess
|
|
20
|
-
import tempfile
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
from .config import ConfigError, Source, build, parse
|
|
24
|
-
|
|
25
|
-
CONFIG_NAME = ".queue-watch.toml"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def state_dir() -> Path:
|
|
29
|
-
base = os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state"
|
|
30
|
-
return Path(base) / "queue-watch"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def profile_path(name: str) -> Path:
|
|
34
|
-
if not name or "/" in name or name.startswith("."):
|
|
35
|
-
raise ConfigError(f"profile must be a plain name, got {name!r}")
|
|
36
|
-
base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
|
|
37
|
-
return Path(base) / "queue-watch" / f"{name}.toml"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def git_root(cwd: Path) -> Path | None:
|
|
41
|
-
try:
|
|
42
|
-
result = subprocess.run(
|
|
43
|
-
["git", "-C", str(cwd), "rev-parse", "--show-toplevel"],
|
|
44
|
-
capture_output=True,
|
|
45
|
-
check=False,
|
|
46
|
-
text=True,
|
|
47
|
-
timeout=10,
|
|
48
|
-
)
|
|
49
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
50
|
-
return None
|
|
51
|
-
return Path(result.stdout.strip()) if result.returncode == 0 else None
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def repo_config(cwd: Path) -> Path | None:
|
|
55
|
-
root = git_root(cwd)
|
|
56
|
-
if root is None:
|
|
57
|
-
return None
|
|
58
|
-
path = root / CONFIG_NAME
|
|
59
|
-
return path if path.is_file() else None
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _store() -> Path:
|
|
63
|
-
return state_dir() / "trusted.json"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def _trusted() -> dict[str, str]:
|
|
67
|
-
try:
|
|
68
|
-
data = json.loads(_store().read_text())
|
|
69
|
-
except (OSError, ValueError):
|
|
70
|
-
return {}
|
|
71
|
-
return data if isinstance(data, dict) else {}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def digest(data: bytes) -> str:
|
|
75
|
-
return hashlib.sha256(data).hexdigest()
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def trust(path: Path) -> list[Source]:
|
|
79
|
-
"""Validate ``path`` and record its current bytes as trusted."""
|
|
80
|
-
path = path.resolve()
|
|
81
|
-
data = path.read_bytes()
|
|
82
|
-
sources = parse(data.decode(), path) # Never trust a file that cannot load.
|
|
83
|
-
trusted = _trusted()
|
|
84
|
-
trusted[str(path)] = digest(data)
|
|
85
|
-
store = _store()
|
|
86
|
-
store.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
-
with tempfile.NamedTemporaryFile("w", dir=store.parent, delete=False) as handle:
|
|
88
|
-
json.dump(trusted, handle, indent=2, sort_keys=True)
|
|
89
|
-
os.replace(handle.name, store)
|
|
90
|
-
return sources
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def load_trusted(path: Path) -> list[Source]:
|
|
94
|
-
"""Load a repository config only if these exact bytes were trusted."""
|
|
95
|
-
path = path.resolve()
|
|
96
|
-
data = path.read_bytes() # Check and parse the same bytes.
|
|
97
|
-
recorded = _trusted().get(str(path))
|
|
98
|
-
if recorded != digest(data):
|
|
99
|
-
state = "changed since it was trusted" if recorded else "is not trusted yet"
|
|
100
|
-
raise ConfigError(
|
|
101
|
-
f"{path} {state}. It can run commands and open SSH connections. "
|
|
102
|
-
"Review it, then run: queue-watch trust"
|
|
103
|
-
)
|
|
104
|
-
return parse(data.decode(), path)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def github_repo(cwd: Path) -> str | None:
|
|
108
|
-
try:
|
|
109
|
-
result = subprocess.run(
|
|
110
|
-
["gh", "repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"],
|
|
111
|
-
capture_output=True,
|
|
112
|
-
check=False,
|
|
113
|
-
text=True,
|
|
114
|
-
timeout=20,
|
|
115
|
-
cwd=cwd,
|
|
116
|
-
)
|
|
117
|
-
except (OSError, subprocess.TimeoutExpired):
|
|
118
|
-
return None
|
|
119
|
-
name = result.stdout.strip()
|
|
120
|
-
return name if result.returncode == 0 and name else None
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
async def detect(cwd: Path) -> list[Source]:
|
|
124
|
-
path = repo_config(cwd)
|
|
125
|
-
if path is not None:
|
|
126
|
-
return load_trusted(path)
|
|
127
|
-
repo = github_repo(cwd)
|
|
128
|
-
if repo is None:
|
|
129
|
-
raise ConfigError(
|
|
130
|
-
"no sources found: not inside a GitHub checkout with an authenticated gh, "
|
|
131
|
-
f"and no {CONFIG_NAME}. Pass --repo, --config or --profile."
|
|
132
|
-
)
|
|
133
|
-
return await github_sources(repo)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
async def github_sources(repo: str) -> list[Source]:
|
|
137
|
-
"""Actions for ``repo``, plus its merge queue when the default branch has one."""
|
|
138
|
-
from .adapters.github_merge_queue import has_queue
|
|
139
|
-
|
|
140
|
-
sources = [build({"id": "ci", "adapter": "github-actions", "repo": repo}, repo)]
|
|
141
|
-
try:
|
|
142
|
-
queued = await has_queue(repo)
|
|
143
|
-
except (OSError, RuntimeError, ValueError, KeyError) as error:
|
|
144
|
-
raise ConfigError(f"cannot check {repo} for a merge queue: {error}") from error
|
|
145
|
-
if queued:
|
|
146
|
-
sources.append(
|
|
147
|
-
build({"id": "merge-queue", "adapter": "github-merge-queue", "repo": repo}, repo)
|
|
148
|
-
)
|
|
149
|
-
return sources
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
"""Source discovery: repository config trust, profiles, GitHub fallback."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import subprocess
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
|
|
10
|
-
from queue_watch import cli, discovery
|
|
11
|
-
from queue_watch.adapters import github_merge_queue
|
|
12
|
-
from queue_watch.config import ConfigError
|
|
13
|
-
|
|
14
|
-
CONFIG = '[[source]]\nid = "ci"\nadapter = "github-actions"\nrepo = "o/r"\n'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@pytest.fixture
|
|
18
|
-
def checkout(tmp_path, monkeypatch):
|
|
19
|
-
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
|
20
|
-
repo = tmp_path / "repo"
|
|
21
|
-
(repo / "sub").mkdir(parents=True)
|
|
22
|
-
subprocess.run(["git", "init", "-q", str(repo)], check=True)
|
|
23
|
-
return repo
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def test_repo_config_needs_trust_and_retrust_after_any_edit(checkout):
|
|
27
|
-
path = checkout / discovery.CONFIG_NAME
|
|
28
|
-
path.write_text(CONFIG)
|
|
29
|
-
with pytest.raises(ConfigError, match="is not trusted yet.*queue-watch trust"):
|
|
30
|
-
asyncio.run(discovery.detect(checkout / "sub"))
|
|
31
|
-
|
|
32
|
-
discovery.trust(path)
|
|
33
|
-
(source,) = asyncio.run(discovery.detect(checkout / "sub"))
|
|
34
|
-
assert source.id == "ci"
|
|
35
|
-
|
|
36
|
-
path.write_text(CONFIG.replace('"ci"', '"build"'))
|
|
37
|
-
with pytest.raises(ConfigError, match="changed since it was trusted"):
|
|
38
|
-
asyncio.run(discovery.detect(checkout))
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def test_a_config_that_cannot_load_is_never_trusted(checkout):
|
|
42
|
-
path = checkout / discovery.CONFIG_NAME
|
|
43
|
-
path.write_text('[[source]]\nid = "a"\nadapter = "jenkins"\n')
|
|
44
|
-
with pytest.raises(ConfigError, match="adapter must be one of"):
|
|
45
|
-
discovery.trust(path)
|
|
46
|
-
assert not (checkout.parent / "state" / "queue-watch" / "trusted.json").exists()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def test_trust_command_trusts_the_checkout_config(checkout, monkeypatch, capsys):
|
|
50
|
-
(checkout / discovery.CONFIG_NAME).write_text(CONFIG)
|
|
51
|
-
monkeypatch.chdir(checkout / "sub")
|
|
52
|
-
assert cli.main(["trust"]) == 0
|
|
53
|
-
assert "ci: github-actions" in capsys.readouterr().out
|
|
54
|
-
assert len(asyncio.run(discovery.detect(checkout))) == 1
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
@pytest.mark.parametrize("queued", [True, False])
|
|
58
|
-
def test_github_fallback_adds_merge_queue_only_when_present(checkout, monkeypatch, queued):
|
|
59
|
-
async def has_queue(repo):
|
|
60
|
-
assert repo == "o/r"
|
|
61
|
-
return queued
|
|
62
|
-
|
|
63
|
-
monkeypatch.setattr(discovery, "github_repo", lambda cwd: "o/r")
|
|
64
|
-
monkeypatch.setattr(github_merge_queue, "has_queue", has_queue)
|
|
65
|
-
sources = asyncio.run(discovery.detect(checkout))
|
|
66
|
-
expected = ["github-actions", "github-merge-queue"] if queued else ["github-actions"]
|
|
67
|
-
assert [source.adapter.name for source in sources] == expected
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def test_no_checkout_and_no_config_names_every_option(tmp_path, monkeypatch):
|
|
71
|
-
monkeypatch.setattr(discovery, "github_repo", lambda cwd: None)
|
|
72
|
-
with pytest.raises(ConfigError, match="--repo, --config or --profile"):
|
|
73
|
-
asyncio.run(discovery.detect(tmp_path))
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def test_profiles_live_under_xdg_config_home(tmp_path, monkeypatch):
|
|
77
|
-
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path))
|
|
78
|
-
assert discovery.profile_path("work") == tmp_path / "queue-watch" / "work.toml"
|
|
79
|
-
for bad in ("../work", ".hidden", ""):
|
|
80
|
-
with pytest.raises(ConfigError, match="plain name"):
|
|
81
|
-
discovery.profile_path(bad)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw
RENAMED
|
File without changes
|
{queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw
RENAMED
|
File without changes
|
{queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|