queue-watch 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- queue_watch/adapters/__init__.py +34 -0
- queue_watch/adapters/command.py +54 -0
- queue_watch/adapters/flock_queue.py +322 -0
- queue_watch/adapters/github_actions.py +164 -0
- queue_watch/adapters/github_merge_queue.py +141 -0
- queue_watch/app.py +876 -0
- queue_watch/changes.py +181 -0
- queue_watch/cli.py +129 -0
- queue_watch/config.py +105 -0
- queue_watch/discovery.py +149 -0
- queue_watch/model.py +188 -0
- queue_watch/process.py +67 -0
- queue_watch/widgets.py +774 -0
- queue_watch-0.1.0.dist-info/METADATA +141 -0
- queue_watch-0.1.0.dist-info/RECORD +19 -0
- queue_watch-0.1.0.dist-info/WHEEL +4 -0
- queue_watch-0.1.0.dist-info/entry_points.txt +2 -0
- queue_watch-0.1.0.dist-info/licenses/LICENSE +21 -0
queue_watch/changes.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Changes: records from every source that describe the same branch or commit.
|
|
2
|
+
|
|
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),
|
|
5
|
+
``queue_entry`` (live merge queue position) and ``attempt`` (a finished merge
|
|
6
|
+
attempt). Other kinds are only shown per source.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from typing import NamedTuple
|
|
15
|
+
|
|
16
|
+
# Worst first: decides the one state that represents a whole change.
|
|
17
|
+
PRIORITY = ("failure", "blocked", "running", "queued", "cancelled", "success", "neutral", "unknown")
|
|
18
|
+
GROUPS = (
|
|
19
|
+
("attention", "Needs attention", "blocked"),
|
|
20
|
+
("running", "Running", "running"),
|
|
21
|
+
("queued", "Queued", "queued"),
|
|
22
|
+
("done", "Done", "success"),
|
|
23
|
+
)
|
|
24
|
+
TIME_KEYS = ("created_at", "updated_at", "started_at", "finished_at")
|
|
25
|
+
ROLES = frozenset({"run", "queue_entry", "attempt"})
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Hit(NamedTuple):
|
|
29
|
+
"""One record and the id of the source that reported it."""
|
|
30
|
+
|
|
31
|
+
source: str
|
|
32
|
+
record: dict
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def worst(states: list[str]) -> str | None:
|
|
36
|
+
return min(states, key=PRIORITY.index, default=None)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def stamp(record: dict, *keys: str) -> float:
|
|
40
|
+
"""Latest of ``keys`` (all timestamps by default) as epoch seconds; 0 if none."""
|
|
41
|
+
times = [
|
|
42
|
+
datetime.fromisoformat(record[key]).timestamp()
|
|
43
|
+
for key in keys or TIME_KEYS
|
|
44
|
+
if record.get(key)
|
|
45
|
+
]
|
|
46
|
+
return max(times, default=0.0)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Change:
|
|
51
|
+
key: str
|
|
52
|
+
ref: str | None = None
|
|
53
|
+
sha: str | None = None
|
|
54
|
+
runs: list[Hit] = field(default_factory=list) # newest first
|
|
55
|
+
entries: list[Hit] = field(default_factory=list) # queue order
|
|
56
|
+
attempts: list[Hit] = field(default_factory=list) # newest first
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def entry(self) -> Hit | None:
|
|
60
|
+
return self.entries[0] if self.entries else None
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def checks(self) -> list[Hit]:
|
|
64
|
+
"""The newest run of each workflow: what CI currently says about this change."""
|
|
65
|
+
latest: dict[tuple[str, str], Hit] = {}
|
|
66
|
+
for hit in self.runs:
|
|
67
|
+
latest.setdefault((hit.source, hit.record["title"]), hit)
|
|
68
|
+
return list(latest.values())
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def ci_state(self) -> str | None:
|
|
72
|
+
return worst([hit.record["state"] for hit in self.checks])
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def live_states(self) -> list[str]:
|
|
76
|
+
states = [self.ci_state] if self.runs else []
|
|
77
|
+
return states + ([self.entry.record["state"]] if self.entry else [])
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def state(self) -> str:
|
|
81
|
+
if self.live_states:
|
|
82
|
+
return worst(self.live_states)
|
|
83
|
+
return self.attempts[0].record["state"] if self.attempts else "unknown"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def group(self) -> str:
|
|
87
|
+
live = set(self.live_states)
|
|
88
|
+
if live & {"failure", "blocked"}:
|
|
89
|
+
return "attention"
|
|
90
|
+
if "running" in live:
|
|
91
|
+
return "running"
|
|
92
|
+
if "queued" in live:
|
|
93
|
+
return "queued"
|
|
94
|
+
return "done"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def title(self) -> str:
|
|
98
|
+
for hit in (self.entry, *self.attempts):
|
|
99
|
+
if hit:
|
|
100
|
+
return re.sub(r"^#\d+\s+", "", hit.record["title"])
|
|
101
|
+
newest = self.runs[0].record if self.runs else {}
|
|
102
|
+
return newest.get("subtitle") or newest.get("title") or self.key
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def pr(self) -> int | None:
|
|
106
|
+
if self.entry and isinstance(
|
|
107
|
+
number := (self.entry.record.get("fields") or {}).get("pull_request"), int
|
|
108
|
+
):
|
|
109
|
+
return number
|
|
110
|
+
for hit in (self.entry, *self.attempts):
|
|
111
|
+
if hit and (match := re.match(r"#(\d+)\b", hit.record["title"])):
|
|
112
|
+
return int(match.group(1))
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def owner(self) -> str | None:
|
|
117
|
+
for hit in (self.entry, *self.attempts, *self.runs):
|
|
118
|
+
if hit and hit.record.get("owner"):
|
|
119
|
+
return hit.record["owner"]
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def url(self) -> str | None:
|
|
124
|
+
for hit in (self.entry, *self.runs, *self.attempts):
|
|
125
|
+
if hit and hit.record.get("url"):
|
|
126
|
+
return hit.record["url"]
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def activity(self) -> float:
|
|
131
|
+
hits = (*self.runs, *self.entries, *self.attempts)
|
|
132
|
+
return max((stamp(hit.record) for hit in hits), default=0.0)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
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
|
+
changes: list[Change] = []
|
|
140
|
+
for source, snapshot in snapshots.items():
|
|
141
|
+
for record in snapshot.get("records", []):
|
|
142
|
+
ref, sha = record.get("ref"), record.get("sha")
|
|
143
|
+
if record["kind"] not in ROLES or not (ref or sha):
|
|
144
|
+
continue
|
|
145
|
+
change = (ref and by_ref.get(ref)) or (sha and by_sha.get(sha))
|
|
146
|
+
if not change:
|
|
147
|
+
change = Change(key=ref or sha)
|
|
148
|
+
changes.append(change)
|
|
149
|
+
if ref:
|
|
150
|
+
by_ref.setdefault(ref, change)
|
|
151
|
+
change.ref = change.ref or ref
|
|
152
|
+
if sha:
|
|
153
|
+
by_sha.setdefault(sha, change)
|
|
154
|
+
change.sha = change.sha or sha
|
|
155
|
+
hit = Hit(source, record)
|
|
156
|
+
match record["kind"]:
|
|
157
|
+
case "run":
|
|
158
|
+
change.runs.append(hit)
|
|
159
|
+
case "queue_entry":
|
|
160
|
+
change.entries.append(hit)
|
|
161
|
+
case "attempt":
|
|
162
|
+
change.attempts.append(hit)
|
|
163
|
+
for change in changes:
|
|
164
|
+
change.runs.sort(key=lambda hit: stamp(hit.record, "created_at"), reverse=True)
|
|
165
|
+
change.entries.sort(key=lambda hit: position(hit.record))
|
|
166
|
+
change.attempts.sort(key=lambda hit: stamp(hit.record), reverse=True)
|
|
167
|
+
changes.sort(key=lambda c: (position(c.entry.record) if c.entry else 1e9, -c.activity))
|
|
168
|
+
return changes
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def position(record: dict) -> float:
|
|
172
|
+
value = record.get("position")
|
|
173
|
+
return value if isinstance(value, int) else 1e9
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def grouped(changes: list[Change]) -> list[tuple[str, str, str, list[Change]]]:
|
|
177
|
+
"""(key, label, representative state, changes) per group, in display order."""
|
|
178
|
+
return [
|
|
179
|
+
(key, label, state, [change for change in changes if change.group == key])
|
|
180
|
+
for key, label, state in GROUPS
|
|
181
|
+
]
|
queue_watch/cli.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""queue-watch: read-only terminal dashboard for CI runs and merge queues.
|
|
2
|
+
|
|
3
|
+
Click tabs, rows, filters and toolbar buttons; scroll tables and the inspector
|
|
4
|
+
with the wheel. Keyboard: tab/arrows, / search, r refresh, p pause, o open the
|
|
5
|
+
selected record, q quit. Times are shown in America/Los_Angeles.
|
|
6
|
+
|
|
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.
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
queue-watch # inside a GitHub checkout
|
|
16
|
+
queue-watch --repo owner/repo
|
|
17
|
+
queue-watch --profile work # ~/.config/queue-watch/work.toml
|
|
18
|
+
queue-watch --config sources.toml --once
|
|
19
|
+
queue-watch trust # after reviewing .queue-watch.toml
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import UTC, datetime
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from . import __version__
|
|
32
|
+
from .config import ConfigError, Source, load
|
|
33
|
+
from .discovery import CONFIG_NAME, detect, github_sources, profile_path, repo_config, trust
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def once(sources: list[Source]) -> int:
|
|
37
|
+
results = await asyncio.gather(
|
|
38
|
+
*(source.snapshot() for source in sources), return_exceptions=True
|
|
39
|
+
)
|
|
40
|
+
output = {"observed_at": datetime.now(UTC).isoformat(), "sources": {}}
|
|
41
|
+
failed = False
|
|
42
|
+
for source, result in zip(sources, results, strict=True):
|
|
43
|
+
entry = {"adapter": source.adapter.name, "label": source.label}
|
|
44
|
+
if isinstance(result, BaseException):
|
|
45
|
+
entry["error"] = str(result) or type(result).__name__
|
|
46
|
+
failed = True
|
|
47
|
+
else:
|
|
48
|
+
entry["snapshot"] = result
|
|
49
|
+
output["sources"][source.id] = entry
|
|
50
|
+
print(json.dumps(output, indent=2))
|
|
51
|
+
return int(failed)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def trust_command(argv: list[str]) -> int:
|
|
55
|
+
parser = argparse.ArgumentParser(
|
|
56
|
+
prog="queue-watch trust",
|
|
57
|
+
description=f"Trust a repository's {CONFIG_NAME} after reviewing it. Trust "
|
|
58
|
+
"covers the file's exact bytes; edits need trusting again.",
|
|
59
|
+
)
|
|
60
|
+
parser.add_argument("path", nargs="?", type=Path, help=f"default: the checkout's {CONFIG_NAME}")
|
|
61
|
+
args = parser.parse_args(argv)
|
|
62
|
+
path = args.path or repo_config(Path.cwd())
|
|
63
|
+
if path is None:
|
|
64
|
+
parser.error(f"no {CONFIG_NAME} at the root of this Git checkout")
|
|
65
|
+
try:
|
|
66
|
+
sources = trust(path)
|
|
67
|
+
except (OSError, UnicodeDecodeError, ConfigError) as error:
|
|
68
|
+
parser.error(str(error))
|
|
69
|
+
print(f"Trusted {path.resolve()}")
|
|
70
|
+
for source in sources:
|
|
71
|
+
print(f" {source.id}: {source.adapter.name} ({source.label})")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
argv = sys.argv[1:] if argv is None else argv
|
|
77
|
+
if argv[:1] == ["trust"]:
|
|
78
|
+
return trust_command(argv[1:])
|
|
79
|
+
parser = argparse.ArgumentParser(
|
|
80
|
+
prog="queue-watch",
|
|
81
|
+
description=__doc__,
|
|
82
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument("--version", action="version", version=f"queue-watch {__version__}")
|
|
85
|
+
group = parser.add_mutually_exclusive_group()
|
|
86
|
+
group.add_argument("--repo", help="GitHub owner/repo: Actions plus any merge queue")
|
|
87
|
+
group.add_argument("--config", type=Path, help="TOML file with [[source]] tables")
|
|
88
|
+
group.add_argument("--profile", help="NAME for ~/.config/queue-watch/NAME.toml")
|
|
89
|
+
parser.add_argument("--once", action="store_true", help="print JSON once; no TUI")
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--theme", default="tokyo-night", help="Textual theme (default tokyo-night)"
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("--ascii", action="store_true", help="ASCII glyphs and borders only")
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"--density",
|
|
96
|
+
choices=("compact", "comfortable"),
|
|
97
|
+
default="compact",
|
|
98
|
+
help="change rows: compact one-line, or comfortable with title, owner and PR "
|
|
99
|
+
"on a second line (press d to switch)",
|
|
100
|
+
)
|
|
101
|
+
args = parser.parse_args(argv)
|
|
102
|
+
try:
|
|
103
|
+
if args.config:
|
|
104
|
+
sources = load(args.config.expanduser())
|
|
105
|
+
elif args.profile:
|
|
106
|
+
sources = load(profile_path(args.profile))
|
|
107
|
+
elif args.repo:
|
|
108
|
+
sources = asyncio.run(github_sources(args.repo))
|
|
109
|
+
else:
|
|
110
|
+
sources = asyncio.run(detect(Path.cwd()))
|
|
111
|
+
except (ConfigError, OSError, UnicodeDecodeError) as error:
|
|
112
|
+
parser.error(str(error))
|
|
113
|
+
if args.once:
|
|
114
|
+
return asyncio.run(once(sources))
|
|
115
|
+
if not sys.stdin.isatty() or not sys.stdout.isatty():
|
|
116
|
+
parser.error("interactive mode needs a terminal; use --once for JSON")
|
|
117
|
+
from .app import QueueWatch
|
|
118
|
+
|
|
119
|
+
app = QueueWatch(sources, theme=args.theme, ascii=args.ascii, density=args.density)
|
|
120
|
+
if args.theme not in app.available_themes:
|
|
121
|
+
parser.error(
|
|
122
|
+
f"unknown theme {args.theme!r}; choose from {', '.join(sorted(app.available_themes))}"
|
|
123
|
+
)
|
|
124
|
+
app.run(mouse=True)
|
|
125
|
+
return 0
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
raise SystemExit(main())
|
queue_watch/config.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Sources: configured adapter instances, loaded from TOML or command-line flags.
|
|
2
|
+
|
|
3
|
+
[[source]]
|
|
4
|
+
id = "ci" # unique; required
|
|
5
|
+
adapter = "github-actions" # required; see queue_watch.adapters.registry()
|
|
6
|
+
label = "CI" # optional; defaults to the adapter's label
|
|
7
|
+
interval = 15 # optional refresh seconds
|
|
8
|
+
repo = "owner/repo" # every other key is an adapter option
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import inspect
|
|
14
|
+
import tomllib
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .adapters import Adapter, registry
|
|
19
|
+
from .model import validate_snapshot
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(ValueError):
|
|
23
|
+
"""The source configuration cannot be used."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class Source:
|
|
28
|
+
id: str
|
|
29
|
+
label: str
|
|
30
|
+
interval: float
|
|
31
|
+
adapter: Adapter
|
|
32
|
+
|
|
33
|
+
async def snapshot(self) -> dict:
|
|
34
|
+
return validate_snapshot(await self.adapter.snapshot())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
INJECTED = frozenset({"config_dir"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build(entry: dict, where: str, config_dir: Path | None = None) -> Source:
|
|
41
|
+
options = dict(entry)
|
|
42
|
+
source_id = options.pop("id", None)
|
|
43
|
+
name = options.pop("adapter", None)
|
|
44
|
+
if not isinstance(source_id, str) or not source_id:
|
|
45
|
+
raise ConfigError(f"{where}: id must be a non-empty string")
|
|
46
|
+
adapters = registry()
|
|
47
|
+
if name not in adapters:
|
|
48
|
+
raise ConfigError(
|
|
49
|
+
f"{where}: adapter must be one of {', '.join(sorted(adapters))}, got {name!r}"
|
|
50
|
+
)
|
|
51
|
+
kind = adapters[name]
|
|
52
|
+
label = options.pop("label", kind.label)
|
|
53
|
+
interval = options.pop("interval", kind.interval)
|
|
54
|
+
if not isinstance(interval, (int, float)) or isinstance(interval, bool) or interval < 1:
|
|
55
|
+
raise ConfigError(f"{where}: interval must be a number of seconds >= 1")
|
|
56
|
+
parameters = inspect.signature(kind.__init__).parameters
|
|
57
|
+
accepted = {key: p for key, p in parameters.items() if key not in {"self", *INJECTED}}
|
|
58
|
+
unknown = sorted(set(options) - set(accepted))
|
|
59
|
+
if unknown:
|
|
60
|
+
raise ConfigError(
|
|
61
|
+
f"{where}: {name} does not accept {', '.join(unknown)}; "
|
|
62
|
+
f"options are {', '.join(accepted)}"
|
|
63
|
+
)
|
|
64
|
+
missing = [
|
|
65
|
+
key
|
|
66
|
+
for key, parameter in accepted.items()
|
|
67
|
+
if parameter.default is inspect.Parameter.empty and key not in options
|
|
68
|
+
]
|
|
69
|
+
if missing:
|
|
70
|
+
raise ConfigError(f"{where}: {name} needs {', '.join(missing)}")
|
|
71
|
+
if "config_dir" in parameters:
|
|
72
|
+
options["config_dir"] = config_dir
|
|
73
|
+
try:
|
|
74
|
+
adapter = kind(**options)
|
|
75
|
+
except (TypeError, ValueError) as error:
|
|
76
|
+
raise ConfigError(f"{where}: {error}") from error
|
|
77
|
+
return Source(source_id, str(label), float(interval), adapter)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(path: Path) -> list[Source]:
|
|
81
|
+
try:
|
|
82
|
+
text = path.read_text()
|
|
83
|
+
except OSError as error:
|
|
84
|
+
raise ConfigError(f"{path}: {error}") from error
|
|
85
|
+
return parse(text, path)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def parse(text: str, path: Path) -> list[Source]:
|
|
89
|
+
"""Sources from TOML ``text`` that was read from ``path``."""
|
|
90
|
+
try:
|
|
91
|
+
data = tomllib.loads(text)
|
|
92
|
+
except tomllib.TOMLDecodeError as error:
|
|
93
|
+
raise ConfigError(f"{path}: {error}") from error
|
|
94
|
+
entries = data.get("source")
|
|
95
|
+
if not isinstance(entries, list) or not entries:
|
|
96
|
+
raise ConfigError(f"{path}: define at least one [[source]] table")
|
|
97
|
+
sources = [
|
|
98
|
+
build(entry, f"{path} source {index + 1}", path.parent.resolve())
|
|
99
|
+
for index, entry in enumerate(entries)
|
|
100
|
+
]
|
|
101
|
+
ids = [source.id for source in sources]
|
|
102
|
+
duplicates = sorted({source_id for source_id in ids if ids.count(source_id) > 1})
|
|
103
|
+
if duplicates:
|
|
104
|
+
raise ConfigError(f"{path}: duplicate source id {', '.join(duplicates)}")
|
|
105
|
+
return sources
|
queue_watch/discovery.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
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
|