alissa-tools-github-devloop 0.8.26__tar.gz → 0.8.27__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_devloop-0.8.26/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.27}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/__main__.py +39 -1
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/config.py +38 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/fleet_vitals.py +2 -1
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/ghclient.py +251 -5
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/loop.py +727 -17
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/loop_events.py +38 -2
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/state.py +102 -7
- alissa_tools_github_devloop-0.8.27/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/page.py +1 -1
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/sources.py +14 -5
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.8.26/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/alissa_client.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/bows.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/hubs.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/janitor.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/prompts.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.26 → alissa_tools_github_devloop-0.8.27}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
688
|
-
|
|
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
|