alissa-tools-github-revloop 0.12.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,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 = "revloop.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) / ".revloop" / "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 = 10 # 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", cls.round_cap))
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,263 @@
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
+ # "open" or "closed"; merged PRs report state "closed" AND merged True.
32
+ state: str = "open"
33
+ merged: bool = False
34
+
35
+ @property
36
+ def full_name(self) -> str:
37
+ return f"{self.owner}/{self.repo}"
38
+
39
+ @property
40
+ def slug(self) -> str:
41
+ return f"{self.owner}/{self.repo}#{self.number}"
42
+
43
+ @property
44
+ def is_terminal(self) -> bool:
45
+ """Closed or merged: no round can ever be owed on this PR again."""
46
+ return self.merged or self.state != "open"
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class IssueComment:
51
+ """An issue comment on a PR (a PR is an issue). Distinct from Review:
52
+ issue comments live on the issues endpoints and never create review
53
+ records, so posting one can never disturb round counting."""
54
+
55
+ id: int
56
+ author: str
57
+ body: str
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Review:
62
+ author: str
63
+ state: str
64
+ commit_id: str
65
+ submitted_at: str
66
+ url: str
67
+ body: str = ""
68
+
69
+ @property
70
+ def is_substantive(self) -> bool:
71
+ """A real review round, not a side effect of an inline comment.
72
+
73
+ Posting a standalone inline comment on a PR creates its own review
74
+ record with an empty body, so review records outnumber rounds. The
75
+ round-closing review always carries the verdict write-up in its body.
76
+ """
77
+ return bool(self.body.strip())
78
+
79
+
80
+ class RateLimited(RuntimeError):
81
+ pass
82
+
83
+
84
+ class IdentityMismatch(RuntimeError):
85
+ """Configured reviewer identity disagrees with the gh token."""
86
+
87
+
88
+ class GitHub:
89
+ def __init__(self, login: str | None = None):
90
+ self._login = login
91
+
92
+ def token_login(self) -> str:
93
+ """Who the gh token actually belongs to. `gh api --jq` prints scalars
94
+ raw (unquoted), so this is deliberately not parsed as JSON."""
95
+ return run(["gh", "api", "user", "--jq", ".login"]).strip()
96
+
97
+ @property
98
+ def login(self) -> str:
99
+ if self._login is None:
100
+ self._login = self.token_login()
101
+ return self._login
102
+
103
+ def verify_identity(self) -> str:
104
+ """`review-requested:@me` resolves server-side from the gh token, but
105
+ round counting filters reviews by `self.login`. If a configured
106
+ reviewer_login disagrees with the token, the daemon would search one
107
+ account's queue and count another's reviews — every round would look
108
+ like round 1 and respawn forever. Fail loudly instead."""
109
+ actual = self.token_login()
110
+ if self._login is not None and self._login != actual:
111
+ raise IdentityMismatch(
112
+ f"configured reviewer_login={self._login!r} but the gh token "
113
+ f"belongs to {actual!r}. `@me` follows the token, so round "
114
+ f"counting would break. Fix reviewer_login (or set it to null "
115
+ f"to auto-detect), or re-authenticate gh."
116
+ )
117
+ self._login = actual
118
+ return actual
119
+
120
+ def _api(self, *args: str, timeout: int = 60):
121
+ try:
122
+ return run_json(["gh", "api", *args], timeout=timeout)
123
+ except CommandError as exc:
124
+ blob = exc.stderr.lower()
125
+ if "rate limit" in blob or "403" in blob:
126
+ raise RateLimited(exc.stderr.strip()[:300]) from exc
127
+ raise
128
+
129
+ def review_requests(self, repos: tuple[str, ...] = ()) -> list[tuple[str, str, int]]:
130
+ """PRs with a review pending from me.
131
+
132
+ `draft:false` enforces CR1 -- draft PRs are never reviewed. GitHub
133
+ clears the request once a review is submitted and re-adds it when the
134
+ implementer re-requests, so this doubles as the CR9 round edge-trigger.
135
+ """
136
+ query = "is:open is:pr draft:false review-requested:@me"
137
+ for full_name in repos:
138
+ query += f" repo:{full_name}"
139
+
140
+ payload = self._api(
141
+ "-X",
142
+ "GET",
143
+ "search/issues",
144
+ "-f",
145
+ f"q={query}",
146
+ "-f",
147
+ "per_page=100",
148
+ )
149
+ items = (payload or {}).get("items", [])
150
+
151
+ out: list[tuple[str, str, int]] = []
152
+ for item in items:
153
+ # repository_url looks like https://api.github.com/repos/<owner>/<repo>
154
+ parts = item.get("repository_url", "").rstrip("/").split("/")
155
+ if len(parts) < 2:
156
+ log.warning("could not parse repo from %s", item.get("repository_url"))
157
+ continue
158
+ out.append((parts[-2], parts[-1], int(item["number"])))
159
+ return out
160
+
161
+ def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
162
+ data = self._api(f"repos/{owner}/{repo}/pulls/{number}")
163
+ return PullRequest(
164
+ owner=owner,
165
+ repo=repo,
166
+ number=number,
167
+ title=data.get("title", ""),
168
+ author=(data.get("user") or {}).get("login", ""),
169
+ head_sha=(data.get("head") or {}).get("sha", ""),
170
+ draft=bool(data.get("draft")),
171
+ url=data.get("html_url", ""),
172
+ state=data.get("state") or "open",
173
+ merged=bool(data.get("merged")),
174
+ )
175
+
176
+ def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
177
+ data = (
178
+ self._api(
179
+ "-X",
180
+ "GET",
181
+ f"repos/{owner}/{repo}/pulls/{number}/reviews",
182
+ "-f",
183
+ "per_page=100",
184
+ )
185
+ or []
186
+ )
187
+ return [
188
+ Review(
189
+ author=(r.get("user") or {}).get("login", ""),
190
+ state=r.get("state", ""),
191
+ commit_id=r.get("commit_id") or "",
192
+ submitted_at=r.get("submitted_at") or "",
193
+ url=r.get("html_url", ""),
194
+ body=r.get("body") or "",
195
+ )
196
+ for r in data
197
+ ]
198
+
199
+ def my_reviews(self, owner: str, repo: str, number: int) -> list[Review]:
200
+ """My substantive submitted reviews, oldest first -- one per round.
201
+
202
+ Empty-bodied records are dropped: a standalone inline comment creates
203
+ its own zero-body review record, so counting raw records overcounts
204
+ rounds badly. On fahera-mx/studio.alissa.app#210 three real rounds
205
+ produced six records (round 1 plus three inline-comment artifacts, then
206
+ rounds 2 and 3), and round 3's reviewer was told it was "round 6 of
207
+ cap 10".
208
+
209
+ Do NOT dedupe by `commit_id` instead -- it looks like the natural
210
+ grouping key but it UNDERCOUNTS. A round reviews whatever head is
211
+ current, and consecutive rounds routinely land on the same commit when
212
+ the implementer triages findings without pushing: on #210 rounds 2 and
213
+ 3 both carry head 805398a and would collapse into one. Body presence
214
+ tracks "a reviewer wrote a verdict"; commit identity does not.
215
+ """
216
+ mine = [
217
+ r
218
+ for r in self.reviews(owner, repo, number)
219
+ if r.author == self.login
220
+ and r.state in SUBMITTED_STATES
221
+ and r.is_substantive
222
+ ]
223
+ return sorted(mine, key=lambda r: r.submitted_at)
224
+
225
+ def comment(self, owner: str, repo: str, number: int, body: str) -> None:
226
+ run_json(
227
+ [
228
+ "gh",
229
+ "api",
230
+ f"repos/{owner}/{repo}/issues/{number}/comments",
231
+ "-f",
232
+ f"body={body}",
233
+ ]
234
+ )
235
+
236
+ def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
237
+ data = (
238
+ self._api(
239
+ "-X",
240
+ "GET",
241
+ f"repos/{owner}/{repo}/issues/{number}/comments",
242
+ "-f",
243
+ "per_page=100",
244
+ )
245
+ or []
246
+ )
247
+ return [
248
+ IssueComment(
249
+ id=int(c.get("id") or 0),
250
+ author=(c.get("user") or {}).get("login", ""),
251
+ body=c.get("body") or "",
252
+ )
253
+ for c in data
254
+ ]
255
+
256
+ def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
257
+ self._api(
258
+ "-X",
259
+ "PATCH",
260
+ f"repos/{owner}/{repo}/issues/comments/{comment_id}",
261
+ "-f",
262
+ f"body={body}",
263
+ )