alissa-tools-github-revloop 0.20.0__tar.gz → 0.22.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.20.0/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.22.0}/PKG-INFO +1 -1
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/alissa.py +72 -6
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/config.py +39 -2
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/loop.py +37 -0
- alissa_tools_github_revloop-0.22.0/src/main/alissa/tools/github/revloop/version +1 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_revloop-0.20.0/src/main/alissa/tools/github/revloop/version +0 -1
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/LICENSE +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/MANIFEST.in +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/NOTICE +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/README.md +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/requirements.txt +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/setup.cfg +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/setup.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
|
@@ -80,6 +80,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
80
80
|
metavar="OWNER/REPO",
|
|
81
81
|
help="only watch this repo; repeatable. Replaces the config list entirely.",
|
|
82
82
|
)
|
|
83
|
+
over.add_argument(
|
|
84
|
+
"--author",
|
|
85
|
+
dest="authors",
|
|
86
|
+
action="append",
|
|
87
|
+
metavar="LOGIN",
|
|
88
|
+
help="only review PRs opened by this GitHub login; repeatable. "
|
|
89
|
+
"Replaces the config list entirely. Empty = every author is served "
|
|
90
|
+
"(a scope filter, not a grant); the self-review skip still applies.",
|
|
91
|
+
)
|
|
83
92
|
over.add_argument(
|
|
84
93
|
"--operator",
|
|
85
94
|
dest="operators",
|
|
@@ -194,6 +203,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
194
203
|
through. `repos` becomes a tuple so it matches the config-file form."""
|
|
195
204
|
return {
|
|
196
205
|
"repos": tuple(args.repos) if args.repos else None,
|
|
206
|
+
"authors": tuple(args.authors) if args.authors else None,
|
|
197
207
|
"operators": tuple(args.operators) if args.operators else None,
|
|
198
208
|
"poll_interval": args.poll_interval,
|
|
199
209
|
"round_cap": args.round_cap,
|
|
@@ -13,8 +13,36 @@ from .proc import CommandError, run, run_json
|
|
|
13
13
|
|
|
14
14
|
log = logging.getLogger(__name__)
|
|
15
15
|
|
|
16
|
-
#
|
|
17
|
-
|
|
16
|
+
# The seven canonical Alissa task statuses -- `TaskStatusSchema` in the studio
|
|
17
|
+
# repo (`packages/client/src/schemas/common.ts`). Mirrored here for the reason
|
|
18
|
+
# the studio CLI mirrors it into `TASK_STATUSES` rather than importing it: what
|
|
19
|
+
# this module needs is the SET of names, not the schema that validates them.
|
|
20
|
+
#
|
|
21
|
+
# It is the vocabulary the `--status` filter below is allowed to speak. CLI
|
|
22
|
+
# 0.2.0 validates `--status` against exactly these seven client-side and exits 1
|
|
23
|
+
# on anything else BEFORE issuing any request, so a single non-canonical value
|
|
24
|
+
# does not narrow the call slightly less -- it costs the whole narrowed call.
|
|
25
|
+
CANONICAL_TASK_STATUSES = frozenset(
|
|
26
|
+
{
|
|
27
|
+
"draft",
|
|
28
|
+
"committed",
|
|
29
|
+
"in_progress",
|
|
30
|
+
"blocked",
|
|
31
|
+
"pending_validation",
|
|
32
|
+
"validated",
|
|
33
|
+
"cancelled",
|
|
34
|
+
}
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# A review task is "open" while it can still receive a verdict. Every member is
|
|
38
|
+
# canonical, and a test pins that: a status Alissa cannot issue is dead weight
|
|
39
|
+
# in `is_open` and poison in the `--status` filter derived from it.
|
|
40
|
+
#
|
|
41
|
+
# `todo` was such a member until 0.22.0 (issue #97). Alissa has never had that
|
|
42
|
+
# status, so `is_open` could never match it -- but it was still reaching CLI
|
|
43
|
+
# 0.2.0's `--status` validator, which rejects the call over it, which in turn
|
|
44
|
+
# drops this process back to the unnarrowed whole-corpus list for good.
|
|
45
|
+
OPEN_STATUSES = {"committed", "in_progress", "pending_validation"}
|
|
18
46
|
|
|
19
47
|
# -- narrowing the `alissa task list` call (issue #87) ------------------------
|
|
20
48
|
#
|
|
@@ -26,13 +54,48 @@ OPEN_STATUSES = {"committed", "in_progress", "pending_validation", "todo"}
|
|
|
26
54
|
# not evidence, and this daemon turns a non-zero `alissa` exit into a SKIPPED
|
|
27
55
|
# decision, so sending a flag the CLI does not have costs a review.
|
|
28
56
|
|
|
29
|
-
# Statuses a LIVE review task can hold. Deliberately
|
|
30
|
-
# not a hand-written list: `is_review_task_for` already rejects every other
|
|
57
|
+
# Statuses a LIVE review task can hold. Deliberately derived from OPEN_STATUSES
|
|
58
|
+
# and not a hand-written list: `is_review_task_for` already rejects every other
|
|
31
59
|
# status client-side, so filtering server-side on exactly this set cannot change
|
|
32
60
|
# which task the daemon resolves -- it only stops shipping the rows over the
|
|
33
61
|
# wire. Any status added to `is_open` is added here by construction.
|
|
62
|
+
#
|
|
63
|
+
# The canonical guard below does not weaken that invariant, and is deliberately
|
|
64
|
+
# not an INTERSECTION with the canonical set, which would: an intersection makes
|
|
65
|
+
# the filter a strict subset of the open set the moment the two disagree, so the
|
|
66
|
+
# daemon would stop fetching rows `is_open` still accepts -- a skipped review,
|
|
67
|
+
# reported as an empty corpus. The whole derivation is kept or nothing is sent.
|
|
68
|
+
# Sending nothing degrades to the call the daemon has always made: wide, but
|
|
69
|
+
# complete, which is the direction every other failure on this path degrades in.
|
|
34
70
|
TASK_LIST_STATUS_FLAG = "--status"
|
|
35
|
-
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _status_filter(statuses: "set[str] | frozenset[str]") -> str:
|
|
74
|
+
"""The `--status` argument for `statuses`, or `""` when they cannot be one.
|
|
75
|
+
|
|
76
|
+
`""` means "do not narrow by status". It is returned for a set carrying any
|
|
77
|
+
non-canonical value, because such a value is not a filter the CLI serves
|
|
78
|
+
less precisely -- it is one the CLI refuses outright, taking the `--view`
|
|
79
|
+
and `--self` narrowing down with it and pinning this process to the
|
|
80
|
+
whole-corpus list (`list_tasks`). One wide call beats every call being wide.
|
|
81
|
+
|
|
82
|
+
Loud rather than silent: the condition is a code defect (a status added to
|
|
83
|
+
`OPEN_STATUSES` that Alissa cannot issue), and the tests pin against it, so
|
|
84
|
+
this is the last line rather than the first.
|
|
85
|
+
"""
|
|
86
|
+
unknown = sorted(set(statuses) - CANONICAL_TASK_STATUSES)
|
|
87
|
+
if unknown:
|
|
88
|
+
log.warning(
|
|
89
|
+
"not narrowing `alissa task list` by status: %s not canonical "
|
|
90
|
+
"Alissa status(es) -- the CLI rejects the whole call over one "
|
|
91
|
+
"unknown value, so this daemon lists the corpus unnarrowed",
|
|
92
|
+
", ".join(unknown),
|
|
93
|
+
)
|
|
94
|
+
return ""
|
|
95
|
+
return ",".join(sorted(statuses))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
TASK_LIST_STATUS_FILTER = _status_filter(OPEN_STATUSES)
|
|
36
99
|
|
|
37
100
|
# `--self` drops the SPONSOR's corpus and keeps only the calling actor's rows.
|
|
38
101
|
#
|
|
@@ -375,7 +438,10 @@ class Alissa:
|
|
|
375
438
|
if self._task_list_narrowing_disabled:
|
|
376
439
|
return argv
|
|
377
440
|
flags = self.probe_task_list()
|
|
378
|
-
|
|
441
|
+
# An empty filter is not a filter: `_status_filter` answers `""` when
|
|
442
|
+
# the open set carries a status the CLI would reject, and sending
|
|
443
|
+
# `--status ""` would be that same rejected call with an extra step.
|
|
444
|
+
if flags.status and narrow_status and TASK_LIST_STATUS_FILTER:
|
|
379
445
|
argv += [TASK_LIST_STATUS_FLAG, TASK_LIST_STATUS_FILTER]
|
|
380
446
|
if flags.self_scope and self._task_list_self_scope:
|
|
381
447
|
argv.append(TASK_LIST_SELF_FLAG)
|
|
@@ -120,6 +120,7 @@ CONFIG_KEYS = (
|
|
|
120
120
|
"poll_interval",
|
|
121
121
|
"round_cap",
|
|
122
122
|
"repos",
|
|
123
|
+
"authors",
|
|
123
124
|
"operators",
|
|
124
125
|
"agent_profile",
|
|
125
126
|
"reviewer_login",
|
|
@@ -262,6 +263,21 @@ class Config:
|
|
|
262
263
|
# Empty tuple means "every repo that requests a review from me".
|
|
263
264
|
repos: tuple[str, ...] = ()
|
|
264
265
|
|
|
266
|
+
# GitHub logins whose PRs this loop will spend rounds on. A SCOPE FILTER,
|
|
267
|
+
# not a capability grant -- so, like `repos` and unlike `operators`, empty
|
|
268
|
+
# (the default) means EVERY author, and an existing deployment with no
|
|
269
|
+
# `authors` key behaves exactly as it did before the key existed.
|
|
270
|
+
#
|
|
271
|
+
# The distinction is worth keeping straight: `operators` empty means NOBODY
|
|
272
|
+
# because honouring an ack is a power being handed out, and a grant that
|
|
273
|
+
# defaults to everyone is a hole. Serving a review request is not a power
|
|
274
|
+
# the daemon hands to the author -- summoning the loop already costs repo
|
|
275
|
+
# write access (to request the reviewer) plus a place on the `repos`
|
|
276
|
+
# allowlist. This key only narrows which of those already-authorised PRs are
|
|
277
|
+
# worth the rounds: skip dependabot/renovate, or a teammate who does not
|
|
278
|
+
# want agent reviews.
|
|
279
|
+
authors: tuple[str, ...] = ()
|
|
280
|
+
|
|
265
281
|
# GitHub logins whose re-entry ack may raise a capped PR's effective cap
|
|
266
282
|
# (loop.parse_reentry_ack). Empty -- the default -- means NO ack is ever
|
|
267
283
|
# honoured: the lever fails closed, because anyone who can comment on a PR
|
|
@@ -356,6 +372,23 @@ class Config:
|
|
|
356
372
|
def watches(self, full_name: str) -> bool:
|
|
357
373
|
return not self.repos or full_name in self.repos
|
|
358
374
|
|
|
375
|
+
def serves_author(self, login: str) -> bool:
|
|
376
|
+
"""Whether a PR by this login is in scope for the loop.
|
|
377
|
+
|
|
378
|
+
Empty list = every author (the filter pattern, see the field). Otherwise
|
|
379
|
+
membership case-insensitively, because GitHub logins are: a config
|
|
380
|
+
saying `Alissa-App` must match the `alissa-app` the API returns, or the
|
|
381
|
+
allowlist silently serves nobody -- the same failure mode `_string_list`
|
|
382
|
+
exists to prevent. Mirrors `loop.ReviewWatcher._is_operator`.
|
|
383
|
+
|
|
384
|
+
This is not the self-review guard and cannot stand in for it: an author
|
|
385
|
+
listed here is still refused if it is the reviewer identity, because
|
|
386
|
+
that check runs first and independently (see `loop.evaluate`).
|
|
387
|
+
"""
|
|
388
|
+
if not self.authors:
|
|
389
|
+
return True
|
|
390
|
+
return login.lower() in {a.lower() for a in self.authors}
|
|
391
|
+
|
|
359
392
|
@classmethod
|
|
360
393
|
def build(
|
|
361
394
|
cls,
|
|
@@ -399,6 +432,9 @@ class Config:
|
|
|
399
432
|
)
|
|
400
433
|
|
|
401
434
|
repos = _string_list(raw.get("repos", ()), "repos", "owner/repo entries")
|
|
435
|
+
authors = _string_list(
|
|
436
|
+
raw.get("authors", ()), "authors", "GitHub logins"
|
|
437
|
+
)
|
|
402
438
|
operators = _string_list(
|
|
403
439
|
raw.get("operators", ()), "operators", "GitHub logins"
|
|
404
440
|
)
|
|
@@ -529,6 +565,7 @@ class Config:
|
|
|
529
565
|
poll_interval=interval,
|
|
530
566
|
round_cap=cap,
|
|
531
567
|
repos=repos,
|
|
568
|
+
authors=authors,
|
|
532
569
|
operators=operators,
|
|
533
570
|
agent_profile=raw.get("agent_profile", "claude"),
|
|
534
571
|
reviewer_login=raw.get("reviewer_login"),
|
|
@@ -553,8 +590,8 @@ def _string_list(value: Any, key: str, what: str) -> tuple[str, ...]:
|
|
|
553
590
|
The guard is the point: JSON makes `"repos": "org/repo"` an easy typo, and
|
|
554
591
|
Python would iterate it into single CHARACTERS -- an allowlist of 30-odd
|
|
555
592
|
one-character names, which `watches()` then matches against nothing and the
|
|
556
|
-
daemon quietly reviews no PR at all.
|
|
557
|
-
|
|
593
|
+
daemon quietly reviews no PR at all. Every list key goes through here so
|
|
594
|
+
none of them can grow the footgun back.
|
|
558
595
|
"""
|
|
559
596
|
if isinstance(value, str):
|
|
560
597
|
raise ValueError(
|
|
@@ -1695,6 +1695,13 @@ class ReviewWatcher:
|
|
|
1695
1695
|
if pr.author == self.github.login:
|
|
1696
1696
|
# GitHub rejects a self review-request, so this should be
|
|
1697
1697
|
# unreachable -- but a shared bot identity would land here.
|
|
1698
|
+
#
|
|
1699
|
+
# FIRST, and deliberately above the authors allowlist (further
|
|
1700
|
+
# down): the allowlist is a scope filter an operator writes, and no
|
|
1701
|
+
# entry in it may buy back a review GitHub itself forbids. Listing
|
|
1702
|
+
# the reviewer login in `authors` therefore narrows the loop to a PR
|
|
1703
|
+
# it will then refuse -- which is the correct reading of "only serve
|
|
1704
|
+
# this author" for an author that cannot be served.
|
|
1698
1705
|
return Decision(
|
|
1699
1706
|
Action.SKIPPED,
|
|
1700
1707
|
f"PR author is the reviewer identity ({pr.author}); "
|
|
@@ -1755,6 +1762,36 @@ class ReviewWatcher:
|
|
|
1755
1762
|
self._clear_own_review_request(pr, my_reviews, converged)
|
|
1756
1763
|
return Decision(Action.CONVERGED, converged, completed)
|
|
1757
1764
|
|
|
1765
|
+
# THE AUTHORS SCOPE FILTER (issue #93), and its position is the whole
|
|
1766
|
+
# of its contract: it gates STARTING a round and nothing else.
|
|
1767
|
+
#
|
|
1768
|
+
# Client-side, because the discovery search is deliberately untouched --
|
|
1769
|
+
# the author is only in hand once the PR detail is fetched. Below
|
|
1770
|
+
# convergence rather than up beside the self-review skip, because every
|
|
1771
|
+
# branch above finishes a round that was already started: a verdict
|
|
1772
|
+
# envelope ahead of its native review still becomes a review of record
|
|
1773
|
+
# (_close_round_natively), and a converged PR still gets its dangling
|
|
1774
|
+
# review request withdrawn (_clear_own_review_request). An operator who
|
|
1775
|
+
# narrows `authors` mid-round must not strand the round that is already
|
|
1776
|
+
# running, and stopping one step short of its verdict of record is the
|
|
1777
|
+
# studio #298 failure this module refuses everywhere else (PR #94 round
|
|
1778
|
+
# 1). It costs a filtered PR one my_reviews fetch and the review-task
|
|
1779
|
+
# lookup per poll -- the negative cache bounds the corpus search -- and
|
|
1780
|
+
# that is the price of not stranding a round.
|
|
1781
|
+
#
|
|
1782
|
+
# Above THIS line, therefore, and everything the issue's checklist asks
|
|
1783
|
+
# for follows from where it sits: the effective cap and its ack
|
|
1784
|
+
# announcement, the cap-out escalation (the only branch below that would
|
|
1785
|
+
# COMMENT on the PR), `round_ = completed + 1`, the stale-round probe and
|
|
1786
|
+
# the spawn are all downstream. A filtered PR burns no round number,
|
|
1787
|
+
# records no attempt, holds no stale-round slot and never says a word on
|
|
1788
|
+
# the PR -- it gets silence, exactly as an unwatched repo does.
|
|
1789
|
+
if not self.config.serves_author(pr.author):
|
|
1790
|
+
return Decision(
|
|
1791
|
+
Action.SKIPPED,
|
|
1792
|
+
f"PR author {pr.author} is not in the authors allowlist",
|
|
1793
|
+
)
|
|
1794
|
+
|
|
1758
1795
|
# The effective cap is the configured one plus every re-entry an
|
|
1759
1796
|
# operator has explicitly acked on THIS PR (issue #42). The sum is a
|
|
1760
1797
|
# local read, so it costs nothing on the common path; the GitHub scan
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.22.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.20.0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|