alissa-tools-github-reviewloop 0.1.0__tar.gz

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.
Files changed (21) hide show
  1. alissa_tools_github_reviewloop-0.1.0/MANIFEST.in +2 -0
  2. alissa_tools_github_reviewloop-0.1.0/PKG-INFO +61 -0
  3. alissa_tools_github_reviewloop-0.1.0/README.md +44 -0
  4. alissa_tools_github_reviewloop-0.1.0/requirements.txt +0 -0
  5. alissa_tools_github_reviewloop-0.1.0/setup.cfg +4 -0
  6. alissa_tools_github_reviewloop-0.1.0/setup.py +57 -0
  7. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/__init__.py +8 -0
  8. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/__main__.py +181 -0
  9. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/alissa.py +126 -0
  10. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/config.py +212 -0
  11. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/ghclient.py +184 -0
  12. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/loop.py +330 -0
  13. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/proc.py +56 -0
  14. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/state.py +97 -0
  15. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/version +1 -0
  16. alissa_tools_github_reviewloop-0.1.0/src/main/alissa/tools/github/reviewloop/version.py +46 -0
  17. alissa_tools_github_reviewloop-0.1.0/src/main/alissa_tools_github_reviewloop.egg-info/PKG-INFO +61 -0
  18. alissa_tools_github_reviewloop-0.1.0/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +19 -0
  19. alissa_tools_github_reviewloop-0.1.0/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +1 -0
  20. alissa_tools_github_reviewloop-0.1.0/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +2 -0
  21. alissa_tools_github_reviewloop-0.1.0/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +1 -0
@@ -0,0 +1,2 @@
1
+ include requirements.txt
2
+ include src/main/alissa/tools/github/reviewloop/version
@@ -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,44 @@
1
+ # alissa-tools-github-reviewloop
2
+
3
+ The `alissa.tools.github.reviewloop` module: a GitHub watcher that drives the
4
+ `alissa-code-review` adversarial review loop (CR1–CR9) to convergence.
5
+
6
+ This distribution ships **only** that module. Everything above it —
7
+ `alissa`, `alissa.tools`, `alissa.tools.github` — is a
8
+ [PEP 420](https://peps.python.org/pep-0420/) namespace package with no
9
+ `__init__.py`, so other distributions in other repositories can contribute
10
+ their own packages under the same namespace:
11
+
12
+ ```
13
+ alissa/ ← namespace (no __init__.py)
14
+ └── tools/ ← namespace
15
+ ├── github/ ← namespace
16
+ │ ├── reviewloop/ __init__.py ← THIS distribution
17
+ │ └── issues/ __init__.py ← could ship from another repo
18
+ └── slack/ ← namespace
19
+ └── notify/ __init__.py ← could ship from another repo
20
+ ```
21
+
22
+ The rule: a distribution owns a leaf package and declares only that subtree
23
+ (`find_namespace_packages(include=[PACKAGE, f"{PACKAGE}.*"])`). Adding an
24
+ `__init__.py` at any namespace level would claim it for one distribution and
25
+ shadow the others.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ pip install -e ./alissa-tools-github-reviewloop
31
+ ```
32
+
33
+ ## Console scripts
34
+
35
+ | Command | Entry point |
36
+ | --- | --- |
37
+ | `alissa-reviewloop` | `alissa.tools.github.reviewloop.__main__:main` |
38
+
39
+ ## Layout
40
+
41
+ `src/main` holds the package tree, `src/test` mirrors it as `test_*`. The
42
+ distribution version lives in the plain-text `version` file next to the module
43
+ it versions (`src/main/alissa/tools/github/reviewloop/version`), read by both
44
+ `setup.py` and `version.py`.
File without changes
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,57 @@
1
+ import os
2
+ from setuptools import setup, find_namespace_packages
3
+
4
+
5
+ CODEBASE_PATH = os.environ.get(
6
+ "CODEBASE_PATH",
7
+ default=os.path.join("src", "main"),
8
+ )
9
+
10
+ # Everything above `reviewloop` is a PEP 420 namespace package (no __init__.py),
11
+ # so other distributions can ship their own alissa.tools.* / alissa.tools.github.*
12
+ # concrete packages. This distribution declares only its own subtree.
13
+ NAMESPACE = "alissa.tools.github"
14
+ PACKAGE = f"{NAMESPACE}.reviewloop"
15
+
16
+ with open("requirements.txt", "r") as file:
17
+ requirements = [line for line in file.read().splitlines() if line and not line.startswith("#")]
18
+
19
+ version_filepath = os.path.join(CODEBASE_PATH, *PACKAGE.split("."), "version")
20
+ with open(version_filepath, "r") as file:
21
+ version = file.read().strip()
22
+
23
+
24
+ with open("README.md") as file:
25
+ readme = file.read()
26
+
27
+
28
+ setup(
29
+ name="alissa-tools-github-reviewloop",
30
+ version=version,
31
+ description="ALISSA-TOOLS-GITHUB-REVIEWLOOP",
32
+ long_description=readme,
33
+ long_description_content_type='text/markdown',
34
+ url="https://alissa.app",
35
+ author="Fahera",
36
+ author_email="support@alissa.app",
37
+ packages=find_namespace_packages(
38
+ where=CODEBASE_PATH,
39
+ include=[PACKAGE, f"{PACKAGE}.*"],
40
+ ),
41
+ package_dir={
42
+ "": CODEBASE_PATH
43
+ },
44
+ package_data={
45
+ "": [
46
+ version_filepath,
47
+ ]
48
+ },
49
+ entry_points={
50
+ "console_scripts": [
51
+ "alissa-reviewloop=alissa.tools.github.reviewloop.__main__:main",
52
+ ]
53
+ },
54
+ install_requires=requirements,
55
+ include_package_data=True,
56
+ python_requires=">=3.11",
57
+ )
@@ -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()