alissa-tools-github-revloop 0.12.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- alissa/tools/github/revloop/__init__.py +8 -0
- alissa/tools/github/revloop/__main__.py +181 -0
- alissa/tools/github/revloop/alissa.py +315 -0
- alissa/tools/github/revloop/config.py +212 -0
- alissa/tools/github/revloop/ghclient.py +263 -0
- alissa/tools/github/revloop/loop.py +826 -0
- alissa/tools/github/revloop/proc.py +56 -0
- alissa/tools/github/revloop/prreview.py +228 -0
- alissa/tools/github/revloop/state.py +218 -0
- alissa/tools/github/revloop/version +1 -0
- alissa/tools/github/revloop/version.py +46 -0
- alissa_tools_github_revloop-0.12.0.dist-info/METADATA +61 -0
- alissa_tools_github_revloop-0.12.0.dist-info/RECORD +16 -0
- alissa_tools_github_revloop-0.12.0.dist-info/WHEEL +5 -0
- alissa_tools_github_revloop-0.12.0.dist-info/entry_points.txt +3 -0
- alissa_tools_github_revloop-0.12.0.dist-info/top_level.txt +1 -0
|
@@ -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,228 @@
|
|
|
1
|
+
"""alissa-pr-review: the implementer-side driver for ONE review round.
|
|
2
|
+
|
|
3
|
+
The reviewer daemon (`alissa-revloop`) is autonomous but only the *reviewer*
|
|
4
|
+
half: a review request in, a review out. This command is the *implementer* half,
|
|
5
|
+
run by the dev session that just finished the work — it fires the trigger and
|
|
6
|
+
blocks on the verdict, so the loop closes without a second always-on daemon.
|
|
7
|
+
|
|
8
|
+
One invocation = one round:
|
|
9
|
+
|
|
10
|
+
1. resolve the PR from the branch
|
|
11
|
+
2. flip it ready-for-review (from draft)
|
|
12
|
+
3. request the reviewer ← this is the daemon's edge-trigger
|
|
13
|
+
4. block until a NEW review round lands, or the timeout
|
|
14
|
+
|
|
15
|
+
It reads the verdict from the **review task envelope**, never from GitHub's
|
|
16
|
+
review state: reviewers work in comment mode, so the GitHub state is always
|
|
17
|
+
COMMENTED and cannot express approval. The round-completion signal is the
|
|
18
|
+
reviewer's substantive-review count going up; the verdict word then comes from
|
|
19
|
+
`Alissa.latest_verdict` — the exact logic the daemon uses, so the two halves
|
|
20
|
+
cannot disagree.
|
|
21
|
+
|
|
22
|
+
The triage -> fix -> re-enter loop and the round cap live in the caller (see the
|
|
23
|
+
`run-the-review-loop` how-to): on `request_changes` the dev triages, fixes, and
|
|
24
|
+
runs this again for round k+1.
|
|
25
|
+
|
|
26
|
+
Exit codes: 0 approve · 1 request_changes · 2 timeout/no verdict · 3 usage/setup
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import logging
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
import time
|
|
36
|
+
|
|
37
|
+
from .alissa import Alissa, VERDICT_APPROVE, VERDICT_REQUEST_CHANGES
|
|
38
|
+
from .ghclient import GitHub
|
|
39
|
+
from .proc import CommandError, run, run_json
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# After a new review lands, the verdict envelope is written on the task around
|
|
44
|
+
# the same moment; give it a short grace window to appear before giving up.
|
|
45
|
+
_ENVELOPE_GRACE_SECONDS = 120
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_pr(branch: str | None) -> tuple[str, str, int, str, bool]:
|
|
49
|
+
"""(owner, repo, number, url, is_draft) for `branch` (or the current branch).
|
|
50
|
+
|
|
51
|
+
Runs in the repo working tree, so `gh` infers the repo; the head branch
|
|
52
|
+
selects the PR. owner/repo come from the PR URL, which is the base repo.
|
|
53
|
+
"""
|
|
54
|
+
argv = ["gh", "pr", "view"]
|
|
55
|
+
if branch:
|
|
56
|
+
argv.append(branch)
|
|
57
|
+
argv += ["--json", "url,number,isDraft,state"]
|
|
58
|
+
data = run_json(argv)
|
|
59
|
+
if not data:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"no pull request found for {'branch ' + branch if branch else 'the current branch'}"
|
|
62
|
+
)
|
|
63
|
+
url = data.get("url", "")
|
|
64
|
+
match = re.search(r"github\.com/([\w.-]+)/([\w.-]+)/pull/(\d+)", url)
|
|
65
|
+
if not match:
|
|
66
|
+
raise ValueError(f"could not parse owner/repo from PR url {url!r}")
|
|
67
|
+
if data.get("state") not in (None, "OPEN"):
|
|
68
|
+
raise ValueError(f"PR {url} is {data.get('state')}, not open — nothing to review")
|
|
69
|
+
return match.group(1), match.group(2), int(match.group(3)), url, bool(data.get("isDraft"))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _review_task_ref(alissa: Alissa, owner: str, repo: str, number: int) -> str | None:
|
|
73
|
+
"""The CR2 review task for this PR, by title, regardless of status.
|
|
74
|
+
|
|
75
|
+
Deliberately not `Alissa.find_review_task` (which filters to open tasks): we
|
|
76
|
+
still want the ref just after the task is validated, to read its verdict.
|
|
77
|
+
"""
|
|
78
|
+
pattern = re.compile(
|
|
79
|
+
rf"^Review PR\s+{re.escape(owner)}/{re.escape(repo)}#{number}\b", re.IGNORECASE
|
|
80
|
+
)
|
|
81
|
+
matches = [t for t in alissa.list_tasks() if pattern.match(t.title)]
|
|
82
|
+
return matches[0].ref if matches else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _reviewer_review_count(gh: GitHub, owner: str, repo: str, number: int, reviewer: str) -> int:
|
|
86
|
+
"""Substantive reviews the reviewer has submitted — one per completed round.
|
|
87
|
+
|
|
88
|
+
Same 'substantive' rule the daemon counts by (empty-bodied inline-comment
|
|
89
|
+
artifacts don't close a round), so a fresh round is a clean +1 edge.
|
|
90
|
+
"""
|
|
91
|
+
return sum(
|
|
92
|
+
1
|
|
93
|
+
for r in gh.reviews(owner, repo, number)
|
|
94
|
+
if r.author == reviewer and r.is_substantive
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
99
|
+
p = argparse.ArgumentParser(
|
|
100
|
+
prog="alissa-pr-review",
|
|
101
|
+
description="Implementer-side: flip a PR ready, request a reviewer, and "
|
|
102
|
+
"block until the review verdict lands (one round of the adversarial loop).",
|
|
103
|
+
)
|
|
104
|
+
p.add_argument(
|
|
105
|
+
"--reviewer",
|
|
106
|
+
required=True,
|
|
107
|
+
metavar="LOGIN",
|
|
108
|
+
help="GitHub login to request review from (e.g. alissa-app)",
|
|
109
|
+
)
|
|
110
|
+
p.add_argument(
|
|
111
|
+
"--branch",
|
|
112
|
+
metavar="NAME",
|
|
113
|
+
help="head branch of the PR (default: the current branch)",
|
|
114
|
+
)
|
|
115
|
+
p.add_argument(
|
|
116
|
+
"--timeout",
|
|
117
|
+
type=int,
|
|
118
|
+
default=2700,
|
|
119
|
+
metavar="SECONDS",
|
|
120
|
+
help="give up waiting after this long (default: 2700 = 45 min)",
|
|
121
|
+
)
|
|
122
|
+
p.add_argument(
|
|
123
|
+
"--poll-interval",
|
|
124
|
+
type=int,
|
|
125
|
+
default=30,
|
|
126
|
+
metavar="SECONDS",
|
|
127
|
+
help="seconds between checks (default: 30)",
|
|
128
|
+
)
|
|
129
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
130
|
+
return p
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def main(argv: list[str] | None = None) -> int:
|
|
134
|
+
args = build_parser().parse_args(argv)
|
|
135
|
+
logging.basicConfig(
|
|
136
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
137
|
+
format="%(asctime)s %(levelname)-7s %(message)s",
|
|
138
|
+
datefmt="%H:%M:%S",
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
gh = GitHub()
|
|
142
|
+
alissa = Alissa()
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
# Identity guard: GitHub forbids requesting review from the PR author, so
|
|
146
|
+
# the dev's gh token must not be the reviewer.
|
|
147
|
+
dev = gh.token_login()
|
|
148
|
+
if dev == args.reviewer:
|
|
149
|
+
print(
|
|
150
|
+
f"identity error: the gh token belongs to {dev!r}, the same as "
|
|
151
|
+
f"--reviewer. GitHub forbids requesting review from the PR author; "
|
|
152
|
+
f"run this as a different account than the reviewer.",
|
|
153
|
+
file=sys.stderr,
|
|
154
|
+
)
|
|
155
|
+
return 3
|
|
156
|
+
|
|
157
|
+
owner, repo, number, url, is_draft = resolve_pr(args.branch)
|
|
158
|
+
slug = f"{owner}/{repo}#{number}"
|
|
159
|
+
log.info("PR %s (%s), reviewing as reviewer=%s, dev=%s", slug, url, args.reviewer, dev)
|
|
160
|
+
|
|
161
|
+
if is_draft:
|
|
162
|
+
log.info("flipping %s ready-for-review", slug)
|
|
163
|
+
run(["gh", "pr", "ready", str(number)])
|
|
164
|
+
|
|
165
|
+
before = _reviewer_review_count(gh, owner, repo, number, args.reviewer)
|
|
166
|
+
log.info("requesting review from %s (round %d)", args.reviewer, before + 1)
|
|
167
|
+
run(["gh", "pr", "edit", str(number), "--add-reviewer", args.reviewer])
|
|
168
|
+
|
|
169
|
+
deadline = time.time() + args.timeout
|
|
170
|
+
ref: str | None = None
|
|
171
|
+
round_landed_at: float | None = None
|
|
172
|
+
|
|
173
|
+
while time.time() < deadline:
|
|
174
|
+
time.sleep(args.poll_interval)
|
|
175
|
+
|
|
176
|
+
if round_landed_at is None:
|
|
177
|
+
now = _reviewer_review_count(gh, owner, repo, number, args.reviewer)
|
|
178
|
+
if now > before:
|
|
179
|
+
round_landed_at = time.time()
|
|
180
|
+
log.info("%s: a new review landed — reading the verdict", slug)
|
|
181
|
+
else:
|
|
182
|
+
log.debug("%s: still waiting (reviews=%d)", slug, now)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
ref = ref or _review_task_ref(alissa, owner, repo, number)
|
|
186
|
+
verdict = alissa.latest_verdict(ref) if ref else None
|
|
187
|
+
if verdict == VERDICT_APPROVE:
|
|
188
|
+
print(f"\n✔ APPROVE — {slug} converged.\n PR: {url}\n task: {ref}")
|
|
189
|
+
return 0
|
|
190
|
+
if verdict == VERDICT_REQUEST_CHANGES:
|
|
191
|
+
print(
|
|
192
|
+
f"\n✗ REQUEST_CHANGES — {slug}.\n PR: {url}\n task: {ref}\n"
|
|
193
|
+
f" Triage each finding on its PR thread, fix, then run this again "
|
|
194
|
+
f"for the next round."
|
|
195
|
+
)
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
# Review landed but the envelope isn't readable yet — brief grace.
|
|
199
|
+
if round_landed_at and time.time() - round_landed_at > _ENVELOPE_GRACE_SECONDS:
|
|
200
|
+
print(
|
|
201
|
+
f"\n⚠ a review landed on {slug} but no verdict envelope was found "
|
|
202
|
+
f"on the review task {ref or '(not found)'} within "
|
|
203
|
+
f"{_ENVELOPE_GRACE_SECONDS}s.\n PR: {url}\n Read the PR review "
|
|
204
|
+
f"and the review task directly.",
|
|
205
|
+
file=sys.stderr,
|
|
206
|
+
)
|
|
207
|
+
return 2
|
|
208
|
+
|
|
209
|
+
print(
|
|
210
|
+
f"\n⏱ timed out after {args.timeout}s waiting for a review on {slug} — no "
|
|
211
|
+
f"verdict yet.\n PR: {url}\n The reviewer daemon re-spawns a stalled "
|
|
212
|
+
f"reviewer at ~90 min; re-run this, or check the worker.",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return 2
|
|
216
|
+
|
|
217
|
+
except CommandError as exc:
|
|
218
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
219
|
+
return 3
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
222
|
+
return 3
|
|
223
|
+
except KeyboardInterrupt:
|
|
224
|
+
return 130
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,218 @@
|
|
|
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, to map a live
|
|
6
|
+
session name back to the round it was spawned for (so the reap sweep can tell
|
|
7
|
+
a finished round's session from an in-flight one), and to remember that a
|
|
8
|
+
cap-out was already escalated. The ledger tolerates sessions dying or being
|
|
9
|
+
killed behind its back: a reap record is bookkeeping, never a precondition.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import sqlite3
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
# Shared between SCHEMA and the migration so the two can never drift.
|
|
19
|
+
_SPAWNS_TABLE = """
|
|
20
|
+
CREATE TABLE IF NOT EXISTS spawns (
|
|
21
|
+
repo TEXT NOT NULL,
|
|
22
|
+
number INTEGER NOT NULL,
|
|
23
|
+
round INTEGER NOT NULL,
|
|
24
|
+
head_sha TEXT NOT NULL,
|
|
25
|
+
session TEXT NOT NULL PRIMARY KEY,
|
|
26
|
+
task_ref TEXT,
|
|
27
|
+
spawned_at INTEGER NOT NULL
|
|
28
|
+
)"""
|
|
29
|
+
|
|
30
|
+
SCHEMA = f"""
|
|
31
|
+
{_SPAWNS_TABLE};
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS spawns_by_round ON spawns (repo, number, round);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS escalations (
|
|
36
|
+
repo TEXT NOT NULL,
|
|
37
|
+
number INTEGER NOT NULL,
|
|
38
|
+
head_sha TEXT NOT NULL,
|
|
39
|
+
escalated_at INTEGER NOT NULL,
|
|
40
|
+
PRIMARY KEY (repo, number, head_sha)
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
CREATE TABLE IF NOT EXISTS reaps (
|
|
44
|
+
session TEXT NOT NULL PRIMARY KEY,
|
|
45
|
+
reaped_at INTEGER NOT NULL
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS pings (
|
|
49
|
+
repo TEXT NOT NULL,
|
|
50
|
+
number INTEGER NOT NULL,
|
|
51
|
+
kind TEXT NOT NULL,
|
|
52
|
+
pinged_at INTEGER NOT NULL,
|
|
53
|
+
PRIMARY KEY (repo, number, kind)
|
|
54
|
+
);
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class State:
|
|
59
|
+
def __init__(self, path: Path):
|
|
60
|
+
path = Path(path).expanduser()
|
|
61
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
self._db = sqlite3.connect(str(path))
|
|
63
|
+
self._db.row_factory = sqlite3.Row
|
|
64
|
+
if self._spawns_keyed_by_round():
|
|
65
|
+
self._migrate_spawns()
|
|
66
|
+
self._db.executescript(SCHEMA)
|
|
67
|
+
self._db.commit()
|
|
68
|
+
|
|
69
|
+
def _migrate_spawns(self) -> None:
|
|
70
|
+
"""Re-key an old round-keyed `spawns` by session, in ONE transaction.
|
|
71
|
+
|
|
72
|
+
Deliberately not executescript (it COMMITs the open transaction before
|
|
73
|
+
running): a crash between the rename and the copy would otherwise
|
|
74
|
+
leave an empty new `spawns` that no longer looks stale, stranding
|
|
75
|
+
every row in spawns_v0 — an empty ledger makes the sweep spare every
|
|
76
|
+
live session as "not ours". All-or-nothing instead: any failure rolls
|
|
77
|
+
back to the untouched old table and the next open retries.
|
|
78
|
+
"""
|
|
79
|
+
self._db.execute("BEGIN IMMEDIATE")
|
|
80
|
+
try:
|
|
81
|
+
self._db.execute("ALTER TABLE spawns RENAME TO spawns_v0")
|
|
82
|
+
self._db.execute(_SPAWNS_TABLE)
|
|
83
|
+
self._db.execute(
|
|
84
|
+
"INSERT OR REPLACE INTO spawns "
|
|
85
|
+
"(repo, number, round, head_sha, session, task_ref, spawned_at) "
|
|
86
|
+
"SELECT repo, number, round, head_sha, session, task_ref, spawned_at "
|
|
87
|
+
"FROM spawns_v0"
|
|
88
|
+
)
|
|
89
|
+
self._db.execute("DROP TABLE spawns_v0")
|
|
90
|
+
except BaseException:
|
|
91
|
+
self._db.execute("ROLLBACK")
|
|
92
|
+
raise
|
|
93
|
+
self._db.execute("COMMIT")
|
|
94
|
+
|
|
95
|
+
def _spawns_keyed_by_round(self) -> bool:
|
|
96
|
+
"""True when `spawns` still has the pre-0.8 (repo, number, round) key.
|
|
97
|
+
|
|
98
|
+
That key made `record_spawn` overwrite the row when a stalled round
|
|
99
|
+
was re-enqueued, orphaning the original -- possibly still-live --
|
|
100
|
+
session so the reap sweep spared it forever as "not ours". The key is
|
|
101
|
+
now the session name (unique per spawn, thanks to the nonce). SQLite
|
|
102
|
+
cannot alter a primary key in place, so an old table is renamed and
|
|
103
|
+
copied over exactly once on open.
|
|
104
|
+
"""
|
|
105
|
+
info = self._db.execute("PRAGMA table_info(spawns)").fetchall()
|
|
106
|
+
if not info:
|
|
107
|
+
return False # fresh database, nothing to migrate
|
|
108
|
+
return [r["name"] for r in info if r["pk"]] != ["session"]
|
|
109
|
+
|
|
110
|
+
def close(self) -> None:
|
|
111
|
+
self._db.close()
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> "State":
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def __exit__(self, *exc) -> None:
|
|
117
|
+
self.close()
|
|
118
|
+
|
|
119
|
+
def get_spawn(self, repo: str, number: int, round_: int) -> sqlite3.Row | None:
|
|
120
|
+
"""The NEWEST spawn recorded for this round, or None.
|
|
121
|
+
|
|
122
|
+
A stalled round can be re-enqueued, so one round may have several
|
|
123
|
+
spawns; aging and the in-flight check are about the latest attempt.
|
|
124
|
+
"""
|
|
125
|
+
return self._db.execute(
|
|
126
|
+
"SELECT * FROM spawns WHERE repo=? AND number=? AND round=? "
|
|
127
|
+
"ORDER BY spawned_at DESC, rowid DESC LIMIT 1",
|
|
128
|
+
(repo, number, round_),
|
|
129
|
+
).fetchone()
|
|
130
|
+
|
|
131
|
+
def find_spawn_by_session(self, session: str) -> sqlite3.Row | None:
|
|
132
|
+
"""The spawn a live session name belongs to, or None if it is not ours.
|
|
133
|
+
|
|
134
|
+
Session names carry a random nonce, so a name maps to at most one
|
|
135
|
+
spawn. The reap sweep starts from live tmux state and uses this to
|
|
136
|
+
recover (repo, number, round); a session with no row (another
|
|
137
|
+
workspace's daemon, or a hand-started one) is not ours to judge.
|
|
138
|
+
"""
|
|
139
|
+
return self._db.execute(
|
|
140
|
+
"SELECT * FROM spawns WHERE session=?", (session,)
|
|
141
|
+
).fetchone()
|
|
142
|
+
|
|
143
|
+
def spawn_age(self, repo: str, number: int, round_: int) -> float | None:
|
|
144
|
+
"""Seconds since round `round_` was enqueued, or None if never spawned."""
|
|
145
|
+
row = self.get_spawn(repo, number, round_)
|
|
146
|
+
return None if row is None else time.time() - row["spawned_at"]
|
|
147
|
+
|
|
148
|
+
def record_spawn(
|
|
149
|
+
self,
|
|
150
|
+
*,
|
|
151
|
+
repo: str,
|
|
152
|
+
number: int,
|
|
153
|
+
round_: int,
|
|
154
|
+
head_sha: str,
|
|
155
|
+
session: str,
|
|
156
|
+
task_ref: str | None,
|
|
157
|
+
) -> None:
|
|
158
|
+
self._db.execute(
|
|
159
|
+
"INSERT OR REPLACE INTO spawns "
|
|
160
|
+
"(repo, number, round, head_sha, session, task_ref, spawned_at) "
|
|
161
|
+
"VALUES (?,?,?,?,?,?,?)",
|
|
162
|
+
(repo, number, round_, head_sha, session, task_ref, int(time.time())),
|
|
163
|
+
)
|
|
164
|
+
self._db.commit()
|
|
165
|
+
|
|
166
|
+
def is_reaped(self, session: str) -> bool:
|
|
167
|
+
row = self._db.execute(
|
|
168
|
+
"SELECT 1 FROM reaps WHERE session=?", (session,)
|
|
169
|
+
).fetchone()
|
|
170
|
+
return row is not None
|
|
171
|
+
|
|
172
|
+
def record_reap(self, session: str) -> None:
|
|
173
|
+
self._db.execute(
|
|
174
|
+
"INSERT OR REPLACE INTO reaps (session, reaped_at) VALUES (?,?)",
|
|
175
|
+
(session, int(time.time())),
|
|
176
|
+
)
|
|
177
|
+
self._db.commit()
|
|
178
|
+
|
|
179
|
+
def pinged(self, repo: str, number: int, kind: str) -> bool:
|
|
180
|
+
"""Whether this KIND of operator ping already went out for the PR.
|
|
181
|
+
|
|
182
|
+
Kind is free-form TEXT (devloop's escalation-kind pattern): a caller
|
|
183
|
+
narrows a kind's dedupe scope by folding identity into the string --
|
|
184
|
+
e.g. one stalled ping per deferral episode, "stalled:<session>" (see
|
|
185
|
+
loop.stalled_kind). Kept apart from `escalations`, whose key is
|
|
186
|
+
(repo, number, head_sha) and whose rows page terminal states.
|
|
187
|
+
"""
|
|
188
|
+
row = self._db.execute(
|
|
189
|
+
"SELECT 1 FROM pings WHERE repo=? AND number=? AND kind=?",
|
|
190
|
+
(repo, number, kind),
|
|
191
|
+
).fetchone()
|
|
192
|
+
return row is not None
|
|
193
|
+
|
|
194
|
+
def record_ping(self, repo: str, number: int, kind: str) -> None:
|
|
195
|
+
"""Idempotent per kind: OR IGNORE keeps the FIRST ping's timestamp,
|
|
196
|
+
so `pinged_at` is an audit field for when the episode was first
|
|
197
|
+
raised, not the most recent re-raise."""
|
|
198
|
+
self._db.execute(
|
|
199
|
+
"INSERT OR IGNORE INTO pings (repo, number, kind, pinged_at) "
|
|
200
|
+
"VALUES (?,?,?,?)",
|
|
201
|
+
(repo, number, kind, int(time.time())),
|
|
202
|
+
)
|
|
203
|
+
self._db.commit()
|
|
204
|
+
|
|
205
|
+
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
206
|
+
row = self._db.execute(
|
|
207
|
+
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
208
|
+
(repo, number, head_sha),
|
|
209
|
+
).fetchone()
|
|
210
|
+
return row is not None
|
|
211
|
+
|
|
212
|
+
def record_escalation(self, repo: str, number: int, head_sha: str) -> None:
|
|
213
|
+
self._db.execute(
|
|
214
|
+
"INSERT OR REPLACE INTO escalations "
|
|
215
|
+
"(repo, number, head_sha, escalated_at) VALUES (?,?,?,?)",
|
|
216
|
+
(repo, number, head_sha, int(time.time())),
|
|
217
|
+
)
|
|
218
|
+
self._db.commit()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.12.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-revloop", dirpath=os.path.dirname(__file__))
|
|
44
|
+
except Exception:
|
|
45
|
+
print("Version file not found for package name: alissa-tools-github-revloop, using 0.0.0")
|
|
46
|
+
version = Version(name="alissa-tools-github-revloop", value="0.0.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,16 @@
|
|
|
1
|
+
alissa/tools/github/revloop/__init__.py,sha256=dIjcycNDlYaqaOvMkYIfv6QaLo5nLP_0RXqORpWvKhM,283
|
|
2
|
+
alissa/tools/github/revloop/__main__.py,sha256=mdSFZWBZnulCk8RjkQDa1XMji1_AfODoqWnPk53-wW8,6009
|
|
3
|
+
alissa/tools/github/revloop/alissa.py,sha256=DGuLlsU4epmu-C8g96sMFxZ8s6OVFsMoTxFmUCkdDtw,12255
|
|
4
|
+
alissa/tools/github/revloop/config.py,sha256=cEHmLsV9UCveZYHN_wHY24eWhU31rZQIbOS6VXAqBU4,7607
|
|
5
|
+
alissa/tools/github/revloop/ghclient.py,sha256=T45uEpkNUb6CJBNk9hJwyiMn5p7iBJaTm_r3-senzRc,8927
|
|
6
|
+
alissa/tools/github/revloop/loop.py,sha256=-82QgNChrNzhnlAks68gqjuumb_1iqPPFbXSUAyPqLs,37763
|
|
7
|
+
alissa/tools/github/revloop/proc.py,sha256=_dDr3g76WEf2DRyLUz1Dzl4KvLVptJant_UZOrRixNk,1649
|
|
8
|
+
alissa/tools/github/revloop/prreview.py,sha256=A0VLyQUIkPYXvZ8cS8vBS9S-2FYOhZxRdSuQwmefIYw,8800
|
|
9
|
+
alissa/tools/github/revloop/state.py,sha256=T113BhMAPBJsIEurJTZ1t9moZdfocl_Q2LQvvZoCQ_0,8356
|
|
10
|
+
alissa/tools/github/revloop/version,sha256=23I3BXwGze7GmNOWUDs5Kkuc7-uKYuNyerA4U3A-rNo,6
|
|
11
|
+
alissa/tools/github/revloop/version.py,sha256=cWVOK4zFuZ3jOswsR2SNghJrUHeC08ftxOECtE4DFtA,1418
|
|
12
|
+
alissa_tools_github_revloop-0.12.0.dist-info/METADATA,sha256=hLlUeSTblMHDuUYA6Kg0QC5X-i4LBPGKbO9b2E8dE_o,2110
|
|
13
|
+
alissa_tools_github_revloop-0.12.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
14
|
+
alissa_tools_github_revloop-0.12.0.dist-info/entry_points.txt,sha256=bdTgjQMnI1dGdEluuAewEDfQSIwzn66MXaWGeFrfXdE,138
|
|
15
|
+
alissa_tools_github_revloop-0.12.0.dist-info/top_level.txt,sha256=DodjDg-l-TWQnlxG-Vuc3G5sB4O7cWGQwe6XrVx3u-A,7
|
|
16
|
+
alissa_tools_github_revloop-0.12.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
alissa
|