alissa-tools-github-revloop 0.12.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.
- alissa_tools_github_revloop-0.12.0/MANIFEST.in +2 -0
- alissa_tools_github_revloop-0.12.0/PKG-INFO +61 -0
- alissa_tools_github_revloop-0.12.0/README.md +44 -0
- alissa_tools_github_revloop-0.12.0/requirements.txt +0 -0
- alissa_tools_github_revloop-0.12.0/setup.cfg +4 -0
- alissa_tools_github_revloop-0.12.0/setup.py +58 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/__init__.py +8 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/__main__.py +181 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/alissa.py +315 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/config.py +212 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/ghclient.py +263 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/loop.py +826 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/proc.py +56 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/prreview.py +228 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/state.py +218 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/version +1 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa/tools/github/revloop/version.py +46 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa_tools_github_revloop.egg-info/PKG-INFO +61 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +20 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +1 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +3 -0
- alissa_tools_github_revloop-0.12.0/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alissa-tools-github-revloop
|
|
3
|
+
Version: 0.12.0
|
|
4
|
+
Summary: ALISSA-TOOLS-GITHUB-REVLOOP
|
|
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-revloop
|
|
19
|
+
|
|
20
|
+
The `alissa.tools.github.revloop` 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
|
+
│ ├── revloop/ __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-revloop
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Console scripts
|
|
51
|
+
|
|
52
|
+
| Command | Entry point |
|
|
53
|
+
| --- | --- |
|
|
54
|
+
| `alissa-revloop` | `alissa.tools.github.revloop.__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/revloop/version`), read by both
|
|
61
|
+
`setup.py` and `version.py`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# alissa-tools-github-revloop
|
|
2
|
+
|
|
3
|
+
The `alissa.tools.github.revloop` 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
|
+
│ ├── revloop/ __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-revloop
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Console scripts
|
|
34
|
+
|
|
35
|
+
| Command | Entry point |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `alissa-revloop` | `alissa.tools.github.revloop.__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/revloop/version`), read by both
|
|
44
|
+
`setup.py` and `version.py`.
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
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 `revloop` 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}.revloop"
|
|
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-revloop",
|
|
30
|
+
version=version,
|
|
31
|
+
description="ALISSA-TOOLS-GITHUB-REVLOOP",
|
|
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-revloop=alissa.tools.github.revloop.__main__:main",
|
|
52
|
+
"alissa-pr-review=alissa.tools.github.revloop.prreview:main",
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
install_requires=requirements,
|
|
56
|
+
include_package_data=True,
|
|
57
|
+
python_requires=">=3.11",
|
|
58
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""revloop — 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-revloop (or python -m alissa.tools.github.revloop)"""
|
|
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-revloop",
|
|
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, ./revloop.config.json then "
|
|
62
|
+
"<workspace-root>/revloop.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,315 @@
|
|
|
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
|
+
# CR6 verdict envelope outcomes.
|
|
19
|
+
VERDICT_APPROVE = "approve"
|
|
20
|
+
VERDICT_REQUEST_CHANGES = "request_changes"
|
|
21
|
+
|
|
22
|
+
# Envelope titles and bodies both read:
|
|
23
|
+
# Review verdict: <org>/<repo>#<n> — request_changes (round 3, ...)
|
|
24
|
+
# # Review verdict: <org>/<repo>#<n> — approve
|
|
25
|
+
# The separator is an em-dash in practice; en-dash and hyphen are accepted too
|
|
26
|
+
# so a hand-written envelope does not silently fail to parse.
|
|
27
|
+
# Kept to a single line on purpose: the verdict word sits on the same line as
|
|
28
|
+
# the "Review verdict:" lead-in, so matching across newlines could only pick up
|
|
29
|
+
# a later round's wording out of order.
|
|
30
|
+
_VERDICT_RE = re.compile(
|
|
31
|
+
r"Review\s+verdict\s*:[^\n]*?[—–-]\s*(approve|request_changes)\b",
|
|
32
|
+
re.IGNORECASE,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Task:
|
|
38
|
+
ref: str # TASK-<taskNumber>
|
|
39
|
+
title: str
|
|
40
|
+
status: str
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_open(self) -> bool:
|
|
44
|
+
return self.status in OPEN_STATUSES
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# The namespace loop.session_name() spawns reviewers into; the reap sweep only
|
|
48
|
+
# ever considers sessions under it.
|
|
49
|
+
REVIEW_SESSION_PREFIX = "review-"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ManagedSession:
|
|
54
|
+
name: str
|
|
55
|
+
status: str # the worker's view: "idle", "busy", ...
|
|
56
|
+
# Epoch seconds of the session's last tmux activity. 0 when the CLI did
|
|
57
|
+
# not report one -- treated as "long quiet", so a missing field can never
|
|
58
|
+
# indefinitely immunize a session against the sweep.
|
|
59
|
+
last_activity: float = 0.0
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_idle(self) -> bool:
|
|
63
|
+
return self.status == "idle"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _title_pattern(owner: str, repo: str, number: int) -> re.Pattern[str]:
|
|
67
|
+
"""CR2 title convention: `Review PR <org>/<repo>#<n> (TASK-<origin>)`."""
|
|
68
|
+
return re.compile(
|
|
69
|
+
rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b",
|
|
70
|
+
re.IGNORECASE,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Alissa:
|
|
75
|
+
def list_tasks(self) -> list[Task]:
|
|
76
|
+
data = run_json(["alissa", "task", "list", "--json"], timeout=90) or []
|
|
77
|
+
tasks = []
|
|
78
|
+
for row in data:
|
|
79
|
+
# `taskNumber` is the ref the API resolves; `taskSeq` is a display
|
|
80
|
+
# ordinal and 404s as `TASK-<seq>`.
|
|
81
|
+
number = row.get("taskNumber")
|
|
82
|
+
if number is None:
|
|
83
|
+
continue
|
|
84
|
+
tasks.append(
|
|
85
|
+
Task(
|
|
86
|
+
ref=f"TASK-{number}",
|
|
87
|
+
title=row.get("title", ""),
|
|
88
|
+
status=row.get("status", ""),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
return tasks
|
|
92
|
+
|
|
93
|
+
def find_review_task(self, owner: str, repo: str, number: int) -> Task | None:
|
|
94
|
+
"""CR2: exactly one review task per PR. Reuse it across rounds (CR7)."""
|
|
95
|
+
pattern = _title_pattern(owner, repo, number)
|
|
96
|
+
matches = [t for t in self.list_tasks() if pattern.match(t.title) and t.is_open]
|
|
97
|
+
|
|
98
|
+
if not matches:
|
|
99
|
+
return None
|
|
100
|
+
if len(matches) > 1:
|
|
101
|
+
# Several verdicts on one task are fine; several tasks per PR are not.
|
|
102
|
+
log.warning(
|
|
103
|
+
"CR2 violation: %d open review tasks for %s/%s#%d (%s) -- using %s",
|
|
104
|
+
len(matches),
|
|
105
|
+
owner,
|
|
106
|
+
repo,
|
|
107
|
+
number,
|
|
108
|
+
", ".join(t.ref for t in matches),
|
|
109
|
+
matches[0].ref,
|
|
110
|
+
)
|
|
111
|
+
return matches[0]
|
|
112
|
+
|
|
113
|
+
def latest_verdict(self, task_ref: str) -> str | None:
|
|
114
|
+
"""The newest CR6 verdict envelope on a review task, or None.
|
|
115
|
+
|
|
116
|
+
Returns VERDICT_APPROVE / VERDICT_REQUEST_CHANGES. This is the verdict
|
|
117
|
+
of record: reviewers post comment-mode reviews, so the GitHub review
|
|
118
|
+
state is always COMMENTED and cannot express approval at all.
|
|
119
|
+
|
|
120
|
+
Never raises. The daemon polls forever and this runs inside every pass,
|
|
121
|
+
so absent, empty or malformed evidence degrades to "no verdict" rather
|
|
122
|
+
than taking the loop down.
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
data = run_json(["alissa", "task", "get", task_ref, "--json"], timeout=90)
|
|
126
|
+
except CommandError as exc:
|
|
127
|
+
log.warning("could not read verdict evidence for %s: %s", task_ref, exc)
|
|
128
|
+
return None
|
|
129
|
+
except Exception: # pragma: no cover - defence in depth
|
|
130
|
+
log.exception("unexpected failure reading verdict evidence for %s", task_ref)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
return self._newest_verdict(data)
|
|
135
|
+
except Exception: # pragma: no cover - defence in depth
|
|
136
|
+
log.exception("could not parse verdict evidence for %s", task_ref)
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
@staticmethod
|
|
140
|
+
def _newest_verdict(payload: object) -> str | None:
|
|
141
|
+
"""Pick the newest parseable verdict out of a task's evidence array.
|
|
142
|
+
|
|
143
|
+
Every layer is optional by design -- the payload shape is whatever the
|
|
144
|
+
CLI printed, and a task with no evidence is the normal round-1 case.
|
|
145
|
+
"""
|
|
146
|
+
if not isinstance(payload, dict):
|
|
147
|
+
return None
|
|
148
|
+
evidence = payload.get("evidence")
|
|
149
|
+
if not isinstance(evidence, list):
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
found: list[tuple[str, str]] = []
|
|
153
|
+
for item in evidence:
|
|
154
|
+
if not isinstance(item, dict):
|
|
155
|
+
continue
|
|
156
|
+
title = item.get("title")
|
|
157
|
+
content = item.get("markdownContent")
|
|
158
|
+
for blob in (title, content):
|
|
159
|
+
if not isinstance(blob, str):
|
|
160
|
+
continue
|
|
161
|
+
match = _VERDICT_RE.search(blob)
|
|
162
|
+
if match:
|
|
163
|
+
created = item.get("createdAt")
|
|
164
|
+
found.append(
|
|
165
|
+
(created if isinstance(created, str) else "", match.group(1).lower())
|
|
166
|
+
)
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
if not found:
|
|
170
|
+
return None
|
|
171
|
+
# ISO-8601 timestamps sort lexicographically. Undated evidence sorts
|
|
172
|
+
# first (empty string), so a dated envelope always wins over one that
|
|
173
|
+
# lost its timestamp.
|
|
174
|
+
return max(found, key=lambda pair: pair[0])[1]
|
|
175
|
+
|
|
176
|
+
def count_verdicts(self, task_ref: str) -> int:
|
|
177
|
+
"""How many CR6 verdict envelopes are on the review task.
|
|
178
|
+
|
|
179
|
+
One envelope per completed round (CR7, append-only), so this is the
|
|
180
|
+
authoritative round count -- unlike the GitHub review count it cannot be
|
|
181
|
+
thrown off by an empty-bodied review or two reviews in one cycle. Never
|
|
182
|
+
raises: absent, empty, or malformed evidence degrades to 0 (round 1)
|
|
183
|
+
rather than taking the loop down.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
data = run_json(["alissa", "task", "get", task_ref, "--json"], timeout=90)
|
|
187
|
+
except CommandError as exc:
|
|
188
|
+
log.warning("could not read verdict evidence for %s: %s", task_ref, exc)
|
|
189
|
+
return 0
|
|
190
|
+
except Exception: # pragma: no cover - defence in depth
|
|
191
|
+
log.exception("unexpected failure reading verdict evidence for %s", task_ref)
|
|
192
|
+
return 0
|
|
193
|
+
try:
|
|
194
|
+
return self._count_verdicts(data)
|
|
195
|
+
except Exception: # pragma: no cover - defence in depth
|
|
196
|
+
log.exception("could not count verdict evidence for %s", task_ref)
|
|
197
|
+
return 0
|
|
198
|
+
|
|
199
|
+
@staticmethod
|
|
200
|
+
def _count_verdicts(payload: object) -> int:
|
|
201
|
+
"""Count evidence items carrying a verdict envelope. Mirrors
|
|
202
|
+
`_newest_verdict`'s tolerant parsing -- one match per item at most."""
|
|
203
|
+
if not isinstance(payload, dict):
|
|
204
|
+
return 0
|
|
205
|
+
evidence = payload.get("evidence")
|
|
206
|
+
if not isinstance(evidence, list):
|
|
207
|
+
return 0
|
|
208
|
+
count = 0
|
|
209
|
+
for item in evidence:
|
|
210
|
+
if not isinstance(item, dict):
|
|
211
|
+
continue
|
|
212
|
+
for blob in (item.get("title"), item.get("markdownContent")):
|
|
213
|
+
if isinstance(blob, str) and _VERDICT_RE.search(blob):
|
|
214
|
+
count += 1
|
|
215
|
+
break
|
|
216
|
+
return count
|
|
217
|
+
|
|
218
|
+
def enqueue_reviewer(
|
|
219
|
+
self,
|
|
220
|
+
*,
|
|
221
|
+
session: str,
|
|
222
|
+
directive: str,
|
|
223
|
+
cwd: Path,
|
|
224
|
+
agent: str,
|
|
225
|
+
task_ref: str | None,
|
|
226
|
+
dry_run: bool = False,
|
|
227
|
+
) -> None:
|
|
228
|
+
argv = [
|
|
229
|
+
"alissa",
|
|
230
|
+
"tmux",
|
|
231
|
+
"queue",
|
|
232
|
+
"add",
|
|
233
|
+
session,
|
|
234
|
+
"--agent",
|
|
235
|
+
agent,
|
|
236
|
+
"--cwd",
|
|
237
|
+
str(cwd),
|
|
238
|
+
]
|
|
239
|
+
if task_ref:
|
|
240
|
+
argv += ["--task", task_ref]
|
|
241
|
+
argv.append(directive)
|
|
242
|
+
|
|
243
|
+
if dry_run:
|
|
244
|
+
log.info("[dry-run] would enqueue: %s", " ".join(argv[:-1]) + " <directive>")
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
run(argv, timeout=60)
|
|
248
|
+
|
|
249
|
+
# Reviewers are one-shot per round (CR3): once the session finishes and is
|
|
250
|
+
# reaped, it must never be respawned. Make that explicit so a self-kill or
|
|
251
|
+
# a daemon reap can't trigger a respawn loop. Best-effort — an older CLI
|
|
252
|
+
# without `queue set` should not fail the enqueue.
|
|
253
|
+
try:
|
|
254
|
+
run(["alissa", "tmux", "queue", "set", session, "respawn", "off"],
|
|
255
|
+
timeout=30, check=False)
|
|
256
|
+
except CommandError: # pragma: no cover - defence in depth
|
|
257
|
+
log.warning("could not set respawn off for %s", session)
|
|
258
|
+
|
|
259
|
+
def list_review_sessions(self) -> list[ManagedSession]:
|
|
260
|
+
"""The live `review-*` managed sessions, from `alissa tmux ls`.
|
|
261
|
+
|
|
262
|
+
The reap sweep's starting point. Unlike the review-requested search,
|
|
263
|
+
the live session list cannot lose a finished session, so every reap
|
|
264
|
+
candidate is reachable from here. `--live` because a session that is
|
|
265
|
+
already gone (self-killed, or killed by an operator) holds no worker
|
|
266
|
+
slot and needs no reap. Raises CommandError upward -- the sweep skips
|
|
267
|
+
this pass and tries again next poll.
|
|
268
|
+
"""
|
|
269
|
+
data = run_json(["alissa", "tmux", "ls", "--json", "--live"], timeout=60) or []
|
|
270
|
+
sessions = []
|
|
271
|
+
for row in data if isinstance(data, list) else []:
|
|
272
|
+
if not isinstance(row, dict):
|
|
273
|
+
continue
|
|
274
|
+
name = row.get("name")
|
|
275
|
+
if isinstance(name, str) and name.startswith(REVIEW_SESSION_PREFIX):
|
|
276
|
+
last = row.get("lastActivity")
|
|
277
|
+
sessions.append(
|
|
278
|
+
ManagedSession(
|
|
279
|
+
name=name,
|
|
280
|
+
status=str(row.get("status") or ""),
|
|
281
|
+
last_activity=float(last) if isinstance(last, (int, float)) else 0.0,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
return sessions
|
|
285
|
+
|
|
286
|
+
def kill_session(self, session: str) -> None:
|
|
287
|
+
"""Kill a finished reviewer's managed session to free its worker slot.
|
|
288
|
+
|
|
289
|
+
Best-effort and idempotent-friendly: the session may already be gone (the
|
|
290
|
+
reviewer self-killed), so a non-zero exit is not an error here. Dry-run
|
|
291
|
+
is the caller's job (the sweep decides and logs before calling).
|
|
292
|
+
"""
|
|
293
|
+
run(["alissa", "tmux", "kill", session], timeout=30, check=False)
|
|
294
|
+
|
|
295
|
+
def add_repo_to_workspace(
|
|
296
|
+
self, owner: str, repo: str, workspace_root: Path, *, dry_run: bool = False
|
|
297
|
+
) -> None:
|
|
298
|
+
"""Hub-ify a repo into the workspace (bare clone + main/ worktree) and
|
|
299
|
+
record it in alissa-workspace.yaml. Idempotent per the CLI's contract."""
|
|
300
|
+
argv = ["alissa", "code", "workspace", "add", f"{owner}/{repo}"]
|
|
301
|
+
if dry_run:
|
|
302
|
+
log.info("[dry-run] would run: %s (cwd=%s)", " ".join(argv), workspace_root)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
log.info("hub-ifying %s/%s into %s", owner, repo, workspace_root)
|
|
306
|
+
# Cloning a repo can be slow; the poll loop tolerates a long pass.
|
|
307
|
+
run(argv, timeout=600, cwd=workspace_root)
|
|
308
|
+
|
|
309
|
+
def worker_running(self) -> bool:
|
|
310
|
+
"""The queue only drains while `alissa worker` reconciles it."""
|
|
311
|
+
try:
|
|
312
|
+
out = run(["alissa", "worker", "status"], timeout=30, check=False)
|
|
313
|
+
except CommandError:
|
|
314
|
+
return False
|
|
315
|
+
return "not running" not in out.lower() and "no worker" not in out.lower()
|