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/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
@@ -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