alissa-tools-github-devloop 0.8.26__py3-none-any.whl → 0.8.28__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.
@@ -194,6 +194,40 @@ def build_parser() -> argparse.ArgumentParser:
194
194
  "sessions, even if the config enables it",
195
195
  )
196
196
 
197
+ over.add_argument(
198
+ "--revert-label",
199
+ metavar="LABEL",
200
+ help="label an operator applies to a MERGED PR to request a revert "
201
+ "session (a `TASK-<n>-REVERT-PR<k>` branch reverting the merge "
202
+ "commit, opened as a ready PR); the session removes the label on "
203
+ "completion. Env ALISSA_REVERT_LABEL (non-blank) wins over this flag",
204
+ )
205
+ revert = over.add_mutually_exclusive_group()
206
+ revert.add_argument(
207
+ "--revert",
208
+ dest="revert_enabled",
209
+ action="store_true",
210
+ default=None,
211
+ help="poll merged PRs carrying the revert label and spawn revert "
212
+ "sessions (the default)",
213
+ )
214
+ revert.add_argument(
215
+ "--no-revert",
216
+ dest="revert_enabled",
217
+ action="store_false",
218
+ help="never search for the revert label or spawn revert sessions, "
219
+ "even if the config enables it",
220
+ )
221
+ over.add_argument(
222
+ "--revert-any-author",
223
+ dest="revert_any_author",
224
+ action="store_true",
225
+ default=None,
226
+ help="let the revert lever act on merged PRs by ANY author, not only "
227
+ "the daemon's own (default off: a foreign-authored PR is refused "
228
+ "with a comment and unlabelled)",
229
+ )
230
+
197
231
  resume = over.add_mutually_exclusive_group()
198
232
  resume.add_argument(
199
233
  "--resume",
@@ -431,6 +465,9 @@ def overrides_from(args: argparse.Namespace) -> dict:
431
465
  "fix_rounds_enabled": args.fix_rounds_enabled,
432
466
  "maintain_label": args.maintain_label,
433
467
  "maintain_enabled": args.maintain_enabled,
468
+ "revert_label": args.revert_label,
469
+ "revert_enabled": args.revert_enabled,
470
+ "revert_any_author": args.revert_any_author,
434
471
  "resume_enabled": args.resume_enabled,
435
472
  "wedge_minutes": args.wedge_minutes,
436
473
  "rerequest_enabled": args.rerequest_enabled,
@@ -532,7 +569,7 @@ def log_effective_config(config: Config, login: str) -> None:
532
569
  log.info(
533
570
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
534
571
  "max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
535
- "(label %r); resume=%s; "
572
+ "(label %r); revert=%s (label %r, any_author=%s); resume=%s; "
536
573
  "rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
537
574
  "activity_claims=%s; janitor=%s (grace %s min, every %s min, cache "
538
575
  "cap %s MiB, warn at %s%%)",
@@ -542,6 +579,7 @@ def log_effective_config(config: Config, login: str) -> None:
542
579
  config.reap_grace_seconds // 60,
543
580
  config.fix_rounds_enabled,
544
581
  config.maintain_enabled, config.maintain_label,
582
+ config.revert_enabled, config.revert_label, config.revert_any_author,
545
583
  config.resume_enabled,
546
584
  config.rerequest_enabled,
547
585
  config.rerequest_grace_seconds // 60,
@@ -24,6 +24,8 @@ through to the config rather than fail the daemon's boot on a mode of `''`.
24
24
  `loop_events_enabled` rides the same shape of rail through
25
25
  `ALISSA_DEV_LOOP_EVENTS_ENABLED`, with the same blank-falls-through rule, and
26
26
  `fleet_vitals_enabled` through `ALISSA_DEV_FLEET_VITALS_ENABLED` likewise.
27
+ `revert_label` rides `ALISSA_REVERT_LABEL` the same way (issue #131): a
28
+ non-blank value wins over the CLI and the file, a blank one falls through.
27
29
  """
28
30
 
29
31
  from __future__ import annotations
@@ -108,6 +110,12 @@ ENV_LOOP_EVENTS = "ALISSA_DEV_LOOP_EVENTS_ENABLED"
108
110
  # non-blank-wins / blank-falls-through rule, same config error on a typo.
109
111
  ENV_FLEET_VITALS = "ALISSA_DEV_FLEET_VITALS_ENABLED"
110
112
 
113
+ # The revert lever's label rail (issue #131): `revert_label` ridden the way
114
+ # ALISSA_DEV_REPOS_SOURCE rides repos_source -- a NON-BLANK value wins over
115
+ # the CLI and the file, a blank one falls through. Spelled as the issue names
116
+ # it (no `_DEV_` infix), because that is the name the operator runbook gives.
117
+ ENV_REVERT_LABEL = "ALISSA_REVERT_LABEL"
118
+
111
119
  # The prompt responder's master switch (issue #127): `on` answers, `observe`
112
120
  # classifies and narrates but sends nothing, `off` never captures a pane.
113
121
  PROMPT_ON = "on"
@@ -192,6 +200,9 @@ CONFIG_KEYS = (
192
200
  "fix_rounds_enabled",
193
201
  "maintain_label",
194
202
  "maintain_enabled",
203
+ "revert_label",
204
+ "revert_enabled",
205
+ "revert_any_author",
195
206
  "resume_enabled",
196
207
  "wedge_minutes",
197
208
  "prompt_responder",
@@ -487,6 +498,20 @@ class Config:
487
498
  maintain_label: str = "alissa:maintain"
488
499
  maintain_enabled: bool = True
489
500
 
501
+ # The revert lever (issue #131, the fourth polled edge): an operator
502
+ # labels a MERGED PR `revert_label` to request a session that reverts its
503
+ # merge commit on a fresh `TASK-<n>-REVERT-PR<k>` branch and opens the
504
+ # revert PR ready for review. One spawn per label application (the ledger
505
+ # keys on the labeled-at instant), no stale retry -- the operator's retry
506
+ # is unlabel + re-label. `revert_any_author` widens the lever past the
507
+ # loop's own PRs: off by default in `merge_author_logins`' spirit, because
508
+ # the daemon reverting a human's merge is a decision a human should make
509
+ # explicitly, in config, not by applying a label. Default ON like the
510
+ # maintenance edge, and every side effect still honors dry_run.
511
+ revert_label: str = "alissa:revert"
512
+ revert_enabled: bool = True
513
+ revert_any_author: bool = False
514
+
490
515
  # The third staleness signal (session liveness, read from the reaper's own
491
516
  # tmux listing) and the auto-resume it unlocks: a stale attempt whose
492
517
  # ledger session is provably gone is respawned with RESUME_DIRECTIVE /
@@ -961,6 +986,14 @@ class Config:
961
986
  env_map, ENV_FLEET_VITALS,
962
987
  bool(raw.get("fleet_vitals_enabled", cls.fleet_vitals_enabled)),
963
988
  )
989
+ # The revert label's rail (issue #131): repos_source's shape -- a
990
+ # non-blank ALISSA_REVERT_LABEL wins, blank falls through.
991
+ revert_label = str(raw.get("revert_label", cls.revert_label))
992
+ env_revert_label = env_map.get(ENV_REVERT_LABEL, "").strip()
993
+ if env_revert_label:
994
+ revert_label = env_revert_label
995
+ if not revert_label.strip():
996
+ raise ValueError("revert_label must not be blank")
964
997
 
965
998
  bow_owners = normalize_bow_owners(raw.get("bow_owners", cls.bow_owners))
966
999
 
@@ -1215,6 +1248,11 @@ class Config:
1215
1248
  maintain_enabled=bool(
1216
1249
  raw.get("maintain_enabled", cls.maintain_enabled)
1217
1250
  ),
1251
+ revert_label=revert_label,
1252
+ revert_enabled=bool(raw.get("revert_enabled", cls.revert_enabled)),
1253
+ revert_any_author=bool(
1254
+ raw.get("revert_any_author", cls.revert_any_author)
1255
+ ),
1218
1256
  resume_enabled=bool(
1219
1257
  raw.get("resume_enabled", cls.resume_enabled)
1220
1258
  ),
@@ -208,7 +208,7 @@ def build_snapshot(
208
208
  ledgers = sources.ledgers()
209
209
 
210
210
  repo_by_session: "dict[str, str]" = {}
211
- for table in ("spawns", "fix_spawns", "maintain_spawns"):
211
+ for table in ("spawns", "fix_spawns", "maintain_spawns", "revert_spawns"):
212
212
  for row in ledgers[table]:
213
213
  session = row.get("session")
214
214
  if session and session not in repo_by_session:
@@ -243,6 +243,7 @@ def build_snapshot(
243
243
  int(latest.get("issue_candidates") or 0)
244
244
  + int(latest.get("pr_candidates") or 0)
245
245
  + int(latest.get("maintain_candidates") or 0)
246
+ + int(latest.get("revert_candidates") or 0)
246
247
  )
247
248
 
248
249
  drift = sources.drift()
@@ -14,6 +14,7 @@ import re
14
14
  import tempfile
15
15
  import time
16
16
  from dataclasses import dataclass
17
+ from urllib.parse import quote
17
18
 
18
19
  from . import proc
19
20
 
@@ -38,6 +39,13 @@ TASK_REF_RE = re.compile(r"TASK-(\d+)")
38
39
  TIMELINE_PAGE_SIZE = 100
39
40
  TIMELINE_MAX_PAGES = 5
40
41
 
42
+ # How far back `first_parent_contains` walks the mainline (in pages of
43
+ # TIMELINE_PAGE_SIZE commits) before a merge commit reads as out of reach.
44
+ # Three hundred first-parent commits is roughly three hundred merged PRs on a
45
+ # squash/merge-button repository -- weeks to months of history on the busiest
46
+ # repo this daemon watches; a revert older than that is a hand-run one.
47
+ FIRST_PARENT_MAX_PAGES = 3
48
+
41
49
  # Review states that count as "a review was submitted". PENDING is a draft
42
50
  # review that was never submitted, so it closes nothing. Same set as
43
51
  # reviewloop's SUBMITTED_STATES -- the two daemons must count rounds
@@ -156,6 +164,29 @@ class PullRequest:
156
164
  # probe already pays. Defaulted like the fields above it: the other edges
157
165
  # and their fakes never ask.
158
166
  updated_at: "int | None" = None
167
+ # The merge record, read by the REVERT edge (issue #131) and nobody else:
168
+ # whether the PR was merged (a closed-unmerged PR has nothing to revert),
169
+ # the commit the merge produced -- a merge commit, a squash commit or the
170
+ # last rebased commit, whichever shape the repository's merge button took
171
+ # -- and who pressed it. Defaulted like the fields above: the spawn edges
172
+ # and their fakes never ask, and an OPEN PR carries none of them.
173
+ merged: bool = False
174
+ merge_commit_sha: str = ""
175
+ merged_by: str = ""
176
+ # How many commits the PR carried (`commits` on the pulls payload), read
177
+ # by the revert edge alone: it is what tells a REBASE merge from a squash
178
+ # once the merge sha turns out to have one parent. After a rebase merge
179
+ # `merge_commit_sha` is only the LAST rebased commit, so a session that
180
+ # reverted it alone would undo one commit of N under a PR claiming the
181
+ # whole undo (PR #132 review round 1); with the count in hand the
182
+ # directive can name the range. 0 when the payload carried no readable
183
+ # count -- UNKNOWN, which the directive spells as "count first", never
184
+ # as one.
185
+ commits: int = 0
186
+ # The labels CURRENTLY on the PR, for the revert edge's re-fetch guard:
187
+ # the label search is a snapshot, and an operator who removed the label
188
+ # between search and fetch has withdrawn the request.
189
+ labels: tuple[str, ...] = ()
159
190
 
160
191
  @property
161
192
  def full_name(self) -> str:
@@ -212,9 +243,37 @@ def _pull_request_from(
212
243
  closed_at=parse_github_timestamp(data.get("closed_at")),
213
244
  base_ref=base.get("ref") or "",
214
245
  updated_at=parse_github_timestamp(data.get("updated_at")),
246
+ merged=bool(data.get("merged")),
247
+ merge_commit_sha=data.get("merge_commit_sha") or "",
248
+ merged_by=(data.get("merged_by") or {}).get("login", ""),
249
+ commits=_int_or_zero(data.get("commits")),
250
+ labels=tuple(
251
+ (lb.get("name") or "") if isinstance(lb, dict) else str(lb)
252
+ for lb in (data.get("labels") or [])
253
+ ),
215
254
  )
216
255
 
217
256
 
257
+ def _int_or_zero(value: object) -> int:
258
+ """A payload integer, or 0 for anything that is not one (missing, null,
259
+ a string GitHub never sends) -- the thin-payload reading of a count."""
260
+ try:
261
+ return max(0, int(value)) # type: ignore[call-overload]
262
+ except (TypeError, ValueError):
263
+ return 0
264
+
265
+
266
+ @dataclass(frozen=True)
267
+ class LabelApplication:
268
+ """One `labeled` event: when a label was applied and by whom. The revert
269
+ edge's request record -- `created_at` is the dedupe epoch (one spawn per
270
+ label application) and `actor` is named in the revert PR's body and on
271
+ the original PR, so the paper trail says whose request it was."""
272
+
273
+ created_at: str
274
+ actor: str
275
+
276
+
218
277
  @dataclass(frozen=True)
219
278
  class Review:
220
279
  author: str
@@ -662,7 +721,40 @@ class GitHub:
662
721
  and never a fresh budget that should not exist. Malformed events
663
722
  are skipped, never fatal.
664
723
  """
665
- newest: "str | None" = None
724
+ event = self._newest_labeled_event(owner, repo, number, label)
725
+ return None if event is None else event.get("created_at") or None
726
+
727
+ def revert_labeled_at(
728
+ self, owner: str, repo: str, number: int, label: str
729
+ ) -> "LabelApplication | None":
730
+ """The MOST RECENT application of the revert label to this PR -- when
731
+ and by whom -- or None when no labeled event is visible.
732
+
733
+ The revert edge's request record (issue #131): the timestamp is the
734
+ dedupe key of the one spawn a label application buys (re-labelling
735
+ after an unlabel is a NEW request and spawns again), and the actor
736
+ is who asked, named in the revert PR body and on the original PR.
737
+ Same bounded events walk as `maintain_labeled_at`; an event past the
738
+ ceiling reads as an OLDER application or none, which under-spawns
739
+ (a request the daemon cannot date is not acted on twice), never
740
+ over-spawns. An actor-less event (a deleted account) reads as ''.
741
+ """
742
+ event = self._newest_labeled_event(owner, repo, number, label)
743
+ if event is None or not event.get("created_at"):
744
+ return None
745
+ return LabelApplication(
746
+ created_at=str(event["created_at"]),
747
+ actor=str((event.get("actor") or {}).get("login") or ""),
748
+ )
749
+
750
+ def _newest_labeled_event(
751
+ self, owner: str, repo: str, number: int, label: str
752
+ ) -> "dict | None":
753
+ """The newest `labeled` event naming `label` on this issue/PR, as the
754
+ raw event dict, or None -- the walk behind maintain_labeled_at and
755
+ revert_labeled_at (ascending feed, so the last match wins; stops at
756
+ the first short page; hard-capped at TIMELINE_MAX_PAGES)."""
757
+ newest: "dict | None" = None
666
758
  for page in range(1, TIMELINE_MAX_PAGES + 1):
667
759
  events = (
668
760
  self._api(
@@ -681,16 +773,170 @@ class GitHub:
681
773
  for event in events:
682
774
  if not isinstance(event, dict) or event.get("event") != "labeled":
683
775
  continue
776
+ # casefold(): GitHub label names are case-insensitive (the
777
+ # `label:"…"` search matches `Alissa:Revert`), and the revert
778
+ # edge's dedupe key hangs off this match (PR #132 review).
684
779
  name = (event.get("label") or {}).get("name")
685
- if name != label:
780
+ if not isinstance(name, str) or name.casefold() != label.casefold():
686
781
  continue
687
- created = event.get("created_at")
688
- if created:
689
- newest = created # ascending order: the last one wins
782
+ if event.get("created_at"):
783
+ newest = event # ascending order: the last one wins
690
784
  if len(events) < TIMELINE_PAGE_SIZE:
691
785
  break # short page: the feed is exhausted
692
786
  return newest
693
787
 
788
+ def search_revert_prs(
789
+ self, label: str, repos: tuple[str, ...] = ()
790
+ ) -> list[tuple[str, str, int]]:
791
+ """MERGED PRs carrying the revert label across the watched repos, as
792
+ `(owner, repo, number)` hits -- the candidate set of the operator-
793
+ triggered revert edge (issue #131).
794
+
795
+ `is:merged`, not `is:open`: the lever is for work that already
796
+ landed, and labels stay on closed PRs, so the label is a perfectly
797
+ good trigger there. Like the maintenance search this re-sees
798
+ in-flight requests every poll (the ledger, keyed on the label
799
+ application, dedupes) and carries no author qualifier: whether the
800
+ PR is the loop's own work is `evaluate_revert()`'s guard, and a
801
+ foreign one is REFUSED with a comment rather than silently skipped,
802
+ because an operator asked. Fails closed on an empty allowlist like
803
+ its siblings.
804
+ """
805
+ repos = tuple(r.strip() for r in repos if r and r.strip())
806
+ if not repos:
807
+ log.warning(
808
+ "search_revert_prs: repo allowlist is empty -- nothing is "
809
+ "watched. An unscoped search would match all of GitHub, so "
810
+ "this fails closed; configure `repos` to watch something."
811
+ )
812
+ return []
813
+
814
+ query = f'is:pr is:merged label:"{label}"'
815
+ for full_name in repos:
816
+ query += f" repo:{full_name}"
817
+
818
+ payload = self._api(
819
+ "-X",
820
+ "GET",
821
+ "search/issues",
822
+ "-f",
823
+ f"q={query}",
824
+ "-f",
825
+ f"per_page={self.SEARCH_PER_PAGE}",
826
+ )
827
+ items = (payload or {}).get("items", [])
828
+ if len(items) == self.SEARCH_PER_PAGE:
829
+ log.warning(
830
+ "search_revert_prs: got exactly %d items -- results are "
831
+ "likely truncated. Pagination is deliberately not implemented "
832
+ "(same hard ceiling as search_issues); the window only "
833
+ "drains as PRs lose the label.",
834
+ self.SEARCH_PER_PAGE,
835
+ )
836
+
837
+ out: list[tuple[str, str, int]] = []
838
+ for item in items:
839
+ parts = item.get("repository_url", "").rstrip("/").split("/")
840
+ if len(parts) < 2:
841
+ log.warning("could not parse repo from %s", item.get("repository_url"))
842
+ continue
843
+ try:
844
+ number = int(item["number"])
845
+ except (KeyError, TypeError, ValueError):
846
+ log.warning("could not parse PR number from %r", item.get("number"))
847
+ continue
848
+ out.append((parts[-2], parts[-1], number))
849
+ return out
850
+
851
+ def remove_label(self, owner: str, repo: str, number: int, label: str) -> None:
852
+ """Remove `label` from an issue or PR.
853
+
854
+ The ONE label write the daemon performs (issue #131): the revert
855
+ edge's refusal path -- a request it will not act on is acknowledged
856
+ by taking the request label off, so the PR drops out of the search
857
+ instead of being re-refused every poll. Every other label lifecycle
858
+ (the trigger label, the maintenance label, the revert label on the
859
+ happy path) is removed by an operator or by the SESSION, never here.
860
+ A label that is already gone is not an error: GitHub answers 404 and
861
+ the desired state holds, so that one is swallowed at DEBUG.
862
+ """
863
+ try:
864
+ self._api(
865
+ "-X",
866
+ "DELETE",
867
+ f"repos/{owner}/{repo}/issues/{number}/labels/{quote(label, safe='')}",
868
+ )
869
+ except proc.CommandError as exc:
870
+ if "404" in exc.stderr or "Not Found" in exc.stderr:
871
+ log.debug(
872
+ "%s/%s#%d: label %r was already absent", owner, repo, number, label
873
+ )
874
+ return
875
+ raise
876
+
877
+ def first_parent_contains(
878
+ self, owner: str, repo: str, branch: str, sha: str,
879
+ max_pages: int = FIRST_PARENT_MAX_PAGES,
880
+ ) -> bool:
881
+ """Is commit `sha` on `branch`'s FIRST-PARENT history, within the
882
+ newest `max_pages` × 100 ancestors?
883
+
884
+ The revert edge's other refusal (issue #131): `git revert -m 1` on a
885
+ merge commit that is not on the mainline's first-parent chain
886
+ reverts the wrong side of the merge, and a PR merged into a stacked
887
+ branch or a since-rewritten history is exactly that shape. The list
888
+ endpoint returns EVERY ancestor of `branch` newest-first with each
889
+ commit's parents, so the chain is followed locally -- the branch
890
+ head, its first parent, that one's first parent -- across as many
891
+ pages as it takes to meet `sha` or run out. Not found within the
892
+ window reads as NOT on the chain: a merge older than a few hundred
893
+ mainline commits is out of the lever's reach and is refused with a
894
+ message that says so, which is the conservative direction (nothing
895
+ is reverted). A commit whose first parent falls outside the fetched
896
+ window with pages still to fetch pulls the next page; a chain that
897
+ breaks otherwise (a truncated payload) reads as not found.
898
+ """
899
+ commits: "dict[str, list[str]]" = {}
900
+ head: "str | None" = None
901
+ cursor: "str | None" = None
902
+ for page in range(1, max_pages + 1):
903
+ payload = self._api(
904
+ "-X",
905
+ "GET",
906
+ f"repos/{owner}/{repo}/commits",
907
+ "-f",
908
+ f"sha={branch}",
909
+ "-f",
910
+ f"per_page={TIMELINE_PAGE_SIZE}",
911
+ "-f",
912
+ f"page={page}",
913
+ ) or []
914
+ if not isinstance(payload, list) or not payload:
915
+ break
916
+ for entry in payload:
917
+ if not isinstance(entry, dict) or not entry.get("sha"):
918
+ continue
919
+ parents = [
920
+ str(p.get("sha") or "")
921
+ for p in (entry.get("parents") or [])
922
+ if isinstance(p, dict)
923
+ ]
924
+ commits[str(entry["sha"])] = parents
925
+ if head is None:
926
+ head = str(entry["sha"]) # newest first: page 1's first row
927
+ cursor = head if cursor is None else cursor
928
+ # Walk the chain as far as the fetched window allows.
929
+ while cursor is not None and cursor in commits:
930
+ if cursor == sha:
931
+ return True
932
+ parents = commits[cursor]
933
+ cursor = parents[0] if parents else None
934
+ if cursor is None:
935
+ return False # the root commit: the chain is exhausted
936
+ if len(payload) < TIMELINE_PAGE_SIZE:
937
+ break # short page: nothing older to fetch
938
+ return False
939
+
694
940
  def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
695
941
  """The evaluate_pr() re-fetch: draft state, head, author, body, and
696
942
  the pending review requests -- the consistency guard over search