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.
Files changed (32) hide show
  1. {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
  2. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/__main__.py +10 -0
  3. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/alissa.py +72 -6
  4. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/config.py +39 -2
  5. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/loop.py +37 -0
  6. alissa_tools_github_revloop-0.22.0/src/main/alissa/tools/github/revloop/version +1 -0
  7. {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
  8. alissa_tools_github_revloop-0.20.0/src/main/alissa/tools/github/revloop/version +0 -1
  9. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/LICENSE +0 -0
  10. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/MANIFEST.in +0 -0
  11. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/NOTICE +0 -0
  12. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/README.md +0 -0
  13. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/requirements.txt +0 -0
  14. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/setup.cfg +0 -0
  15. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/setup.py +0 -0
  16. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  17. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/ghclient.py +0 -0
  18. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/proc.py +0 -0
  19. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  20. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/state.py +0 -0
  21. {alissa_tools_github_revloop-0.20.0 → alissa_tools_github_revloop-0.22.0}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {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
  23. {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
  24. {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
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
  30. {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
  31. {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
  32. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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
- # A review task is "open" while it can still receive a verdict.
17
- OPEN_STATUSES = {"committed", "in_progress", "pending_validation", "todo"}
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 OPEN_STATUSES itself and
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
- TASK_LIST_STATUS_FILTER = ",".join(sorted(OPEN_STATUSES))
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
- if flags.status and narrow_status:
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. Both list keys go through here so
557
- neither can grow the footgun back.
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera