alissa-tools-github-devloop 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.
@@ -0,0 +1,11 @@
1
+ """devloop — a GitHub watcher that turns `alissa:develop`-labeled issues into
2
+ fresh developer sessions: the developer-side counterpart of reviewloop.
3
+
4
+ The moving parts: `config` (three-layer settings + reviewer rails),
5
+ `ghclient` (gh api access, self-assignment in-flight marker), `state` (the
6
+ attempt ledger), `loop` (the DevWatcher decision state machine and the
7
+ DEV_DIRECTIVE contract), `alissa` (session queue + worker plumbing), and
8
+ `__main__` (the alissa-devloop CLI).
9
+ """
10
+
11
+ __all__: list[str] = []
@@ -0,0 +1,217 @@
1
+ """CLI entry point: alissa-devloop (or python -m alissa.tools.github.devloop)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import logging
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .config import (
12
+ HUB_ADD,
13
+ HUB_SKIP,
14
+ ON_MISSING_SKIP,
15
+ ON_MISSING_SPAWN,
16
+ ON_MISSING_WARN,
17
+ Config,
18
+ load_config_file,
19
+ resolve_config_path,
20
+ resolve_reviewers,
21
+ )
22
+ from .ghclient import IdentityMismatch, RateLimited
23
+ from .loop import DevWatcher
24
+ from .proc import CommandError
25
+ from .version import version
26
+
27
+ log = logging.getLogger(__name__)
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ p = argparse.ArgumentParser(
32
+ prog="alissa-devloop",
33
+ description="Watch GitHub for alissa:develop-labeled issues and spawn "
34
+ "fresh developer sessions — the developer-side counterpart of "
35
+ "alissa-reviewloop.",
36
+ epilog="Every setting below can also live in the config file; CLI "
37
+ "arguments win, and ALISSA_DEV_REVIEWERS wins over --reviewer. "
38
+ "workspace_root is CLI-only, so one config can drive several daemons "
39
+ "over different workspaces.",
40
+ )
41
+
42
+ p.add_argument(
43
+ "--version", action="version", version=f"%(prog)s {version.value}"
44
+ )
45
+ p.add_argument(
46
+ "--workspace-root",
47
+ type=Path,
48
+ default=None,
49
+ metavar="PATH",
50
+ help="the Alissa Code Workspace to watch (default: current directory)",
51
+ )
52
+ p.add_argument(
53
+ "-c",
54
+ "--config-path",
55
+ "--config",
56
+ dest="config_path",
57
+ type=Path,
58
+ default=None,
59
+ metavar="PATH",
60
+ help="config file; without it, ./devloop.config.json then "
61
+ "<workspace-root>/devloop.config.json, else defaults only",
62
+ )
63
+
64
+ mode = p.add_argument_group("mode")
65
+ mode.add_argument("--once", action="store_true", help="run a single poll pass and exit")
66
+ mode.add_argument("-v", "--verbose", action="store_true")
67
+
68
+ over = p.add_argument_group("config overrides (win over the config file)")
69
+ over.add_argument(
70
+ "--repo",
71
+ dest="repos",
72
+ action="append",
73
+ metavar="OWNER/REPO",
74
+ help="watch this repo for labeled issues; repeatable. When given, "
75
+ "REPLACES the config `repos` list; when absent, the config list "
76
+ "applies. Emptying the allowlist (which turns the daemon off — an "
77
+ "empty allowlist watches NOTHING) is done in the config file.",
78
+ )
79
+ over.add_argument("--poll-interval", type=int, metavar="SECONDS")
80
+ over.add_argument("--label", metavar="LABEL", help="issue label that marks dev-ready work")
81
+ over.add_argument("--hub-template", metavar="TEMPLATE")
82
+ over.add_argument("--agent-profile", metavar="NAME")
83
+ over.add_argument("--developer-login", metavar="LOGIN")
84
+ over.add_argument(
85
+ "--reviewer",
86
+ dest="reviewers",
87
+ action="append",
88
+ metavar="LOGIN",
89
+ help="request this reviewer at the ready-for-review flip; repeatable. "
90
+ "Replaces the config list. ALISSA_DEV_REVIEWERS overrides even this.",
91
+ )
92
+ over.add_argument("--state-path", type=Path, metavar="PATH")
93
+ over.add_argument("--attempt-cap", type=int, metavar="N", help="dev attempts per issue")
94
+ over.add_argument("--stale-minutes", type=int, metavar="MINUTES")
95
+ over.add_argument(
96
+ "--on-missing-origin-task",
97
+ choices=[ON_MISSING_WARN, ON_MISSING_SPAWN, ON_MISSING_SKIP],
98
+ )
99
+ over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
100
+
101
+ dry = over.add_mutually_exclusive_group()
102
+ dry.add_argument(
103
+ "--dry-run",
104
+ dest="dry_run",
105
+ action="store_true",
106
+ default=None,
107
+ help="decide and log, but never spawn a session or touch GitHub state",
108
+ )
109
+ dry.add_argument(
110
+ "--no-dry-run",
111
+ dest="dry_run",
112
+ action="store_false",
113
+ help="act for real even if the config sets dry_run",
114
+ )
115
+ return p
116
+
117
+
118
+ def overrides_from(args: argparse.Namespace) -> dict:
119
+ """CLI values, with None meaning 'not specified' so the config file shows
120
+ through. `repos`/`reviewers` become tuples so they match the file form."""
121
+ return {
122
+ "repos": tuple(args.repos) if args.repos else None,
123
+ "poll_interval": args.poll_interval,
124
+ "label": args.label,
125
+ "hub_template": args.hub_template,
126
+ "agent_profile": args.agent_profile,
127
+ "developer_login": args.developer_login,
128
+ "reviewers": tuple(args.reviewers) if args.reviewers else None,
129
+ "state_path": args.state_path,
130
+ "attempt_cap": args.attempt_cap,
131
+ "stale_minutes": args.stale_minutes,
132
+ "on_missing_origin_task": args.on_missing_origin_task,
133
+ "on_missing_hub": args.on_missing_hub,
134
+ "dry_run": args.dry_run,
135
+ }
136
+
137
+
138
+ def resolve_config(args: argparse.Namespace) -> Config:
139
+ workspace_root = args.workspace_root or Path.cwd()
140
+ path = resolve_config_path(args.config_path, workspace_root)
141
+
142
+ file_data = load_config_file(path) if path else {}
143
+ log.info("config: %s", path or "none found — defaults + CLI arguments only")
144
+
145
+ config = Config.build(workspace_root, file_data, overrides_from(args))
146
+ return dataclasses.replace(
147
+ config,
148
+ reviewers=resolve_reviewers(config.reviewers, config.manifest_path),
149
+ )
150
+
151
+
152
+ def log_effective_config(config: Config, login: str) -> None:
153
+ """The resolved settings, defaults and all. INFO carries the decisions an
154
+ operator needs; -v (DEBUG) shows the full surface."""
155
+ log.info("developing as GitHub user %s (from the gh token)", login)
156
+ log.info(
157
+ "watching %s for label %r",
158
+ ", ".join(config.repos) if config.repos
159
+ else "NO repos (empty allowlist — set `repos` or pass --repo)",
160
+ config.label,
161
+ )
162
+ log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
163
+ log.info(
164
+ "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min",
165
+ config.poll_interval, config.dry_run, config.attempt_cap, config.stale_minutes,
166
+ )
167
+ log.debug("hub_template: %s", config.hub_template)
168
+ log.debug("agent_profile: %s", config.agent_profile)
169
+ log.debug("developer_login: %s", config.developer_login or f"{login} (auto)")
170
+ log.debug("state_db: %s", config.state_db)
171
+ log.debug("on_missing_origin_task: %s", config.on_missing_origin_task)
172
+ log.debug("on_missing_hub: %s", config.on_missing_hub)
173
+
174
+
175
+ def main(argv: "list[str] | None" = None) -> int:
176
+ args = build_parser().parse_args(argv)
177
+
178
+ logging.basicConfig(
179
+ level=logging.DEBUG if args.verbose else logging.INFO,
180
+ format="%(asctime)s %(levelname)-7s %(message)s",
181
+ datefmt="%H:%M:%S",
182
+ )
183
+
184
+ try:
185
+ config = resolve_config(args)
186
+ log.info("workspace: %s", config.workspace_root)
187
+
188
+ watcher = DevWatcher(config)
189
+ for warning in watcher.preflight():
190
+ log.warning(warning)
191
+ log_effective_config(config, watcher.github.login)
192
+
193
+ if args.once:
194
+ watcher.poll_once()
195
+ else:
196
+ watcher.run_forever()
197
+ except IdentityMismatch as exc:
198
+ print(f"identity error: {exc}", file=sys.stderr)
199
+ return 2
200
+ except (FileNotFoundError, ValueError) as exc:
201
+ print(f"config error: {exc}", file=sys.stderr)
202
+ return 2
203
+ except RateLimited as exc:
204
+ # Only --once can get here: run_forever absorbs rate limits by
205
+ # backing off, but a single pass has no next pass to wait for.
206
+ print(f"rate limited: {exc}", file=sys.stderr)
207
+ return 1
208
+ except CommandError as exc:
209
+ print(f"error: {exc}", file=sys.stderr)
210
+ return 1
211
+ except KeyboardInterrupt:
212
+ return 0
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ raise SystemExit(main())
@@ -0,0 +1,75 @@
1
+ """Alissa CLI access: enqueue the fresh developer session and probe the
2
+ worker. The developer's Alissa task is created by the session itself (as a
3
+ downstream of the origin task), so unlike reviewloop there is no task lookup
4
+ here -- the daemon's only Alissa surfaces are the tmux queue and the worker."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ from .proc import CommandError, run
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+
16
+ class Alissa:
17
+ def enqueue_developer(
18
+ self,
19
+ *,
20
+ session: str,
21
+ directive: str,
22
+ cwd: Path,
23
+ agent: str,
24
+ dry_run: bool = False,
25
+ ) -> None:
26
+ argv = [
27
+ "alissa",
28
+ "tmux",
29
+ "queue",
30
+ "add",
31
+ session,
32
+ "--agent",
33
+ agent,
34
+ "--cwd",
35
+ str(cwd),
36
+ directive,
37
+ ]
38
+
39
+ if dry_run:
40
+ log.info("[dry-run] would enqueue: %s", " ".join(argv[:-1]) + " <directive>")
41
+ return
42
+
43
+ run(argv, timeout=60)
44
+
45
+ # Developers are one-shot per attempt: a finished (or dead) session must
46
+ # never be respawned by the worker -- staleness handling re-enqueues as
47
+ # attempt k+1 with a fresh session name instead. Best-effort: an older
48
+ # CLI without `queue set` should not fail the enqueue.
49
+ try:
50
+ run(["alissa", "tmux", "queue", "set", session, "respawn", "off"],
51
+ timeout=30, check=False)
52
+ except CommandError: # pragma: no cover - defence in depth
53
+ log.warning("could not set respawn off for %s", session)
54
+
55
+ def add_repo_to_workspace(
56
+ self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
57
+ ) -> None:
58
+ """Hub-ify a repo into the workspace (bare clone + main/ worktree) and
59
+ record it in alissa-workspace.yaml. Idempotent per the CLI's contract."""
60
+ argv = ["alissa", "code", "workspace", "add", f"{owner}/{repo}"]
61
+ if dry_run:
62
+ log.info("[dry-run] would run: %s (cwd=%s)", " ".join(argv), workspace_root)
63
+ return
64
+
65
+ log.info("hub-ifying %s/%s into %s", owner, repo, workspace_root)
66
+ # Cloning a repo can be slow; the poll loop tolerates a long pass.
67
+ run(argv, timeout=600, cwd=workspace_root)
68
+
69
+ def worker_running(self) -> bool:
70
+ """The queue only drains while `alissa worker` reconciles it."""
71
+ try:
72
+ out = run(["alissa", "worker", "status"], timeout=30, check=False)
73
+ except CommandError:
74
+ return False
75
+ return "not running" not in out.lower() and "no worker" not in out.lower()
@@ -0,0 +1,359 @@
1
+ """Daemon configuration.
2
+
3
+ Settings come from three layers, later winning over earlier:
4
+
5
+ 1. the defaults on `Config`
6
+ 2. a JSON config file (see `resolve_config_path`)
7
+ 3. CLI arguments
8
+
9
+ `workspace_root` is deliberately **not** a config key — it is a property of the
10
+ running process, not of the settings. That lets one config file drive several
11
+ daemons over different workspaces on the same machine, each pointed with
12
+ `--workspace-root` and narrowed with `--repo`.
13
+
14
+ Reviewers ride one extra rail on top of the three layers, highest first:
15
+ the `ALISSA_DEV_REVIEWERS` env var (authoritative when set) > `--reviewer`
16
+ flags > the config `reviewers` key > the workspace manifest's `reviewers:`
17
+ list (see `resolve_reviewers`).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any, Mapping
27
+
28
+ # What to do when a labeled issue has no matching Alissa origin task (the
29
+ # label may have been applied by hand, outside the Alissa task flow).
30
+ ON_MISSING_WARN = "warn_and_spawn" # spawn, but log loudly
31
+ ON_MISSING_SPAWN = "spawn_anyway" # spawn quietly, the issue carries the spec
32
+ ON_MISSING_SKIP = "skip" # ignore the issue until an origin task appears
33
+
34
+ _MISSING_MODES = {ON_MISSING_WARN, ON_MISSING_SPAWN, ON_MISSING_SKIP}
35
+
36
+ # What to do when a labeled issue lands in a repo that has no worktree hub yet.
37
+ HUB_SKIP = "skip"
38
+ HUB_ADD = "add" # `alissa code workspace add <org>/<repo>`
39
+
40
+ _HUB_MODES = {HUB_SKIP, HUB_ADD}
41
+
42
+ CONFIG_FILENAME = "devloop.config.json"
43
+
44
+ # Comma-separated reviewer logins; authoritative when set, even if empty.
45
+ ENV_REVIEWERS = "ALISSA_DEV_REVIEWERS"
46
+
47
+ # Keys accepted in the config file. workspace_root is excluded on purpose.
48
+ CONFIG_KEYS = (
49
+ "hub_template",
50
+ "poll_interval",
51
+ "label",
52
+ "repos",
53
+ "agent_profile",
54
+ "developer_login",
55
+ "reviewers",
56
+ "state_path",
57
+ "attempt_cap",
58
+ "stale_minutes",
59
+ "on_missing_origin_task",
60
+ "on_missing_hub",
61
+ "dry_run",
62
+ )
63
+
64
+ MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
65
+
66
+
67
+ def default_state_path(workspace_root: Path) -> Path:
68
+ return Path(workspace_root) / ".devloop" / "state.db"
69
+
70
+
71
+ def _string_list(value: Any, key: str) -> tuple[str, ...]:
72
+ """Coerce a config list, rejecting the scalar-for-list mistake loudly.
73
+
74
+ A bare JSON string would otherwise be exploded into characters by
75
+ `tuple(...)` — turning `"repos": "acme/widgets"` into a bogus 12-entry
76
+ allowlist that watches nothing yet still satisfies the `on_missing_hub`
77
+ guard's non-empty check. Fail at load time instead.
78
+ """
79
+ if isinstance(value, str):
80
+ raise ValueError(
81
+ f"{key} must be a list of strings, got a string: {value!r}. "
82
+ f"Use [{value!r}]."
83
+ )
84
+ if not isinstance(value, (list, tuple)):
85
+ raise ValueError(
86
+ f"{key} must be a list of strings, got {type(value).__name__}"
87
+ )
88
+ for item in value:
89
+ if not isinstance(item, str):
90
+ raise ValueError(
91
+ f"{key} must be a list of strings, got a "
92
+ f"{type(item).__name__} element: {item!r}"
93
+ )
94
+ return tuple(value)
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class Config:
99
+ # A property of the process, supplied by --workspace-root (default: cwd).
100
+ workspace_root: Path
101
+
102
+ # The hub ROOT — unlike reviewloop's `{root}/{repo}/main` there is no
103
+ # trailing checkout: dev sessions create their own task worktree under it.
104
+ hub_template: str = "{root}/{repo}"
105
+ poll_interval: int = 60
106
+ label: str = "alissa:develop"
107
+
108
+ # Allowlist of owner/repo. Empty means NOTHING is watched — a daemon that
109
+ # writes code only ever works an explicit allowlist. (This deliberately
110
+ # differs from reviewloop, where empty means "every repo".)
111
+ repos: tuple[str, ...] = ()
112
+
113
+ agent_profile: str = "claude"
114
+ developer_login: str | None = None # None -> resolve once via `gh api user`
115
+
116
+ # Post-merge reviewers as they appear in the winning layer -- read
117
+ # `resolve_reviewers` for the manifest/env fallbacks around this field.
118
+ reviewers: tuple[str, ...] = ()
119
+
120
+ # None means "derive from the workspace" -- read `state_db` for the
121
+ # resolved location, never this field.
122
+ state_path: Path | None = None
123
+
124
+ attempt_cap: int = 2
125
+ stale_minutes: int = 120
126
+
127
+ on_missing_origin_task: str = ON_MISSING_WARN
128
+ on_missing_hub: str = HUB_SKIP
129
+ dry_run: bool = False
130
+
131
+ # Derived at build time: `repos` casefolded for matching. GitHub names are
132
+ # case-insensitive, but `repos` keeps the operator's casing for logs/repr.
133
+ _repo_match: frozenset[str] = field(
134
+ default=frozenset(), init=False, repr=False, compare=False
135
+ )
136
+
137
+ def __post_init__(self) -> None:
138
+ object.__setattr__(
139
+ self, "workspace_root", Path(self.workspace_root).expanduser().resolve()
140
+ )
141
+ object.__setattr__(
142
+ self, "_repo_match", frozenset(r.casefold() for r in self.repos)
143
+ )
144
+
145
+ @property
146
+ def state_db(self) -> Path:
147
+ """Where the attempt ledger lives. Defaults inside the workspace so two
148
+ daemons watching different workspaces never share one."""
149
+ if self.state_path is None:
150
+ return default_state_path(self.workspace_root)
151
+ return Path(self.state_path).expanduser()
152
+
153
+ @property
154
+ def manifest_path(self) -> Path:
155
+ return self.workspace_root / "alissa-workspace.yaml"
156
+
157
+ def hub_for(self, owner: str, repo: str) -> Path:
158
+ return Path(
159
+ self.hub_template.format(
160
+ root=str(self.workspace_root), owner=owner, repo=repo
161
+ )
162
+ ).expanduser()
163
+
164
+ def watches(self, full_name: str) -> bool:
165
+ """Empty allowlist = watch nothing (NOT everything — the daemon spawns
166
+ sessions that write and push code, so the blast radius is opt-in).
167
+ Matching is case-insensitive, like GitHub owner/repo names."""
168
+ return full_name.casefold() in self._repo_match
169
+
170
+ @classmethod
171
+ def build(
172
+ cls,
173
+ workspace_root: Path,
174
+ file_data: Mapping[str, Any] | None = None,
175
+ overrides: Mapping[str, Any] | None = None,
176
+ ) -> "Config":
177
+ """Merge the layers and validate. `overrides` entries that are None mean
178
+ "not specified on the CLI" and fall through to the file / defaults."""
179
+ raw: dict[str, Any] = dict(file_data or {})
180
+
181
+ if "workspace_root" in raw:
182
+ raise ValueError(
183
+ "workspace_root is not a config key — it is a property of the "
184
+ "running process. Pass --workspace-root (or run the daemon from "
185
+ "the workspace), and remove it from the config file."
186
+ )
187
+
188
+ # Allow "_"-prefixed keys as inline comments, since JSON has none.
189
+ unknown = {k for k in set(raw) - set(CONFIG_KEYS) if not k.startswith("_")}
190
+ if unknown:
191
+ raise ValueError(
192
+ f"unknown config key(s): {', '.join(sorted(unknown))}. "
193
+ f"Valid keys: {', '.join(CONFIG_KEYS)}"
194
+ )
195
+
196
+ for key, value in (overrides or {}).items():
197
+ if value is not None:
198
+ raw[key] = value
199
+
200
+ mode = raw.get("on_missing_origin_task", cls.on_missing_origin_task)
201
+ if mode not in _MISSING_MODES:
202
+ raise ValueError(
203
+ f"on_missing_origin_task must be one of {sorted(_MISSING_MODES)}, got {mode!r}"
204
+ )
205
+
206
+ hub_mode = raw.get("on_missing_hub", cls.on_missing_hub)
207
+ if hub_mode not in _HUB_MODES:
208
+ raise ValueError(
209
+ f"on_missing_hub must be one of {sorted(_HUB_MODES)}, got {hub_mode!r}"
210
+ )
211
+
212
+ repos = _string_list(raw.get("repos", cls.repos), "repos")
213
+ if hub_mode == HUB_ADD and not repos:
214
+ # Anyone who can label an issue could otherwise cause an arbitrary
215
+ # repo to be cloned onto this machine and opened as an agent's cwd.
216
+ raise ValueError(
217
+ "on_missing_hub='add' requires a non-empty repos allowlist "
218
+ "(config `repos`, or one or more --repo flags) — auto-cloning "
219
+ "whatever repo grows a labeled issue is unbounded"
220
+ )
221
+
222
+ cap = int(raw.get("attempt_cap", cls.attempt_cap))
223
+ if cap < 1:
224
+ raise ValueError(f"attempt_cap must be >= 1, got {cap}")
225
+
226
+ stale = int(raw.get("stale_minutes", cls.stale_minutes))
227
+ if stale < 1:
228
+ raise ValueError(f"stale_minutes must be >= 1, got {stale}")
229
+
230
+ interval = int(raw.get("poll_interval", cls.poll_interval))
231
+ if interval < MIN_POLL_INTERVAL:
232
+ raise ValueError(
233
+ f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
234
+ )
235
+
236
+ state_path = raw.get("state_path")
237
+ return cls(
238
+ workspace_root=Path(workspace_root),
239
+ hub_template=raw.get("hub_template", cls.hub_template),
240
+ poll_interval=interval,
241
+ label=raw.get("label", cls.label),
242
+ repos=repos,
243
+ agent_profile=raw.get("agent_profile", cls.agent_profile),
244
+ developer_login=raw.get("developer_login"),
245
+ reviewers=_string_list(raw.get("reviewers", cls.reviewers), "reviewers"),
246
+ state_path=Path(state_path).expanduser() if state_path else None,
247
+ attempt_cap=cap,
248
+ stale_minutes=stale,
249
+ on_missing_origin_task=mode,
250
+ on_missing_hub=hub_mode,
251
+ dry_run=bool(raw.get("dry_run", cls.dry_run)),
252
+ )
253
+
254
+
255
+ def load_config_file(path: Path) -> dict[str, Any]:
256
+ try:
257
+ data = json.loads(Path(path).expanduser().read_text())
258
+ except json.JSONDecodeError as exc:
259
+ # json.loads has no filename to report, and discovery is implicit
260
+ # across two locations — always name the file that failed to parse.
261
+ raise ValueError(f"{path}: invalid JSON — {exc}") from exc
262
+ if not isinstance(data, dict):
263
+ raise ValueError(f"{path}: expected a JSON object, got {type(data).__name__}")
264
+ return data
265
+
266
+
267
+ def resolve_config_path(
268
+ explicit: Path | None, workspace_root: Path, cwd: Path | None = None
269
+ ) -> Path | None:
270
+ """Find the config file: explicit path, then cwd, then the workspace root.
271
+
272
+ Returns None when no config file exists — CLI arguments and defaults alone
273
+ are a valid way to run. An explicit path that does not exist is an error.
274
+ """
275
+ if explicit is not None:
276
+ path = Path(explicit).expanduser()
277
+ if not path.is_file():
278
+ raise FileNotFoundError(f"config file not found: {path}")
279
+ return path
280
+
281
+ cwd = Path.cwd() if cwd is None else Path(cwd)
282
+ for candidate in (cwd / CONFIG_FILENAME, Path(workspace_root) / CONFIG_FILENAME):
283
+ if candidate.is_file():
284
+ return candidate
285
+ return None
286
+
287
+
288
+ def _clean_item(item: str) -> str:
289
+ return item.strip().strip("'\"").strip()
290
+
291
+
292
+ def manifest_reviewers(manifest_path: Path) -> tuple[str, ...]:
293
+ """Extract the top-level `reviewers:` list from alissa-workspace.yaml.
294
+
295
+ Deliberately a minimal line-based scan, not a YAML parser — the
296
+ distribution stays stdlib-only. Handles the inline form
297
+ (`reviewers: []`, `reviewers: [a, b]`) and the block form
298
+ (`reviewers:` followed by `- a` lines, indented or at column 0 — both
299
+ are valid YAML). A missing manifest, or one without the key, yields no
300
+ reviewers.
301
+ """
302
+ try:
303
+ lines = Path(manifest_path).read_text().splitlines()
304
+ except OSError:
305
+ return ()
306
+
307
+ for index, line in enumerate(lines):
308
+ if line[:1] in (" ", "\t"):
309
+ continue # only the top-level key counts
310
+ stripped = line.split("#", 1)[0].rstrip()
311
+ if not stripped.startswith("reviewers:"):
312
+ continue
313
+
314
+ rest = stripped[len("reviewers:"):].strip()
315
+ if rest.startswith("[") and rest.endswith("]"):
316
+ inner = rest[1:-1]
317
+ return tuple(
318
+ cleaned for part in inner.split(",")
319
+ if (cleaned := _clean_item(part))
320
+ )
321
+ if rest:
322
+ # A scalar (`reviewers: alice`) — treat it as a one-item list.
323
+ cleaned = _clean_item(rest)
324
+ return (cleaned,) if cleaned else ()
325
+
326
+ items: list[str] = []
327
+ for follow in lines[index + 1:]:
328
+ body = follow.split("#", 1)[0]
329
+ if not body.strip():
330
+ continue # blank / comment-only lines do not end the block
331
+ if not body.lstrip().startswith("-"):
332
+ break # a new top-level `key:` (or any non-item) ends the block
333
+ items.append(_clean_item(body.strip()[1:]))
334
+ return tuple(item for item in items if item)
335
+
336
+ return ()
337
+
338
+
339
+ def resolve_reviewers(
340
+ merged: tuple[str, ...],
341
+ manifest_path: Path,
342
+ environ: Mapping[str, str] | None = None,
343
+ ) -> tuple[str, ...]:
344
+ """Final reviewers, applying the rails around the three config layers.
345
+
346
+ `merged` is the reviewers value after the usual defaults→file→CLI merge
347
+ (so `--reviewer` flags have already replaced the config key). Highest
348
+ precedence first: the ALISSA_DEV_REVIEWERS env var — authoritative
349
+ whenever it is set, even set-but-empty (which means "no reviewers") —
350
+ then the merged value, then the workspace manifest's `reviewers:` list.
351
+ """
352
+ env = (os.environ if environ is None else environ).get(ENV_REVIEWERS)
353
+ if env is not None:
354
+ return tuple(
355
+ cleaned for part in env.split(",") if (cleaned := _clean_item(part))
356
+ )
357
+ if merged:
358
+ return merged
359
+ return manifest_reviewers(manifest_path)