alissa-tools-github-revloop 0.12.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ alissa-pr-review = alissa.tools.github.revloop.prreview:main
3
+ alissa-revloop = alissa.tools.github.revloop.__main__:main