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.
Files changed (39) hide show
  1. {queue_watch-0.2.0 → queue_watch-0.3.0}/PKG-INFO +17 -3
  2. {queue_watch-0.2.0 → queue_watch-0.3.0}/README.md +16 -2
  3. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/__init__.py +1 -1
  4. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_actions.py +1 -1
  5. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/github_merge_queue.py +1 -1
  6. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/app.py +2 -1
  7. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/changes.py +23 -8
  8. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/cli.py +62 -6
  9. queue_watch-0.3.0/src/queue_watch/discovery.py +250 -0
  10. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/model.py +7 -6
  11. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/widgets.py +4 -2
  12. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_changes.py +35 -0
  13. queue_watch-0.3.0/tests/test_discovery.py +148 -0
  14. queue_watch-0.2.0/src/queue_watch/discovery.py +0 -149
  15. queue_watch-0.2.0/tests/test_discovery.py +0 -81
  16. {queue_watch-0.2.0 → queue_watch-0.3.0}/.gitignore +0 -0
  17. {queue_watch-0.2.0 → queue_watch-0.3.0}/LICENSE +0 -0
  18. {queue_watch-0.2.0 → queue_watch-0.3.0}/examples/queue-status.py +0 -0
  19. {queue_watch-0.2.0 → queue_watch-0.3.0}/examples/queue.json +0 -0
  20. {queue_watch-0.2.0 → queue_watch-0.3.0}/examples/sources.toml +0 -0
  21. {queue_watch-0.2.0 → queue_watch-0.3.0}/pyproject.toml +0 -0
  22. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/__init__.py +0 -0
  23. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/command.py +0 -0
  24. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/adapters/flock_queue.py +0 -0
  25. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/config.py +0 -0
  26. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/demo.py +0 -0
  27. {queue_watch-0.2.0 → queue_watch-0.3.0}/src/queue_watch/process.py +0 -0
  28. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_comfortable_density.raw +0 -0
  29. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[narrow].raw +0 -0
  30. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[short].raw +0 -0
  31. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/__snapshots__/test_snapshots/test_layout[wide].raw +0 -0
  32. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_app.py +0 -0
  33. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_command.py +0 -0
  34. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_contract.py +0 -0
  35. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_demo.py +0 -0
  36. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_flock_queue.py +0 -0
  37. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_github_actions.py +0 -0
  38. {queue_watch-0.2.0 → queue_watch-0.3.0}/tests/test_github_merge_queue.py +0 -0
  39. {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.2.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. `--profile NAME` reads `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`).
69
+ With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the current Git checkout. Without one, it watches the checkout's GitHub repository: Actions runs, plus the merge queue when the default branch has one. Outside a GitHub checkout it opens the profile named `default`.
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. `--profile NAME` reads `$XDG_CONFIG_HOME/queue-watch/NAME.toml` (default `~/.config`).
42
+ With no flags, queue-watch looks for a `.queue-watch.toml` at the root of the current Git checkout. Without one, it watches the checkout's GitHub repository: Actions runs, plus the merge queue when the default branch has one. Outside a GitHub checkout it opens the profile named `default`.
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
 
@@ -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.3.0"
@@ -76,7 +76,7 @@ class GitHubActions(Adapter):
76
76
  return {
77
77
  "schema": SCHEMA,
78
78
  "observed_at": datetime.now(UTC).isoformat(),
79
- "records": [self.record(run) for run in ordered],
79
+ "records": [{**self.record(run), "repo": self.repo} for run in ordered],
80
80
  "views": [
81
81
  {
82
82
  "kind": "run",
@@ -82,7 +82,7 @@ class GitHubMergeQueue(Adapter):
82
82
  warnings.append(
83
83
  f"showing first {len(entries['nodes'])} of {entries['totalCount']} entries"
84
84
  )
85
- records = [self.record(entry) for entry in entries["nodes"]]
85
+ records = [{**self.record(entry), "repo": self.repo} for entry in entries["nodes"]]
86
86
  return {
87
87
  "schema": SCHEMA,
88
88
  "observed_at": datetime.now(UTC).isoformat(),
@@ -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.ref or change.key)]
639
+ meta = [clean(change.repo)] if change.repo else []
640
+ meta.append(clean(change.ref or change.sha or change.key))
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. 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,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 CONFIG_NAME, detect, github_sources, profile_path, repo_config, trust
36
+ from .discovery import (
37
+ CONFIG_NAME,
38
+ DEFAULT_PROFILE,
39
+ add_to_profile,
40
+ describe_profile,
41
+ detect,
42
+ github_sources,
43
+ profile_path,
44
+ profiles,
45
+ repo_config,
46
+ trust,
47
+ )
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
- Optional: ``raw_state``, ``subtitle``, ``ref``, ``sha``, ``owner``, ``position``
18
- (int), ``created_at``, ``updated_at``, ``started_at``, ``finished_at`` (ISO 8601),
19
- ``url``, ``fields`` (JSON object for the inspector) and ``children`` (records,
20
- for example jobs and steps).
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
- return Content.assemble((clean(name), "b"))
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