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.
- alissa/tools/github/reviewloop/__init__.py +8 -0
- alissa/tools/github/reviewloop/__main__.py +181 -0
- alissa/tools/github/reviewloop/alissa.py +126 -0
- alissa/tools/github/reviewloop/config.py +212 -0
- alissa/tools/github/reviewloop/ghclient.py +184 -0
- alissa/tools/github/reviewloop/loop.py +330 -0
- alissa/tools/github/reviewloop/proc.py +56 -0
- alissa/tools/github/reviewloop/state.py +97 -0
- alissa/tools/github/reviewloop/version +1 -0
- alissa/tools/github/reviewloop/version.py +46 -0
- alissa_tools_github_reviewloop-0.1.0.dist-info/METADATA +61 -0
- alissa_tools_github_reviewloop-0.1.0.dist-info/RECORD +15 -0
- alissa_tools_github_reviewloop-0.1.0.dist-info/WHEEL +5 -0
- alissa_tools_github_reviewloop-0.1.0.dist-info/entry_points.txt +2 -0
- alissa_tools_github_reviewloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
alissa
|