alissa-tools-github-reviewloop 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,8 @@
1
+ """reviewloop — a GitHub watcher that drives the alissa-code-review
2
+ adversarial review loop (CR1–CR9) to convergence."""
3
+
4
+ from .config import Config
5
+ from .loop import Action, Decision, ReviewWatcher
6
+
7
+ __all__ = ["Config", "ReviewWatcher", "Decision", "Action"]
8
+ __version__ = "0.1.0"
@@ -0,0 +1,181 @@
1
+ """CLI entry point: alissa-reviewloop (or python -m alissa.tools.github.reviewloop)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import logging
7
+ import re
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .config import (
12
+ HUB_ADD,
13
+ HUB_SKIP,
14
+ ON_MISSING_CREATE,
15
+ ON_MISSING_SKIP,
16
+ ON_MISSING_SPAWN,
17
+ Config,
18
+ load_config_file,
19
+ resolve_config_path,
20
+ )
21
+ from .ghclient import IdentityMismatch
22
+ from .loop import ReviewWatcher
23
+ from .proc import CommandError
24
+
25
+ log = logging.getLogger(__name__)
26
+
27
+
28
+ def parse_pr_ref(ref: str) -> tuple[str, str, int]:
29
+ """Parse `owner/repo#123` (or a full PR URL) into its parts."""
30
+ match = re.search(r"([\w.-]+)/([\w.-]+?)(?:#|/pull/)(\d+)", ref)
31
+ if not match:
32
+ raise ValueError(f"expected OWNER/REPO#N or a PR URL, got {ref!r}")
33
+ return match.group(1), match.group(2), int(match.group(3))
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ p = argparse.ArgumentParser(
38
+ prog="alissa-reviewloop",
39
+ description="Watch GitHub for review requests and run the adversarial "
40
+ "review loop (alissa-code-review CR1-CR9).",
41
+ epilog="Every setting below can also live in the config file; CLI "
42
+ "arguments win. workspace_root is CLI-only, so one config can drive "
43
+ "several daemons over different workspaces.",
44
+ )
45
+
46
+ p.add_argument(
47
+ "--workspace-root",
48
+ type=Path,
49
+ default=None,
50
+ metavar="PATH",
51
+ help="the Alissa Code Workspace to watch (default: current directory)",
52
+ )
53
+ p.add_argument(
54
+ "-c",
55
+ "--config-path",
56
+ "--config",
57
+ dest="config_path",
58
+ type=Path,
59
+ default=None,
60
+ metavar="PATH",
61
+ help="config file; without it, ./reviewloop.config.json then "
62
+ "<workspace-root>/reviewloop.config.json, else defaults only",
63
+ )
64
+
65
+ mode = p.add_argument_group("mode")
66
+ mode.add_argument("--once", action="store_true", help="run a single poll pass and exit")
67
+ mode.add_argument(
68
+ "--pr",
69
+ metavar="OWNER/REPO#N",
70
+ help="evaluate one PR directly, bypassing the search — use this to tell "
71
+ "'the search did not find it' apart from 'the decision was no'",
72
+ )
73
+ mode.add_argument("-v", "--verbose", action="store_true")
74
+
75
+ over = p.add_argument_group("config overrides (win over the config file)")
76
+ over.add_argument(
77
+ "--repo",
78
+ dest="repos",
79
+ action="append",
80
+ metavar="OWNER/REPO",
81
+ help="only watch this repo; repeatable. Replaces the config list entirely.",
82
+ )
83
+ over.add_argument("--poll-interval", type=int, metavar="SECONDS")
84
+ over.add_argument("--round-cap", type=int, metavar="N", help="CR9 round cap")
85
+ over.add_argument("--hub-template", metavar="TEMPLATE")
86
+ over.add_argument("--agent-profile", metavar="NAME")
87
+ over.add_argument("--reviewer-login", metavar="LOGIN")
88
+ over.add_argument("--state-path", type=Path, metavar="PATH")
89
+ over.add_argument(
90
+ "--on-missing-review-task",
91
+ choices=[ON_MISSING_SPAWN, ON_MISSING_CREATE, ON_MISSING_SKIP],
92
+ )
93
+ over.add_argument("--on-missing-hub", choices=[HUB_SKIP, HUB_ADD])
94
+
95
+ dry = over.add_mutually_exclusive_group()
96
+ dry.add_argument(
97
+ "--dry-run",
98
+ dest="dry_run",
99
+ action="store_true",
100
+ default=None,
101
+ help="decide and log, but never enqueue a session or comment",
102
+ )
103
+ dry.add_argument(
104
+ "--no-dry-run",
105
+ dest="dry_run",
106
+ action="store_false",
107
+ help="act for real even if the config sets dry_run",
108
+ )
109
+ return p
110
+
111
+
112
+ def overrides_from(args: argparse.Namespace) -> dict:
113
+ """CLI values, with None meaning 'not specified' so the config file shows
114
+ through. `repos` becomes a tuple so it matches the config-file form."""
115
+ return {
116
+ "repos": tuple(args.repos) if args.repos else None,
117
+ "poll_interval": args.poll_interval,
118
+ "round_cap": args.round_cap,
119
+ "hub_template": args.hub_template,
120
+ "agent_profile": args.agent_profile,
121
+ "reviewer_login": args.reviewer_login,
122
+ "state_path": args.state_path,
123
+ "on_missing_review_task": args.on_missing_review_task,
124
+ "on_missing_hub": args.on_missing_hub,
125
+ "dry_run": args.dry_run,
126
+ }
127
+
128
+
129
+ def resolve_config(args: argparse.Namespace) -> Config:
130
+ workspace_root = args.workspace_root or Path.cwd()
131
+ path = resolve_config_path(args.config_path, workspace_root)
132
+
133
+ file_data = load_config_file(path) if path else {}
134
+ log.info("config: %s", path or "none found — defaults + CLI arguments only")
135
+
136
+ return Config.build(workspace_root, file_data, overrides_from(args))
137
+
138
+
139
+ def main(argv: list[str] | None = None) -> int:
140
+ args = build_parser().parse_args(argv)
141
+
142
+ logging.basicConfig(
143
+ level=logging.DEBUG if args.verbose else logging.INFO,
144
+ format="%(asctime)s %(levelname)-7s %(message)s",
145
+ datefmt="%H:%M:%S",
146
+ )
147
+
148
+ try:
149
+ config = resolve_config(args)
150
+ log.info("workspace: %s", config.workspace_root)
151
+
152
+ watcher = ReviewWatcher(config)
153
+ for warning in watcher.preflight():
154
+ log.warning(warning)
155
+
156
+ if args.pr:
157
+ owner, repo, number = parse_pr_ref(args.pr)
158
+ decision = watcher.evaluate(owner, repo, number)
159
+ print(f"\n{args.pr} → {decision.action.value}")
160
+ print(f" round: {decision.round}")
161
+ print(f" reason: {decision.reason or '—'}")
162
+ elif args.once:
163
+ watcher.poll_once()
164
+ else:
165
+ watcher.run_forever()
166
+ except IdentityMismatch as exc:
167
+ print(f"identity error: {exc}", file=sys.stderr)
168
+ return 2
169
+ except (FileNotFoundError, ValueError) as exc:
170
+ print(f"config error: {exc}", file=sys.stderr)
171
+ return 2
172
+ except CommandError as exc:
173
+ print(f"error: {exc}", file=sys.stderr)
174
+ return 1
175
+ except KeyboardInterrupt:
176
+ return 0
177
+ return 0
178
+
179
+
180
+ if __name__ == "__main__":
181
+ raise SystemExit(main())
@@ -0,0 +1,126 @@
1
+ """Alissa CLI access: locate the review task (CR2) and enqueue the fresh
2
+ reviewer session (orchestration P1)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import logging
7
+ import re
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ from .proc import CommandError, run, run_json
12
+
13
+ log = logging.getLogger(__name__)
14
+
15
+ # A review task is "open" while it can still receive a verdict.
16
+ OPEN_STATUSES = {"committed", "in_progress", "pending_validation", "todo"}
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Task:
21
+ ref: str # TASK-<seq>
22
+ title: str
23
+ status: str
24
+
25
+ @property
26
+ def is_open(self) -> bool:
27
+ return self.status in OPEN_STATUSES
28
+
29
+
30
+ def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
31
+ """CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`."""
32
+ return re.compile(
33
+ rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b",
34
+ re.IGNORECASE,
35
+ )
36
+
37
+
38
+ class Alissa:
39
+ def list_tasks(self) -> list[Task]:
40
+ data = run_json(["alissa", "task", "list", "--json"], timeout=90) or []
41
+ tasks = []
42
+ for row in data:
43
+ seq = row.get("taskSeq")
44
+ if seq is None:
45
+ continue
46
+ tasks.append(
47
+ Task(
48
+ ref=f"TASK-{seq}",
49
+ title=row.get("title", ""),
50
+ status=row.get("status", ""),
51
+ )
52
+ )
53
+ return tasks
54
+
55
+ def find_review_task(self, owner: str, repo: str, number: int) -> Task | None:
56
+ """CR2: exactly one review task per PR. Reuse it across rounds (CR7)."""
57
+ pattern = _title_pattern(owner, repo, number)
58
+ matches = [t for t in self.list_tasks() if pattern.match(t.title) and t.is_open]
59
+
60
+ if not matches:
61
+ return None
62
+ if len(matches) > 1:
63
+ # Several verdicts on one task are fine; several tasks per PR are not.
64
+ log.warning(
65
+ "CR2 violation: %d open review tasks for %s/%s#%d (%s) -- using %s",
66
+ len(matches),
67
+ owner,
68
+ repo,
69
+ number,
70
+ ", ".join(t.ref for t in matches),
71
+ matches[0].ref,
72
+ )
73
+ return matches[0]
74
+
75
+ def enqueue_reviewer(
76
+ self,
77
+ *,
78
+ session: str,
79
+ directive: str,
80
+ cwd: Path,
81
+ agent: str,
82
+ task_ref: str | None,
83
+ dry_run: bool = False,
84
+ ) -> None:
85
+ argv = [
86
+ "alissa",
87
+ "tmux",
88
+ "queue",
89
+ "add",
90
+ session,
91
+ "--agent",
92
+ agent,
93
+ "--cwd",
94
+ str(cwd),
95
+ ]
96
+ if task_ref:
97
+ argv += ["--task", task_ref]
98
+ argv.append(directive)
99
+
100
+ if dry_run:
101
+ log.info("[dry-run] would enqueue: %s", " ".join(argv[:-1]) + " <directive>")
102
+ return
103
+
104
+ run(argv, timeout=60)
105
+
106
+ def add_repo_to_workspace(
107
+ self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
108
+ ) -> None:
109
+ """Hub-ify a repo into the workspace (bare clone + main/ worktree) and
110
+ record it in alissa-workspace.yaml. Idempotent per the CLI's contract."""
111
+ argv = ["alissa", "code", "workspace", "add", f"{owner}/{repo}"]
112
+ if dry_run:
113
+ log.info("[dry-run] would run: %s (cwd=%s)", " ".join(argv), workspace_root)
114
+ return
115
+
116
+ log.info("hub-ifying %s/%s into %s", owner, repo, workspace_root)
117
+ # Cloning a repo can be slow; the poll loop tolerates a long pass.
118
+ run(argv, timeout=600, cwd=workspace_root)
119
+
120
+ def worker_running(self) -> bool:
121
+ """The queue only drains while `alissa worker` reconciles it."""
122
+ try:
123
+ out = run(["alissa", "worker", "status"], timeout=30, check=False)
124
+ except CommandError:
125
+ return False
126
+ return "not running" not in out.lower() and "no worker" not in out.lower()
@@ -0,0 +1,212 @@
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
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from typing import Any, Mapping
21
+
22
+ # What to do when a PR has a pending review request but no matching Alissa
23
+ # review task (CR2 is implementer-side, so a third-party PR may not have one).
24
+ ON_MISSING_SPAWN = "spawn_anyway" # review anyway, PR URL carries the context
25
+ ON_MISSING_SKIP = "skip" # ignore the PR until a review task appears
26
+ ON_MISSING_CREATE = "warn_and_spawn" # spawn, but log loudly
27
+
28
+ _MISSING_MODES = {ON_MISSING_SPAWN, ON_MISSING_SKIP, ON_MISSING_CREATE}
29
+
30
+ # What to do when a review arrives for a repo that has no worktree hub yet.
31
+ HUB_SKIP = "skip"
32
+ HUB_ADD = "add" # `alissa code workspace add <org>/<repo>`
33
+
34
+ _HUB_MODES = {HUB_SKIP, HUB_ADD}
35
+
36
+ CONFIG_FILENAME = "reviewloop.config.json"
37
+
38
+ # Keys accepted in the config file. workspace_root is excluded on purpose.
39
+ CONFIG_KEYS = (
40
+ "hub_template",
41
+ "poll_interval",
42
+ "round_cap",
43
+ "repos",
44
+ "agent_profile",
45
+ "reviewer_login",
46
+ "state_path",
47
+ "on_missing_review_task",
48
+ "on_missing_hub",
49
+ "dry_run",
50
+ )
51
+
52
+ MIN_POLL_INTERVAL = 10 # the search API allows 30 req/min
53
+
54
+
55
+ def default_state_path(workspace_root: Path) -> Path:
56
+ return Path(workspace_root) / ".reviewloop" / "state.db"
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class Config:
61
+ # A property of the process, supplied by --workspace-root (default: cwd).
62
+ workspace_root: Path
63
+
64
+ hub_template: str = "{root}/{repo}/main"
65
+ poll_interval: int = 60
66
+ round_cap: int = 3 # CR9 default
67
+
68
+ # Empty tuple means "every repo that requests a review from me".
69
+ repos: tuple[str, ...] = ()
70
+
71
+ agent_profile: str = "claude"
72
+ reviewer_login: str | None = None # None -> resolve once via `gh api user`
73
+
74
+ # None means "derive from the workspace" -- read `state_db` for the
75
+ # resolved location, never this field.
76
+ state_path: Path | None = None
77
+
78
+ on_missing_review_task: str = ON_MISSING_SPAWN
79
+ on_missing_hub: str = HUB_SKIP
80
+ dry_run: bool = False
81
+
82
+ def __post_init__(self) -> None:
83
+ object.__setattr__(
84
+ self, "workspace_root", Path(self.workspace_root).expanduser().resolve()
85
+ )
86
+
87
+ @property
88
+ def state_db(self) -> Path:
89
+ """Where the spawn ledger lives. Defaults inside the workspace so two
90
+ daemons watching different workspaces never share one."""
91
+ if self.state_path is None:
92
+ return default_state_path(self.workspace_root)
93
+ return Path(self.state_path).expanduser()
94
+
95
+ @property
96
+ def manifest_path(self) -> Path:
97
+ return self.workspace_root / "alissa-workspace.yaml"
98
+
99
+ def hub_for(self, owner: str, repo: str) -> Path:
100
+ return Path(
101
+ self.hub_template.format(
102
+ root=str(self.workspace_root), owner=owner, repo=repo
103
+ )
104
+ ).expanduser()
105
+
106
+ def watches(self, full_name: str) -> bool:
107
+ return not self.repos or full_name in self.repos
108
+
109
+ @classmethod
110
+ def build(
111
+ cls,
112
+ workspace_root: Path,
113
+ file_data: Mapping[str, Any] | None = None,
114
+ overrides: Mapping[str, Any] | None = None,
115
+ ) -> "Config":
116
+ """Merge the layers and validate. `overrides` entries that are None mean
117
+ "not specified on the CLI" and fall through to the file / defaults."""
118
+ raw: dict[str, Any] = dict(file_data or {})
119
+
120
+ if "workspace_root" in raw:
121
+ raise ValueError(
122
+ "workspace_root is not a config key — it is a property of the "
123
+ "running process. Pass --workspace-root (or run the daemon from "
124
+ "the workspace), and remove it from the config file."
125
+ )
126
+
127
+ # Allow "_"-prefixed keys as inline comments, since JSON has none.
128
+ unknown = {k for k in set(raw) - set(CONFIG_KEYS) if not k.startswith("_")}
129
+ if unknown:
130
+ raise ValueError(
131
+ f"unknown config key(s): {', '.join(sorted(unknown))}. "
132
+ f"Valid keys: {', '.join(CONFIG_KEYS)}"
133
+ )
134
+
135
+ for key, value in (overrides or {}).items():
136
+ if value is not None:
137
+ raw[key] = value
138
+
139
+ mode = raw.get("on_missing_review_task", ON_MISSING_SPAWN)
140
+ if mode not in _MISSING_MODES:
141
+ raise ValueError(
142
+ f"on_missing_review_task must be one of {sorted(_MISSING_MODES)}, got {mode!r}"
143
+ )
144
+
145
+ hub_mode = raw.get("on_missing_hub", HUB_SKIP)
146
+ if hub_mode not in _HUB_MODES:
147
+ raise ValueError(
148
+ f"on_missing_hub must be one of {sorted(_HUB_MODES)}, got {hub_mode!r}"
149
+ )
150
+
151
+ repos = tuple(raw.get("repos", ()))
152
+ if hub_mode == HUB_ADD and not repos:
153
+ # Anyone who can request a review could otherwise cause an arbitrary
154
+ # repo to be cloned onto this machine and opened as an agent's cwd.
155
+ raise ValueError(
156
+ "on_missing_hub='add' requires a non-empty repos allowlist "
157
+ "(config `repos`, or one or more --repo flags) — auto-cloning "
158
+ "whatever repo requests a review is unbounded"
159
+ )
160
+
161
+ cap = int(raw.get("round_cap", 3))
162
+ if cap < 1:
163
+ raise ValueError(f"round_cap must be >= 1, got {cap}")
164
+
165
+ interval = int(raw.get("poll_interval", 60))
166
+ if interval < MIN_POLL_INTERVAL:
167
+ raise ValueError(
168
+ f"poll_interval must be >= {MIN_POLL_INTERVAL} seconds, got {interval}"
169
+ )
170
+
171
+ state_path = raw.get("state_path")
172
+ return cls(
173
+ workspace_root=Path(workspace_root),
174
+ hub_template=raw.get("hub_template", cls.hub_template),
175
+ poll_interval=interval,
176
+ round_cap=cap,
177
+ repos=repos,
178
+ agent_profile=raw.get("agent_profile", "claude"),
179
+ reviewer_login=raw.get("reviewer_login"),
180
+ state_path=Path(state_path).expanduser() if state_path else None,
181
+ on_missing_review_task=mode,
182
+ on_missing_hub=hub_mode,
183
+ dry_run=bool(raw.get("dry_run", False)),
184
+ )
185
+
186
+
187
+ def load_config_file(path: Path) -> dict[str, Any]:
188
+ data = json.loads(Path(path).expanduser().read_text())
189
+ if not isinstance(data, dict):
190
+ raise ValueError(f"{path}: expected a JSON object, got {type(data).__name__}")
191
+ return data
192
+
193
+
194
+ def resolve_config_path(
195
+ explicit: Path | None, workspace_root: Path, cwd: Path | None = None
196
+ ) -> Path | None:
197
+ """Find the config file: explicit path, then cwd, then the workspace root.
198
+
199
+ Returns None when no config file exists — CLI arguments and defaults alone
200
+ are a valid way to run. An explicit path that does not exist is an error.
201
+ """
202
+ if explicit is not None:
203
+ path = Path(explicit).expanduser()
204
+ if not path.is_file():
205
+ raise FileNotFoundError(f"config file not found: {path}")
206
+ return path
207
+
208
+ cwd = Path.cwd() if cwd is None else Path(cwd)
209
+ for candidate in (cwd / CONFIG_FILENAME, Path(workspace_root) / CONFIG_FILENAME):
210
+ if candidate.is_file():
211
+ return candidate
212
+ return None
@@ -0,0 +1,184 @@
1
+ """GitHub access via `gh api`.
2
+
3
+ Note: this targets gh 2.4.0, which predates `gh search`. Every query goes
4
+ through `gh api` against the REST v3 endpoints instead.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from dataclasses import dataclass
11
+
12
+ from .proc import CommandError, run, run_json
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+ # States that count as "I have reviewed this". PENDING is a draft review that
17
+ # was never submitted, so it does not close a round.
18
+ SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class PullRequest:
23
+ owner: str
24
+ repo: str
25
+ number: int
26
+ title: str
27
+ author: str
28
+ head_sha: str
29
+ draft: bool
30
+ url: str
31
+
32
+ @property
33
+ def full_name(self) -> str:
34
+ return f"{self.owner}/{self.repo}"
35
+
36
+ @property
37
+ def slug(self) -> str:
38
+ return f"{self.owner}/{self.repo}#{self.number}"
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Review:
43
+ author: str
44
+ state: str
45
+ commit_id: str
46
+ submitted_at: str
47
+ url: str
48
+
49
+
50
+ class RateLimited(RuntimeError):
51
+ pass
52
+
53
+
54
+ class IdentityMismatch(RuntimeError):
55
+ """Configured reviewer identity disagrees with the gh token."""
56
+
57
+
58
+ class GitHub:
59
+ def __init__(self, login: str | None = None):
60
+ self._login = login
61
+
62
+ def token_login(self) -> str:
63
+ """Who the gh token actually belongs to. `gh api --jq` prints scalars
64
+ raw (unquoted), so this is deliberately not parsed as JSON."""
65
+ return run(["gh", "api", "user", "--jq", ".login"]).strip()
66
+
67
+ @property
68
+ def login(self) -> str:
69
+ if self._login is None:
70
+ self._login = self.token_login()
71
+ return self._login
72
+
73
+ def verify_identity(self) -> str:
74
+ """`review-requested:@me` resolves server-side from the gh token, but
75
+ round counting filters reviews by `self.login`. If a configured
76
+ reviewer_login disagrees with the token, the daemon would search one
77
+ account's queue and count another's reviews — every round would look
78
+ like round 1 and respawn forever. Fail loudly instead."""
79
+ actual = self.token_login()
80
+ if self._login is not None and self._login != actual:
81
+ raise IdentityMismatch(
82
+ f"configured reviewer_login={self._login!r} but the gh token "
83
+ f"belongs to {actual!r}. `@me` follows the token, so round "
84
+ f"counting would break. Fix reviewer_login (or set it to null "
85
+ f"to auto-detect), or re-authenticate gh."
86
+ )
87
+ self._login = actual
88
+ return actual
89
+
90
+ def _api(self, *args: str, timeout: int = 60):
91
+ try:
92
+ return run_json(["gh", "api", *args], timeout=timeout)
93
+ except CommandError as exc:
94
+ blob = exc.stderr.lower()
95
+ if "rate limit" in blob or "403" in blob:
96
+ raise RateLimited(exc.stderr.strip()[:300]) from exc
97
+ raise
98
+
99
+ def review_requests(self, repos: tuple[str, ...] = ()) -> list[tuple[str, str, int]]:
100
+ """PRs with a review pending from me.
101
+
102
+ `draft:false` enforces CR1 -- draft PRs are never reviewed. GitHub
103
+ clears the request once a review is submitted and re-adds it when the
104
+ implementer re-requests, so this doubles as the CR9 round edge-trigger.
105
+ """
106
+ query = "is:open is:pr draft:false review-requested:@me"
107
+ for full_name in repos:
108
+ query += f" repo:{full_name}"
109
+
110
+ payload = self._api(
111
+ "-X",
112
+ "GET",
113
+ "search/issues",
114
+ "-f",
115
+ f"q={query}",
116
+ "-f",
117
+ "per_page=100",
118
+ )
119
+ items = (payload or {}).get("items", [])
120
+
121
+ out: list[tuple[str, str, int]] = []
122
+ for item in items:
123
+ # repository_url looks like https://api.github.com/repos/<owner>/<repo>
124
+ parts = item.get("repository_url", "").rstrip("/").split("/")
125
+ if len(parts) < 2:
126
+ log.warning("could not parse repo from %s", item.get("repository_url"))
127
+ continue
128
+ out.append((parts[-2], parts[-1], int(item["number"])))
129
+ return out
130
+
131
+ def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
132
+ data = self._api(f"repos/{owner}/{repo}/pulls/{number}")
133
+ return PullRequest(
134
+ owner=owner,
135
+ repo=repo,
136
+ number=number,
137
+ title=data.get("title", ""),
138
+ author=(data.get("user") or {}).get("login", ""),
139
+ head_sha=(data.get("head") or {}).get("sha", ""),
140
+ draft=bool(data.get("draft")),
141
+ url=data.get("html_url", ""),
142
+ )
143
+
144
+ def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
145
+ data = (
146
+ self._api(
147
+ "-X",
148
+ "GET",
149
+ f"repos/{owner}/{repo}/pulls/{number}/reviews",
150
+ "-f",
151
+ "per_page=100",
152
+ )
153
+ or []
154
+ )
155
+ return [
156
+ Review(
157
+ author=(r.get("user") or {}).get("login", ""),
158
+ state=r.get("state", ""),
159
+ commit_id=r.get("commit_id") or "",
160
+ submitted_at=r.get("submitted_at") or "",
161
+ url=r.get("html_url", ""),
162
+ )
163
+ for r in data
164
+ ]
165
+
166
+ def my_reviews(self, owner: str, repo: str, number: int) -> list[Review]:
167
+ """My submitted reviews, oldest first -- one per completed round."""
168
+ mine = [
169
+ r
170
+ for r in self.reviews(owner, repo, number)
171
+ if r.author == self.login and r.state in SUBMITTED_STATES
172
+ ]
173
+ return sorted(mine, key=lambda r: r.submitted_at)
174
+
175
+ def comment(self, owner: str, repo: str, number: int, body: str) -> None:
176
+ run_json(
177
+ [
178
+ "gh",
179
+ "api",
180
+ f"repos/{owner}/{repo}/issues/{number}/comments",
181
+ "-f",
182
+ f"body={body}",
183
+ ]
184
+ )
@@ -0,0 +1,330 @@
1
+ """The watcher loop.
2
+
3
+ One pass = poll GitHub for pending review requests, decide per PR whether a
4
+ fresh reviewer round is owed, and enqueue it. Rounds are derived from GitHub
5
+ (one submitted review per round), not from local bookkeeping.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import re
12
+ import time
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from pathlib import Path
16
+
17
+ from .alissa import Alissa
18
+ from .config import HUB_ADD, ON_MISSING_SKIP, Config
19
+ from .ghclient import GitHub, PullRequest, RateLimited
20
+ from .proc import CommandError
21
+ from .state import State
22
+
23
+ log = logging.getLogger(__name__)
24
+
25
+ # A reviewer session that has not submitted after this long is presumed dead
26
+ # (skill failure mode: "reviewer session stalls"). The round is re-enqueued.
27
+ STALE_ROUND_SECONDS = 90 * 60
28
+
29
+ ROUND_1_DIRECTIVE = (
30
+ "You are a PR REVIEWER, not an implementer. {assignment} "
31
+ "Load the alissa-code-review skill and follow procedures/review-a-pr.md: "
32
+ "hydrate the task and the PR it names, review per the rubric, post "
33
+ "severity-tagged comments via gh pr review, record the verdict evidence, "
34
+ "move the task to pending_validation. "
35
+ "NEVER push commits, merge, or change PR state. "
36
+ "Do NOT create further ali-* sessions."
37
+ )
38
+
39
+ ROUND_K_DIRECTIVE = (
40
+ "You are a PR REVIEWER, not an implementer — round {round} of a review loop "
41
+ "(cap {cap}). {assignment} "
42
+ "Load the alissa-code-review skill and follow procedures/review-a-pr.md "
43
+ "including its round-k section: verify the triage of every prior finding, "
44
+ "verify the fixes, sweep the new diff with the full rubric, record a "
45
+ "round-{round} verdict envelope, move the task to pending_validation. "
46
+ "NEVER push commits, merge, or change PR state. "
47
+ "Do NOT create further ali-* sessions."
48
+ )
49
+
50
+ ESCALATION_COMMENT = (
51
+ "**Review loop cap-out (CR9)** — {rounds} rounds ran on this PR without "
52
+ "converging on `approve`. Per the alissa-code-review skill the loop does not "
53
+ "run past the cap and never silently merges; this needs an operator decision "
54
+ "(merge with a recorded waiver, direct specific fixes and re-enter with a "
55
+ "fresh cap, or park it).\n\n"
56
+ "Last verdict: `{last_state}` at `{sha}`."
57
+ )
58
+
59
+
60
+ class Action(str, Enum):
61
+ SPAWNED = "spawned"
62
+ IN_FLIGHT = "in-flight"
63
+ CONVERGED = "converged"
64
+ CAPPED = "capped"
65
+ ESCALATED = "escalated"
66
+ SKIPPED = "skipped"
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Decision:
71
+ action: Action
72
+ reason: str = ""
73
+ round: int | None = None
74
+
75
+
76
+ def session_name(pr: PullRequest, round_: int) -> str:
77
+ """tmux-safe, unique per (repo, pr, round)."""
78
+ repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
79
+ return f"review-{repo}-pr{pr.number}-r{round_}"
80
+
81
+
82
+ class ReviewWatcher:
83
+ def __init__(
84
+ self,
85
+ config: Config,
86
+ github: GitHub | None = None,
87
+ alissa: Alissa | None = None,
88
+ state: State | None = None,
89
+ ):
90
+ self.config = config
91
+ self.github = github or GitHub(config.reviewer_login)
92
+ self.alissa = alissa or Alissa()
93
+ self.state = state or State(config.state_db)
94
+
95
+ # -- per-PR decision ---------------------------------------------------
96
+
97
+ def evaluate(self, owner: str, repo: str, number: int) -> Decision:
98
+ pr = self.github.pull_request(owner, repo, number)
99
+
100
+ # CR1: draft PRs are never reviewed. The search already filters these;
101
+ # this catches a flip back to draft between search and fetch.
102
+ if pr.draft:
103
+ return Decision(Action.SKIPPED, "PR is a draft (CR1)")
104
+
105
+ if pr.author == self.github.login:
106
+ # GitHub rejects a self review-request, so this should be
107
+ # unreachable -- but a shared bot identity would land here.
108
+ return Decision(
109
+ Action.SKIPPED,
110
+ f"PR author is the reviewer identity ({pr.author}); "
111
+ "GitHub forbids self-review",
112
+ )
113
+
114
+ my_reviews = self.github.my_reviews(owner, repo, number)
115
+ completed = len(my_reviews)
116
+
117
+ if my_reviews and my_reviews[-1].state == "APPROVED":
118
+ return Decision(Action.CONVERGED, "last verdict is approve", completed)
119
+
120
+ # CR9: never queue round cap+1.
121
+ if completed >= self.config.round_cap:
122
+ if self.state.escalated(pr.full_name, number, pr.head_sha):
123
+ return Decision(Action.CAPPED, "already escalated", completed)
124
+ self._escalate(pr, my_reviews[-1].state if my_reviews else "none", completed)
125
+ return Decision(Action.ESCALATED, f"{completed} rounds, no approve", completed)
126
+
127
+ round_ = completed + 1
128
+
129
+ age = self.state.spawn_age(pr.full_name, number, round_)
130
+ if age is not None and age < STALE_ROUND_SECONDS:
131
+ return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
132
+ if age is not None:
133
+ log.warning(
134
+ "%s round %d has been in flight %.0f min with no submitted review "
135
+ "— re-enqueuing (reviewer session presumed stalled)",
136
+ pr.slug,
137
+ round_,
138
+ age / 60,
139
+ )
140
+
141
+ return self._spawn(pr, round_)
142
+
143
+ # -- actions -----------------------------------------------------------
144
+
145
+ def _spawn(self, pr: PullRequest, round_: int) -> Decision:
146
+ task = self.alissa.find_review_task(pr.owner, pr.repo, pr.number)
147
+
148
+ if task is None:
149
+ if self.config.on_missing_review_task == ON_MISSING_SKIP:
150
+ return Decision(
151
+ Action.SKIPPED, "no open Alissa review task (CR2)", round_
152
+ )
153
+ log.warning(
154
+ "%s has no open Alissa review task (CR2) — spawning against the PR "
155
+ "URL; the reviewer must create or locate one before recording a verdict",
156
+ pr.slug,
157
+ )
158
+ assignment = (
159
+ f"Review the GitHub PR {pr.url} . There is no Alissa review task for "
160
+ f"it yet — locate the origin task from the PR and create the downstream "
161
+ f"review task per CR2 before recording your verdict."
162
+ )
163
+ else:
164
+ assignment = f"You've been assigned Alissa review task {task.ref}."
165
+
166
+ template = ROUND_1_DIRECTIVE if round_ == 1 else ROUND_K_DIRECTIVE
167
+ directive = template.format(
168
+ assignment=assignment, round=round_, cap=self.config.round_cap
169
+ )
170
+
171
+ hub, problem = self._ensure_hub(pr)
172
+ if problem is not None:
173
+ return Decision(Action.SKIPPED, problem, round_)
174
+
175
+ name = session_name(pr, round_)
176
+ self.alissa.enqueue_reviewer(
177
+ session=name,
178
+ directive=directive,
179
+ cwd=hub,
180
+ agent=self.config.agent_profile,
181
+ task_ref=task.ref if task else None,
182
+ dry_run=self.config.dry_run,
183
+ )
184
+
185
+ if not self.config.dry_run:
186
+ self.state.record_spawn(
187
+ repo=pr.full_name,
188
+ number=pr.number,
189
+ round_=round_,
190
+ head_sha=pr.head_sha,
191
+ session=name,
192
+ task_ref=task.ref if task else None,
193
+ )
194
+
195
+ return Decision(
196
+ Action.SPAWNED,
197
+ f"session {name} → {task.ref if task else 'no task'}",
198
+ round_,
199
+ )
200
+
201
+ def _ensure_hub(self, pr: PullRequest) -> tuple[Path, str | None]:
202
+ """Resolve the reviewer's cwd, hub-ifying the repo first if configured.
203
+
204
+ Returns (hub, problem). `problem` is non-None when the round cannot run.
205
+ """
206
+ hub = self.config.hub_for(pr.owner, pr.repo)
207
+ if hub.is_dir():
208
+ return hub, None
209
+
210
+ if self.config.on_missing_hub != HUB_ADD:
211
+ return hub, (
212
+ f"no worktree hub at {hub} — add the repo with "
213
+ f"`alissa code workspace add {pr.full_name}`, or set "
214
+ f"on_missing_hub='add' (requires a repos allowlist)"
215
+ )
216
+
217
+ # Guarded twice: config.load() rejects 'add' without an allowlist, and
218
+ # poll_once() only reaches here for watched repos. Belt and braces --
219
+ # this path clones code onto the machine and opens it as an agent cwd.
220
+ if not self.config.watches(pr.full_name):
221
+ return hub, f"{pr.full_name} is not in the repos allowlist"
222
+
223
+ if not self.config.manifest_path.is_file():
224
+ return hub, (
225
+ f"{self.config.workspace_root} is not an Alissa Code Workspace "
226
+ f"(no alissa-workspace.yaml) — run `alissa code workspace init`"
227
+ )
228
+
229
+ try:
230
+ self.alissa.add_repo_to_workspace(
231
+ pr.owner,
232
+ pr.repo,
233
+ self.config.workspace_root,
234
+ dry_run=self.config.dry_run,
235
+ )
236
+ except CommandError as exc:
237
+ return hub, f"could not hub-ify {pr.full_name}: {exc}"
238
+
239
+ if self.config.dry_run:
240
+ return hub, None
241
+ if not hub.is_dir():
242
+ return hub, (
243
+ f"`alissa code workspace add {pr.full_name}` reported success but "
244
+ f"{hub} still does not exist — check hub_template against the "
245
+ f"manifest's `dir:` override"
246
+ )
247
+ return hub, None
248
+
249
+ def preflight(self) -> list[str]:
250
+ """Startup checks. Returns warnings; raises on anything fatal."""
251
+ warnings: list[str] = []
252
+
253
+ # Fatal: a mismatched identity silently breaks round counting.
254
+ login = self.github.verify_identity()
255
+ log.info("reviewing as GitHub user %s (from the gh token)", login)
256
+
257
+ if not self.config.workspace_root.is_dir():
258
+ warnings.append(f"workspace_root {self.config.workspace_root} does not exist")
259
+ elif not self.config.manifest_path.is_file():
260
+ warnings.append(
261
+ f"{self.config.workspace_root} has no alissa-workspace.yaml — it is "
262
+ f"not an Alissa Code Workspace yet (`alissa code workspace init`)"
263
+ )
264
+
265
+ if not self.config.dry_run and not self.alissa.worker_running():
266
+ warnings.append(
267
+ "`alissa worker` does not appear to be running — queued reviewer "
268
+ "sessions will not spawn until it is (`alissa worker start`)"
269
+ )
270
+
271
+ return warnings
272
+
273
+ def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
274
+ body = ESCALATION_COMMENT.format(
275
+ rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
276
+ )
277
+ log.error("CAP-OUT %s after %d rounds — escalating to operator", pr.slug, rounds)
278
+
279
+ if self.config.dry_run:
280
+ log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
281
+ return
282
+
283
+ try:
284
+ self.github.comment(pr.owner, pr.repo, pr.number, body)
285
+ except CommandError as exc:
286
+ log.error("could not post escalation comment on %s: %s", pr.slug, exc)
287
+ self.state.record_escalation(pr.full_name, pr.number, pr.head_sha)
288
+
289
+ # -- polling -----------------------------------------------------------
290
+
291
+ def poll_once(self) -> list[tuple[str, Decision]]:
292
+ requests = self.github.review_requests(self.config.repos)
293
+ log.info("%d PR(s) with a review pending from %s", len(requests), self.github.login)
294
+
295
+ results = []
296
+ for owner, repo, number in requests:
297
+ slug = f"{owner}/{repo}#{number}"
298
+ if not self.config.watches(f"{owner}/{repo}"):
299
+ continue
300
+ try:
301
+ decision = self.evaluate(owner, repo, number)
302
+ except RateLimited:
303
+ raise
304
+ except CommandError as exc:
305
+ log.error("%s: %s", slug, exc)
306
+ decision = Decision(Action.SKIPPED, str(exc))
307
+
308
+ level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
309
+ log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
310
+ results.append((slug, decision))
311
+ return results
312
+
313
+ def run_forever(self) -> None:
314
+ # preflight() is the caller's responsibility -- the CLI runs it once for
315
+ # every mode, so calling it here too would double every check.
316
+ backoff = self.config.poll_interval
317
+ while True:
318
+ try:
319
+ self.poll_once()
320
+ backoff = self.config.poll_interval
321
+ except RateLimited as exc:
322
+ backoff = min(backoff * 2, 900)
323
+ log.warning("rate limited (%s) — backing off %ds", exc, backoff)
324
+ except CommandError as exc:
325
+ backoff = min(backoff * 2, 900)
326
+ log.error("poll failed: %s — retrying in %ds", exc, backoff)
327
+ except KeyboardInterrupt:
328
+ log.info("stopping")
329
+ return
330
+ time.sleep(backoff)
@@ -0,0 +1,56 @@
1
+ """Subprocess helpers. Everything shells out to `gh` and `alissa` so both keep
2
+ their own auth handling; we never touch tokens ourselves."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import logging
8
+ import os
9
+ import subprocess
10
+ from typing import Sequence
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+
15
+ class CommandError(RuntimeError):
16
+ def __init__(self, argv: Sequence[str], returncode: int, stderr: str):
17
+ self.argv = list(argv)
18
+ self.returncode = returncode
19
+ self.stderr = stderr
20
+ super().__init__(f"{argv[0]} exited {returncode}: {stderr.strip()[:400]}")
21
+
22
+
23
+ def run(
24
+ argv: Sequence[str],
25
+ *,
26
+ timeout: int = 60,
27
+ check: bool = True,
28
+ cwd: "str | os.PathLike[str] | None" = None,
29
+ ) -> str:
30
+ """Run a command, return stdout. Never uses shell=True."""
31
+ log.debug("exec: %s", " ".join(argv))
32
+ try:
33
+ proc = subprocess.run(
34
+ list(argv),
35
+ capture_output=True,
36
+ text=True,
37
+ timeout=timeout,
38
+ cwd=str(cwd) if cwd is not None else None,
39
+ )
40
+ except subprocess.TimeoutExpired as exc:
41
+ raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
42
+
43
+ if check and proc.returncode != 0:
44
+ raise CommandError(argv, proc.returncode, proc.stderr)
45
+ return proc.stdout
46
+
47
+
48
+ def run_json(argv: Sequence[str], *, timeout: int = 60):
49
+ """Run a command whose stdout is JSON."""
50
+ out = run(argv, timeout=timeout).strip()
51
+ if not out:
52
+ return None
53
+ try:
54
+ return json.loads(out)
55
+ except json.JSONDecodeError as exc:
56
+ raise CommandError(argv, 0, f"expected JSON, got: {out[:300]}") from exc
@@ -0,0 +1,97 @@
1
+ """Local spawn ledger.
2
+
3
+ Deliberately thin: GitHub is the source of truth for how many rounds have run
4
+ (one submitted review per round). This table exists only to stop the daemon
5
+ double-spawning a reviewer while a round is still in flight, and to remember
6
+ that a cap-out was already escalated.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+ import time
13
+ from pathlib import Path
14
+
15
+ SCHEMA = """
16
+ CREATE TABLE IF NOT EXISTS spawns (
17
+ repo TEXT NOT NULL,
18
+ number INTEGER NOT NULL,
19
+ round INTEGER NOT NULL,
20
+ head_sha TEXT NOT NULL,
21
+ session TEXT NOT NULL,
22
+ task_ref TEXT,
23
+ spawned_at INTEGER NOT NULL,
24
+ PRIMARY KEY (repo, number, round)
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS escalations (
28
+ repo TEXT NOT NULL,
29
+ number INTEGER NOT NULL,
30
+ head_sha TEXT NOT NULL,
31
+ escalated_at INTEGER NOT NULL,
32
+ PRIMARY KEY (repo, number, head_sha)
33
+ );
34
+ """
35
+
36
+
37
+ class State:
38
+ def __init__(self, path: Path):
39
+ path = Path(path).expanduser()
40
+ path.parent.mkdir(parents=True, exist_ok=True)
41
+ self._db = sqlite3.connect(str(path))
42
+ self._db.row_factory = sqlite3.Row
43
+ self._db.executescript(SCHEMA)
44
+ self._db.commit()
45
+
46
+ def close(self) -> None:
47
+ self._db.close()
48
+
49
+ def __enter__(self) -> "State":
50
+ return self
51
+
52
+ def __exit__(self, *exc) -> None:
53
+ self.close()
54
+
55
+ def get_spawn(self, repo: str, number: int, round_: int) -> sqlite3.Row | None:
56
+ return self._db.execute(
57
+ "SELECT * FROM spawns WHERE repo=? AND number=? AND round=?",
58
+ (repo, number, round_),
59
+ ).fetchone()
60
+
61
+ def spawn_age(self, repo: str, number: int, round_: int) -> float | None:
62
+ """Seconds since round `round_` was enqueued, or None if never spawned."""
63
+ row = self.get_spawn(repo, number, round_)
64
+ return None if row is None else time.time() - row["spawned_at"]
65
+
66
+ def record_spawn(
67
+ self,
68
+ *,
69
+ repo: str,
70
+ number: int,
71
+ round_: int,
72
+ head_sha: str,
73
+ session: str,
74
+ task_ref: str | None,
75
+ ) -> None:
76
+ self._db.execute(
77
+ "INSERT OR REPLACE INTO spawns "
78
+ "(repo, number, round, head_sha, session, task_ref, spawned_at) "
79
+ "VALUES (?,?,?,?,?,?,?)",
80
+ (repo, number, round_, head_sha, session, task_ref, int(time.time())),
81
+ )
82
+ self._db.commit()
83
+
84
+ def escalated(self, repo: str, number: int, head_sha: str) -> bool:
85
+ row = self._db.execute(
86
+ "SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
87
+ (repo, number, head_sha),
88
+ ).fetchone()
89
+ return row is not None
90
+
91
+ def record_escalation(self, repo: str, number: int, head_sha: str) -> None:
92
+ self._db.execute(
93
+ "INSERT OR REPLACE INTO escalations "
94
+ "(repo, number, head_sha, escalated_at) VALUES (?,?,?,?)",
95
+ (repo, number, head_sha, int(time.time())),
96
+ )
97
+ self._db.commit()
@@ -0,0 +1 @@
1
+ 0.1.0
@@ -0,0 +1,46 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True, slots=True)
6
+ class Version:
7
+ name: str
8
+ value: str
9
+
10
+ def components(self, as_int: bool = False) -> list:
11
+ return [int(val) if as_int else val for val in self.value.split(".")]
12
+
13
+ @property
14
+ def major(self) -> int:
15
+ component, *_ = self.components(as_int=True)
16
+ return component
17
+
18
+ @property
19
+ def minor(self) -> int:
20
+ _, component, *_ = self.components(as_int=True)
21
+ return component
22
+
23
+ @property
24
+ def patch(self) -> int:
25
+ *_, component = self.components(as_int=True)
26
+ return component
27
+
28
+ @classmethod
29
+ def from_path(cls, dirpath: str, name: str):
30
+ for file in os.listdir(dirpath):
31
+ if file.lower().endswith("version"):
32
+ filepath = os.path.join(dirpath, file)
33
+ break
34
+ else:
35
+ raise ValueError("Version file not found for package name: " + name)
36
+
37
+ with open(filepath, "r") as version_file:
38
+ version_value = version_file.readline().strip()
39
+ return cls(name=name, value=version_value)
40
+
41
+
42
+ try:
43
+ version = Version.from_path(name="alissa-tools-github-reviewloop", dirpath=os.path.dirname(__file__))
44
+ except Exception:
45
+ print("Version file not found for package name: alissa-tools-github-reviewloop, using 0.0.0")
46
+ version = Version(name="alissa-tools-github-reviewloop", value="0.0.0")
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: alissa-tools-github-reviewloop
3
+ Version: 0.1.0
4
+ Summary: ALISSA-TOOLS-GITHUB-REVIEWLOOP
5
+ Home-page: https://alissa.app
6
+ Author: Fahera
7
+ Author-email: support@alissa.app
8
+ Requires-Python: >=3.11
9
+ Description-Content-Type: text/markdown
10
+ Dynamic: author
11
+ Dynamic: author-email
12
+ Dynamic: description
13
+ Dynamic: description-content-type
14
+ Dynamic: home-page
15
+ Dynamic: requires-python
16
+ Dynamic: summary
17
+
18
+ # alissa-tools-github-reviewloop
19
+
20
+ The `alissa.tools.github.reviewloop` module: a GitHub watcher that drives the
21
+ `alissa-code-review` adversarial review loop (CR1–CR9) to convergence.
22
+
23
+ This distribution ships **only** that module. Everything above it —
24
+ `alissa`, `alissa.tools`, `alissa.tools.github` — is a
25
+ [PEP 420](https://peps.python.org/pep-0420/) namespace package with no
26
+ `__init__.py`, so other distributions in other repositories can contribute
27
+ their own packages under the same namespace:
28
+
29
+ ```
30
+ alissa/ ← namespace (no __init__.py)
31
+ └── tools/ ← namespace
32
+ ├── github/ ← namespace
33
+ │ ├── reviewloop/ __init__.py ← THIS distribution
34
+ │ └── issues/ __init__.py ← could ship from another repo
35
+ └── slack/ ← namespace
36
+ └── notify/ __init__.py ← could ship from another repo
37
+ ```
38
+
39
+ The rule: a distribution owns a leaf package and declares only that subtree
40
+ (`find_namespace_packages(include=[PACKAGE, f"{PACKAGE}.*"])`). Adding an
41
+ `__init__.py` at any namespace level would claim it for one distribution and
42
+ shadow the others.
43
+
44
+ ## Install
45
+
46
+ ```sh
47
+ pip install -e ./alissa-tools-github-reviewloop
48
+ ```
49
+
50
+ ## Console scripts
51
+
52
+ | Command | Entry point |
53
+ | --- | --- |
54
+ | `alissa-reviewloop` | `alissa.tools.github.reviewloop.__main__:main` |
55
+
56
+ ## Layout
57
+
58
+ `src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
59
+ distribution version lives in the plain-text `version` file next to the module
60
+ it versions (`src/main/alissa/tools/github/reviewloop/version`), read by both
61
+ `setup.py` and `version.py`.
@@ -0,0 +1,15 @@
1
+ alissa/tools/github/reviewloop/__init__.py,sha256=JVMPodE5m3wNGMcdeTEuoZf4hF1og5-PTeb73WzVn3Q,286
2
+ alissa/tools/github/reviewloop/__main__.py,sha256=7yt99MnIUlLPhZrg9ldXRIJ4GUSdNdPrpKWYLC3bO3g,6024
3
+ alissa/tools/github/reviewloop/alissa.py,sha256=MnnFn9IlbRGEPEBxvqegRh0cYUO6jRmmyY07AOeesuY,3949
4
+ alissa/tools/github/reviewloop/config.py,sha256=JrP_wJhlDh--QCLtcHnPa8YZYY5sxGCE38mOjkxqi4s,7600
5
+ alissa/tools/github/reviewloop/ghclient.py,sha256=bKT2fEGVngVn0SwYI296_S7gCGkxA8wqpoSvn18HlIU,6007
6
+ alissa/tools/github/reviewloop/loop.py,sha256=MNluMOAREnpvuh_OuZC_S7HnLemADtxzFwRIDCjgUrQ,12968
7
+ alissa/tools/github/reviewloop/proc.py,sha256=_dDr3g76WEf2DRyLUz1Dzl4KvLVptJant_UZOrRixNk,1649
8
+ alissa/tools/github/reviewloop/state.py,sha256=wkrf7v6K9dnDgYzVpKTUYDdT7JwovFjqvOM-yV_d5IE,3033
9
+ alissa/tools/github/reviewloop/version,sha256=atlhOkVXmNbZLl9fOQq0uqcFlryGntaxf1zdKyhjXwY,5
10
+ alissa/tools/github/reviewloop/version.py,sha256=m6atW-IvTLm8R2UwtMiXtnXUihPk0EyrtMJwZr1MWCg,1427
11
+ alissa_tools_github_reviewloop-0.1.0.dist-info/METADATA,sha256=kQotlA9NhVcxHJ_2N8CeliFlM-jQbomW8FQn_99Z7xc,2136
12
+ alissa_tools_github_reviewloop-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ alissa_tools_github_reviewloop-0.1.0.dist-info/entry_points.txt,sha256=vynlUoMGQ299ZKbXbZFCdsUqFo7_Wcm5HMZfBHRn5Bk,83
14
+ alissa_tools_github_reviewloop-0.1.0.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
15
+ alissa_tools_github_reviewloop-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ alissa-reviewloop = alissa.tools.github.reviewloop.__main__:main