alissa-tools-github-devloop 0.3.0__tar.gz → 0.4.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_devloop-0.3.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.4.0}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/__main__.py +28 -1
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/config.py +16 -1
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/ghclient.py +138 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/loop.py +383 -30
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/state.py +84 -0
- alissa_tools_github_devloop-0.4.0/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.3.0/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/README.md +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/setup.py +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.3.0 → alissa_tools_github_devloop-0.4.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -115,6 +115,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
115
115
|
"even if the config enables it",
|
|
116
116
|
)
|
|
117
117
|
|
|
118
|
+
over.add_argument(
|
|
119
|
+
"--maintain-label",
|
|
120
|
+
metavar="LABEL",
|
|
121
|
+
help="PR label an operator applies to request a maintenance session "
|
|
122
|
+
"(merge main into the branch / freshen it); the session removes the "
|
|
123
|
+
"label on completion",
|
|
124
|
+
)
|
|
125
|
+
maintain = over.add_mutually_exclusive_group()
|
|
126
|
+
maintain.add_argument(
|
|
127
|
+
"--maintain",
|
|
128
|
+
dest="maintain_enabled",
|
|
129
|
+
action="store_true",
|
|
130
|
+
default=None,
|
|
131
|
+
help="poll open PRs carrying the maintenance label and spawn "
|
|
132
|
+
"maintenance sessions (the default)",
|
|
133
|
+
)
|
|
134
|
+
maintain.add_argument(
|
|
135
|
+
"--no-maintain",
|
|
136
|
+
dest="maintain_enabled",
|
|
137
|
+
action="store_false",
|
|
138
|
+
help="never search for the maintenance label or spawn maintenance "
|
|
139
|
+
"sessions, even if the config enables it",
|
|
140
|
+
)
|
|
141
|
+
|
|
118
142
|
dry = over.add_mutually_exclusive_group()
|
|
119
143
|
dry.add_argument(
|
|
120
144
|
"--dry-run",
|
|
@@ -149,6 +173,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
149
173
|
"on_missing_origin_task": args.on_missing_origin_task,
|
|
150
174
|
"on_missing_hub": args.on_missing_hub,
|
|
151
175
|
"fix_rounds_enabled": args.fix_rounds_enabled,
|
|
176
|
+
"maintain_label": args.maintain_label,
|
|
177
|
+
"maintain_enabled": args.maintain_enabled,
|
|
152
178
|
"dry_run": args.dry_run,
|
|
153
179
|
}
|
|
154
180
|
|
|
@@ -180,9 +206,10 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
180
206
|
log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
|
|
181
207
|
log.info(
|
|
182
208
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
183
|
-
"fix_rounds=%s",
|
|
209
|
+
"fix_rounds=%s; maintain=%s (label %r)",
|
|
184
210
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
185
211
|
config.stale_minutes, config.fix_rounds_enabled,
|
|
212
|
+
config.maintain_enabled, config.maintain_label,
|
|
186
213
|
)
|
|
187
214
|
log.debug("hub_template: %s", config.hub_template)
|
|
188
215
|
log.debug("agent_profile: %s", config.agent_profile)
|
|
@@ -59,6 +59,8 @@ CONFIG_KEYS = (
|
|
|
59
59
|
"on_missing_origin_task",
|
|
60
60
|
"on_missing_hub",
|
|
61
61
|
"fix_rounds_enabled",
|
|
62
|
+
"maintain_label",
|
|
63
|
+
"maintain_enabled",
|
|
62
64
|
"dry_run",
|
|
63
65
|
)
|
|
64
66
|
|
|
@@ -128,7 +130,7 @@ class Config:
|
|
|
128
130
|
# resolved location, never this field.
|
|
129
131
|
state_path: Path | None = None
|
|
130
132
|
|
|
131
|
-
attempt_cap: int =
|
|
133
|
+
attempt_cap: int = 10
|
|
132
134
|
stale_minutes: int = 120
|
|
133
135
|
|
|
134
136
|
on_missing_origin_task: str = ON_MISSING_WARN
|
|
@@ -140,6 +142,15 @@ class Config:
|
|
|
140
142
|
# still honors dry_run. Turning it off reverts to issues-only polling.
|
|
141
143
|
fix_rounds_enabled: bool = True
|
|
142
144
|
|
|
145
|
+
# The maintenance edge (the third polled edge): an operator labels an
|
|
146
|
+
# open PR `maintain_label` to request an ad-hoc maintenance session
|
|
147
|
+
# (merge conflicts on approved-but-unmerged branches, freshening stale
|
|
148
|
+
# branches — the re-trigger the other two edges deliberately don't
|
|
149
|
+
# cover). Default ON like fix_rounds_enabled, and every side effect
|
|
150
|
+
# still honors dry_run.
|
|
151
|
+
maintain_label: str = "alissa:maintain"
|
|
152
|
+
maintain_enabled: bool = True
|
|
153
|
+
|
|
143
154
|
dry_run: bool = False
|
|
144
155
|
|
|
145
156
|
# Derived at build time: `repos` casefolded for matching. GitHub names are
|
|
@@ -282,6 +293,10 @@ class Config:
|
|
|
282
293
|
fix_rounds_enabled=bool(
|
|
283
294
|
raw.get("fix_rounds_enabled", cls.fix_rounds_enabled)
|
|
284
295
|
),
|
|
296
|
+
maintain_label=raw.get("maintain_label", cls.maintain_label),
|
|
297
|
+
maintain_enabled=bool(
|
|
298
|
+
raw.get("maintain_enabled", cls.maintain_enabled)
|
|
299
|
+
),
|
|
285
300
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
286
301
|
)
|
|
287
302
|
|
|
@@ -6,11 +6,13 @@ through `gh api` against the REST v3 endpoints instead.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import calendar
|
|
9
10
|
import json
|
|
10
11
|
import logging
|
|
11
12
|
import os
|
|
12
13
|
import re
|
|
13
14
|
import tempfile
|
|
15
|
+
import time
|
|
14
16
|
from dataclasses import dataclass
|
|
15
17
|
|
|
16
18
|
from . import proc
|
|
@@ -43,6 +45,19 @@ TIMELINE_MAX_PAGES = 5
|
|
|
43
45
|
SUBMITTED_STATES = {"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"}
|
|
44
46
|
|
|
45
47
|
|
|
48
|
+
def parse_github_timestamp(ts: "str | None") -> "int | None":
|
|
49
|
+
"""A GitHub API timestamp (`2026-07-23T02:13:56Z`) as unix seconds, or
|
|
50
|
+
None when absent/unparseable. GitHub emits UTC `Z` timestamps
|
|
51
|
+
everywhere; anything else conservatively reads as "unknown" -- callers
|
|
52
|
+
treat an unknown boundary as "do not filter", never as an error."""
|
|
53
|
+
if not ts:
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
return calendar.timegm(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ"))
|
|
57
|
+
except ValueError:
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
46
61
|
def parse_task_ref(body: str) -> int | None:
|
|
47
62
|
"""Origin Alissa task number from an issue body, or None if absent.
|
|
48
63
|
|
|
@@ -383,6 +398,129 @@ class GitHub:
|
|
|
383
398
|
out.append((parts[-2], parts[-1], number))
|
|
384
399
|
return out
|
|
385
400
|
|
|
401
|
+
def search_maintain_prs(
|
|
402
|
+
self, label: str, repos: tuple[str, ...] = ()
|
|
403
|
+
) -> list[tuple[str, str, int]]:
|
|
404
|
+
"""Open PRs carrying the maintenance label across the watched repos,
|
|
405
|
+
as `(owner, repo, number)` hits -- the candidate set of the
|
|
406
|
+
operator-triggered maintenance edge.
|
|
407
|
+
|
|
408
|
+
The label IS the trigger and the visible in-flight state: an operator
|
|
409
|
+
applies it to request a maintenance session, and the session removes
|
|
410
|
+
it on completion -- so this search re-sees in-flight work every poll
|
|
411
|
+
(the ledger, not the label, dedupes) and the query deliberately has
|
|
412
|
+
no `author:@me` or `draft:false` qualifier: the operator labeled a
|
|
413
|
+
specific PR, and evaluate_maintain()'s re-fetch guards (author,
|
|
414
|
+
head-ref shape, closed-between-search-and-fetch) do the filtering.
|
|
415
|
+
The label is quoted because it contains a colon (`alissa:maintain`).
|
|
416
|
+
|
|
417
|
+
Fails closed on an empty/degenerate allowlist exactly like the two
|
|
418
|
+
sibling searches: an unscoped label query would run against ALL of
|
|
419
|
+
GitHub, and the caller spawns code-writing sessions against whatever
|
|
420
|
+
comes back.
|
|
421
|
+
"""
|
|
422
|
+
repos = tuple(r.strip() for r in repos if r and r.strip())
|
|
423
|
+
if not repos:
|
|
424
|
+
log.warning(
|
|
425
|
+
"search_maintain_prs: repo allowlist is empty -- nothing is "
|
|
426
|
+
"watched. An unscoped search would match all of GitHub, so "
|
|
427
|
+
"this fails closed; configure `repos` to watch something."
|
|
428
|
+
)
|
|
429
|
+
return []
|
|
430
|
+
|
|
431
|
+
query = f'is:pr is:open label:"{label}"'
|
|
432
|
+
for full_name in repos:
|
|
433
|
+
query += f" repo:{full_name}"
|
|
434
|
+
|
|
435
|
+
payload = self._api(
|
|
436
|
+
"-X",
|
|
437
|
+
"GET",
|
|
438
|
+
"search/issues",
|
|
439
|
+
"-f",
|
|
440
|
+
f"q={query}",
|
|
441
|
+
"-f",
|
|
442
|
+
f"per_page={self.SEARCH_PER_PAGE}",
|
|
443
|
+
)
|
|
444
|
+
items = (payload or {}).get("items", [])
|
|
445
|
+
if len(items) == self.SEARCH_PER_PAGE:
|
|
446
|
+
log.warning(
|
|
447
|
+
"search_maintain_prs: got exactly %d items -- results are "
|
|
448
|
+
"likely truncated. Pagination is deliberately not implemented "
|
|
449
|
+
"(same hard ceiling as search_issues); the window only "
|
|
450
|
+
"drains as PRs close or lose the label.",
|
|
451
|
+
self.SEARCH_PER_PAGE,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
out: list[tuple[str, str, int]] = []
|
|
455
|
+
for item in items:
|
|
456
|
+
# repository_url looks like https://api.github.com/repos/<owner>/<repo>
|
|
457
|
+
parts = item.get("repository_url", "").rstrip("/").split("/")
|
|
458
|
+
if len(parts) < 2:
|
|
459
|
+
log.warning("could not parse repo from %s", item.get("repository_url"))
|
|
460
|
+
continue
|
|
461
|
+
try:
|
|
462
|
+
number = int(item["number"])
|
|
463
|
+
except (KeyError, TypeError, ValueError):
|
|
464
|
+
log.warning("could not parse PR number from %r", item.get("number"))
|
|
465
|
+
continue
|
|
466
|
+
out.append((parts[-2], parts[-1], number))
|
|
467
|
+
return out
|
|
468
|
+
|
|
469
|
+
def maintain_labeled_at(
|
|
470
|
+
self, owner: str, repo: str, number: int, label: str
|
|
471
|
+
) -> "str | None":
|
|
472
|
+
"""When the maintenance label was MOST RECENTLY applied to this PR
|
|
473
|
+
(ISO timestamp), or None when no labeled event is visible -- the
|
|
474
|
+
epoch boundary of the current maintenance request.
|
|
475
|
+
|
|
476
|
+
Completion is a label removal the daemon never observes (a completed
|
|
477
|
+
PR drops out of the label search), so the maintain ledger cannot be
|
|
478
|
+
reset on completion -- instead the caller re-bases its attempt budget
|
|
479
|
+
on rows spawned AFTER the newest `labeled` event: an operator
|
|
480
|
+
re-labeling a PR weeks after a completed cycle starts a fresh budget,
|
|
481
|
+
while a dead session (label never removed, same labeling) keeps
|
|
482
|
+
accumulating attempts toward the cap.
|
|
483
|
+
|
|
484
|
+
Source: the issue-events feed (PRs are issues to this endpoint),
|
|
485
|
+
walked in the same bounded page loop as the timeline probe --
|
|
486
|
+
ascending chronological order, so the NEWEST matching event is the
|
|
487
|
+
last one seen; the walk stops at the first short page and hard-caps
|
|
488
|
+
at TIMELINE_MAX_PAGES pages. A labeled event past the ceiling is
|
|
489
|
+
invisible: the probe then reports an OLDER labeling (or none), which
|
|
490
|
+
under-filters -- attempts from an older request stay in the budget,
|
|
491
|
+
the same direction as the no-event fallback (lifetime accounting)
|
|
492
|
+
and never a fresh budget that should not exist. Malformed events
|
|
493
|
+
are skipped, never fatal.
|
|
494
|
+
"""
|
|
495
|
+
newest: "str | None" = None
|
|
496
|
+
for page in range(1, TIMELINE_MAX_PAGES + 1):
|
|
497
|
+
events = (
|
|
498
|
+
self._api(
|
|
499
|
+
"-X",
|
|
500
|
+
"GET",
|
|
501
|
+
f"repos/{owner}/{repo}/issues/{number}/events",
|
|
502
|
+
"-f",
|
|
503
|
+
f"per_page={TIMELINE_PAGE_SIZE}",
|
|
504
|
+
"-f",
|
|
505
|
+
f"page={page}",
|
|
506
|
+
)
|
|
507
|
+
or []
|
|
508
|
+
)
|
|
509
|
+
if not isinstance(events, list):
|
|
510
|
+
break
|
|
511
|
+
for event in events:
|
|
512
|
+
if not isinstance(event, dict) or event.get("event") != "labeled":
|
|
513
|
+
continue
|
|
514
|
+
name = (event.get("label") or {}).get("name")
|
|
515
|
+
if name != label:
|
|
516
|
+
continue
|
|
517
|
+
created = event.get("created_at")
|
|
518
|
+
if created:
|
|
519
|
+
newest = created # ascending order: the last one wins
|
|
520
|
+
if len(events) < TIMELINE_PAGE_SIZE:
|
|
521
|
+
break # short page: the feed is exhausted
|
|
522
|
+
return newest
|
|
523
|
+
|
|
386
524
|
def pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
|
|
387
525
|
"""The evaluate_pr() re-fetch: draft state, head, author, and the
|
|
388
526
|
pending review requests -- the consistency guard over search
|
|
@@ -57,6 +57,34 @@ CHANGES_REQUESTED); COMMENTED-mode reviews defer to the CR6 verdict envelope
|
|
|
57
57
|
via an injectable reader, and with no readable verdict the edge stays closed
|
|
58
58
|
-- a fix session is never spawned on an ambiguous verdict.
|
|
59
59
|
|
|
60
|
+
The pass's THIRD edge is the MAINTENANCE edge (maintain_enabled): open PRs
|
|
61
|
+
an operator labeled `maintain_label` (default `alissa:maintain`) get a
|
|
62
|
+
maintenance session -- merge conflicts on approved-but-unmerged branches,
|
|
63
|
+
freshening stale branches: the ad-hoc re-trigger the other two edges
|
|
64
|
+
deliberately don't cover. The label is BOTH the trigger and the visible
|
|
65
|
+
in-flight state, and its lifecycle is remove-on-completion by design: the
|
|
66
|
+
daemon never removes it -- the maintenance session does, as its terminal
|
|
67
|
+
act, after push -> re-request (the push stales any approve, so the
|
|
68
|
+
re-request re-arms the reviewer daemon). While the label stays, the
|
|
69
|
+
maintain_spawns ledger dedupes a fresh spawn, ages a stale one into attempt
|
|
70
|
+
k+1 (a dead session's label never clears itself, so the retry is what the
|
|
71
|
+
lifecycle buys), and caps attempts PER REQUEST -- at cap the daemon posts
|
|
72
|
+
ONE operator comment per request (the `maintain-cap` kind, epoch-folded)
|
|
73
|
+
and stops until the operator acts. "Per request" is load-bearing: the edge
|
|
74
|
+
exists to be RE-triggered, completion is a label removal the daemon never
|
|
75
|
+
observes (a completed PR drops out of the search), and ledger rows are
|
|
76
|
+
never deleted -- so the budget is re-based on the newest `labeled` event
|
|
77
|
+
(maintain_labeled_at, fetched lazily on the stale path only): attempts
|
|
78
|
+
spawned before the current labeling belong to a prior, completed request
|
|
79
|
+
and neither age into a false "presumed dead" retry warning nor count
|
|
80
|
+
toward a false cap-out. Attempt NUMBERING stays lifetime-monotonic
|
|
81
|
+
(maintain_max_attempt) even as the budget resets: session names fold the
|
|
82
|
+
attempt number in, and a fresh budget reusing attempt 1 could collide with
|
|
83
|
+
a prior epoch's zombie session. With no readable labeled event the budget
|
|
84
|
+
conservatively stays lifetime-wide (the pre-epoch behavior). The re-fetch
|
|
85
|
+
guards mirror the fix edge's: closed/merged between search and fetch, a
|
|
86
|
+
foreign author, and a foreign-shaped head ref all skip.
|
|
87
|
+
|
|
60
88
|
Finally, every pass runs the SESSION REAPER: finished `develop-*`/`fix-*`
|
|
61
89
|
worker sessions otherwise idle in tmux forever (observed live on the reviewer
|
|
62
90
|
daemon, 2026-07-22 -- and dev workers are worse, because they block through
|
|
@@ -99,6 +127,7 @@ from .ghclient import (
|
|
|
99
127
|
PullRequest,
|
|
100
128
|
RateLimited,
|
|
101
129
|
Review,
|
|
130
|
+
parse_github_timestamp,
|
|
102
131
|
parse_task_ref,
|
|
103
132
|
)
|
|
104
133
|
from .proc import CommandError
|
|
@@ -182,6 +211,35 @@ FIX_DIRECTIVE = (
|
|
|
182
211
|
"create further ali-* sessions — this loop is depth-1."
|
|
183
212
|
)
|
|
184
213
|
|
|
214
|
+
MAINTAIN_DIRECTIVE = (
|
|
215
|
+
"You are an IMPLEMENTER on maintenance duty, not a reviewer. An operator "
|
|
216
|
+
"labeled PR {pr_url} `{label}` (maintenance attempt {attempt} of {cap}): "
|
|
217
|
+
"bring the branch current with main — resolve any merge conflict and "
|
|
218
|
+
"freshen it. "
|
|
219
|
+
"Golden path: your cwd is the repo's worktree hub ROOT and the PR's head "
|
|
220
|
+
"branch `{head_ref}` already exists — re-attach its worktree with `git "
|
|
221
|
+
"worktree add {head_ref} {head_ref}` if it is missing, and work ONLY on "
|
|
222
|
+
"that branch (never a new branch, never a clone, never main). "
|
|
223
|
+
"FIRST the idempotent no-op check: if the branch is already conflict-free "
|
|
224
|
+
"AND current vs origin/main, remove the `{label}` label from the PR "
|
|
225
|
+
"(`gh pr edit {number} --remove-label \"{label}\"`) and exit — push "
|
|
226
|
+
"nothing, re-request nobody; this no-op path is what makes retries safe. "
|
|
227
|
+
"Otherwise merge origin/main INTO the branch (`git merge origin/main`) — "
|
|
228
|
+
"NEVER rebase, never force-push: review threads keep their anchors only "
|
|
229
|
+
"while history is append-only — resolving any conflicts so BOTH sides' "
|
|
230
|
+
"intent is preserved, then run the repo's four scripts (unit tests, "
|
|
231
|
+
"coverage, style, types) and get ALL of them green before publishing. "
|
|
232
|
+
"The terminal sequence's ORDER is load-bearing: push the merge commit, "
|
|
233
|
+
"THEN re-request review from {reviewers} — the push stales any approve, "
|
|
234
|
+
"so the re-request is what re-arms the review daemon — THEN remove the "
|
|
235
|
+
"`{label}` label from the PR LAST: the label removal is the convergence "
|
|
236
|
+
"signal, and nothing may follow it except your last act: run "
|
|
237
|
+
"`alissa tmux kill {session}` to end this session and free the machine "
|
|
238
|
+
"(best-effort — a crashed session never reaches it). NEVER merge the PR, "
|
|
239
|
+
"never push to main, and do NOT create further ali-* sessions — this "
|
|
240
|
+
"loop is depth-1."
|
|
241
|
+
)
|
|
242
|
+
|
|
185
243
|
ESCALATION_COMMENT = (
|
|
186
244
|
"**Dev loop cap-out** — {attempts} developer attempt(s) were spawned on "
|
|
187
245
|
"this issue without it converging (attempt cap: {cap}). The daemon will "
|
|
@@ -223,6 +281,16 @@ FIX_STALLED_COMMENT = (
|
|
|
223
281
|
"close the PR to park the work."
|
|
224
282
|
)
|
|
225
283
|
|
|
284
|
+
MAINTAIN_CAP_COMMENT = (
|
|
285
|
+
"**Maintain loop cap-out** — {attempts} maintenance attempt(s) were "
|
|
286
|
+
"spawned on this PR without any of them completing (attempt cap: {cap}; "
|
|
287
|
+
"completion = the `{label}` label removed, the session's terminal act). "
|
|
288
|
+
"The daemon will not spawn another while the label stays. This needs an "
|
|
289
|
+
"operator decision: finish the maintenance by hand (merge main into the "
|
|
290
|
+
"branch, push, re-request review) and remove the `{label}` label, raise "
|
|
291
|
+
"`attempt_cap` to re-enter the loop, or remove the label to park the PR."
|
|
292
|
+
)
|
|
293
|
+
|
|
226
294
|
ASSIGNMENT_REJECTED_COMMENT = (
|
|
227
295
|
"**Dev loop cannot start** — the daemon tried to self-assign this issue "
|
|
228
296
|
"as its in-flight marker, but GitHub silently dropped the assignment: "
|
|
@@ -245,6 +313,11 @@ ESCALATION_STALLED = "stalled"
|
|
|
245
313
|
ESCALATION_FIX_CAP = "fix-cap"
|
|
246
314
|
ESCALATION_FIX_STALLED = "fix-stalled"
|
|
247
315
|
|
|
316
|
+
# The maintenance edge's cap-out kind prefix. The `maintain-` prefix keeps
|
|
317
|
+
# rows from ever colliding with the issue edge's `cap` when an issue and a
|
|
318
|
+
# PR share a number; the epoch suffix is maintain_cap_kind's job below.
|
|
319
|
+
ESCALATION_MAINTAIN_CAP = "maintain-cap"
|
|
320
|
+
|
|
248
321
|
# CR6 verdict words, spelled exactly as reviewloop's alissa.py spells them --
|
|
249
322
|
# the envelope tie-breaker (see DevWatcher.envelope_verdict) must return
|
|
250
323
|
# these strings, so the two daemons read one vocabulary.
|
|
@@ -252,6 +325,21 @@ VERDICT_APPROVE = "approve"
|
|
|
252
325
|
VERDICT_REQUEST_CHANGES = "request_changes"
|
|
253
326
|
|
|
254
327
|
|
|
328
|
+
def maintain_cap_kind(labeled_ts: "int | None") -> str:
|
|
329
|
+
"""The ledger kind that dedupes ONE maintenance request's cap-out page.
|
|
330
|
+
|
|
331
|
+
Folded per request like fix_cap_kind is per round: request k capping
|
|
332
|
+
out must not silence request k+1's page -- a re-label re-opens the edge
|
|
333
|
+
with a fresh budget, and its cap-out is a fresh operator ask going
|
|
334
|
+
unanswered. The epoch boundary IS the newest `labeled` event's
|
|
335
|
+
timestamp, so it folds into the kind (`maintain-cap:e<ts>`). With no
|
|
336
|
+
readable labeled event (labeled_ts None) the kind stays bare -- the
|
|
337
|
+
budget is lifetime-wide there, so one lifetime page matches it."""
|
|
338
|
+
if labeled_ts is None:
|
|
339
|
+
return ESCALATION_MAINTAIN_CAP
|
|
340
|
+
return f"{ESCALATION_MAINTAIN_CAP}:e{labeled_ts}"
|
|
341
|
+
|
|
342
|
+
|
|
255
343
|
def fix_cap_kind(round_: int) -> str:
|
|
256
344
|
"""The ledger kind that dedupes ONE round's fix cap-out operator page.
|
|
257
345
|
|
|
@@ -366,6 +454,22 @@ def fix_session_name(pr: PullRequest, round_: int, attempt: int) -> str:
|
|
|
366
454
|
return f"fix-{owner}-{repo}-pr{pr.number}-r{round_}-a{attempt}"
|
|
367
455
|
|
|
368
456
|
|
|
457
|
+
def maintain_session_name(pr: PullRequest, attempt: int) -> str:
|
|
458
|
+
"""A tmux-safe maintenance-session name:
|
|
459
|
+
`maintain-<owner>-<repo>-pr<n>-a<attempt>`.
|
|
460
|
+
|
|
461
|
+
Deterministic like its two siblings (no nonce): the attempt number comes
|
|
462
|
+
from the maintain ledger's row count, so every spawn gets a fresh name.
|
|
463
|
+
No round segment -- the edge has no rounds. The owner is folded in for
|
|
464
|
+
the same reason as the other edges (the P3 session-name lesson):
|
|
465
|
+
maintain counts are keyed per `owner/repo`, so two owners' PR #16 can
|
|
466
|
+
both sit at attempt 1, and a repo-only name would collide.
|
|
467
|
+
"""
|
|
468
|
+
owner = re.sub(r"[^A-Za-z0-9-]", "-", pr.owner).strip("-").lower()
|
|
469
|
+
repo = re.sub(r"[^A-Za-z0-9-]", "-", pr.repo).strip("-").lower()
|
|
470
|
+
return f"maintain-{owner}-{repo}-pr{pr.number}-a{attempt}"
|
|
471
|
+
|
|
472
|
+
|
|
369
473
|
def no_envelope_verdict(owner: str, repo: str, number: int) -> "str | None":
|
|
370
474
|
"""The default (stub) envelope tie-breaker: no verdict is ever readable.
|
|
371
475
|
|
|
@@ -990,31 +1094,39 @@ class DevWatcher:
|
|
|
990
1094
|
return VERDICT_REQUEST_CHANGES
|
|
991
1095
|
return self.envelope_verdict(pr.owner, pr.repo, pr.number)
|
|
992
1096
|
|
|
1097
|
+
def _foreign_head_skip(self, pr: PullRequest, attempt: int) -> "Decision | None":
|
|
1098
|
+
"""The head-ref shape guard, shared by every PR-side spawn (fix and
|
|
1099
|
+
maintain): head_ref is interpolated into the directive as both a
|
|
1100
|
+
worktree path and a shell token (`git worktree add {head_ref}
|
|
1101
|
+
{head_ref}`), and a PR's head branch is not strictly self-authored
|
|
1102
|
+
(anyone with push access can move it). This daemon only ever creates
|
|
1103
|
+
flat `TASK-<n>-<DESC>` branches, so a ref outside that shape --
|
|
1104
|
+
slashes that nest the worktree path, whitespace or shell
|
|
1105
|
+
metacharacters that reach an agent prompt -- is not this daemon's
|
|
1106
|
+
work anyway: skip conservatively rather than sanitize. Returns the
|
|
1107
|
+
SKIPPED Decision, or None when the ref is safe to interpolate."""
|
|
1108
|
+
if re.fullmatch(r"[A-Za-z0-9._-]+", pr.head_ref):
|
|
1109
|
+
return None
|
|
1110
|
+
log.warning(
|
|
1111
|
+
"%s head ref %r does not match the daemon's own branch shape "
|
|
1112
|
+
"— not spawning a worker session on a foreign-shaped branch",
|
|
1113
|
+
pr.pr_slug,
|
|
1114
|
+
pr.head_ref,
|
|
1115
|
+
)
|
|
1116
|
+
return Decision(
|
|
1117
|
+
Action.SKIPPED,
|
|
1118
|
+
f"head ref {pr.head_ref!r} does not match the daemon's own "
|
|
1119
|
+
f"branch shape ([A-Za-z0-9._-]+) — foreign-shaped branch, "
|
|
1120
|
+
f"not this daemon's work",
|
|
1121
|
+
attempt,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
993
1124
|
def _spawn_fix(
|
|
994
1125
|
self, pr: PullRequest, round_: int, attempt: int, review: Review
|
|
995
1126
|
) -> Decision:
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
# push access can move it). This daemon only ever creates flat
|
|
1000
|
-
# `TASK-<n>-<DESC>` branches, so a ref outside that shape -- slashes
|
|
1001
|
-
# that nest the worktree path, whitespace or shell metacharacters
|
|
1002
|
-
# that reach an agent prompt -- is not this daemon's work anyway:
|
|
1003
|
-
# skip conservatively rather than sanitize.
|
|
1004
|
-
if not re.fullmatch(r"[A-Za-z0-9._-]+", pr.head_ref):
|
|
1005
|
-
log.warning(
|
|
1006
|
-
"%s head ref %r does not match the daemon's own branch shape "
|
|
1007
|
-
"— not spawning a fix session on a foreign-shaped branch",
|
|
1008
|
-
pr.pr_slug,
|
|
1009
|
-
pr.head_ref,
|
|
1010
|
-
)
|
|
1011
|
-
return Decision(
|
|
1012
|
-
Action.SKIPPED,
|
|
1013
|
-
f"head ref {pr.head_ref!r} does not match the daemon's own "
|
|
1014
|
-
f"branch shape ([A-Za-z0-9._-]+) — foreign-shaped branch, "
|
|
1015
|
-
f"not this daemon's work",
|
|
1016
|
-
attempt,
|
|
1017
|
-
)
|
|
1127
|
+
skipped = self._foreign_head_skip(pr, attempt)
|
|
1128
|
+
if skipped is not None:
|
|
1129
|
+
return skipped
|
|
1018
1130
|
|
|
1019
1131
|
hub, problem = self._ensure_hub(pr.owner, pr.repo)
|
|
1020
1132
|
if problem is not None:
|
|
@@ -1062,11 +1174,211 @@ class DevWatcher:
|
|
|
1062
1174
|
attempt,
|
|
1063
1175
|
)
|
|
1064
1176
|
|
|
1177
|
+
# -- the maintenance edge (operator-labeled PRs) ------------------------
|
|
1178
|
+
|
|
1179
|
+
def evaluate_maintain(self, owner: str, repo: str, number: int) -> Decision:
|
|
1180
|
+
"""Is a maintenance session owed on this labeled PR? The label IS the
|
|
1181
|
+
ask -- an operator applied it -- so unlike the fix edge there is no
|
|
1182
|
+
verdict or court arithmetic here, only the re-fetch guards and the
|
|
1183
|
+
ledger. The label's lifecycle is remove-on-completion: the daemon
|
|
1184
|
+
NEVER removes it (the maintenance session does, as its terminal
|
|
1185
|
+
act), so the label doubles as the visible in-flight state -- the
|
|
1186
|
+
ledger dedupes a fresh spawn, a stale ledger row under a
|
|
1187
|
+
still-present label means the session died and attempt k+1 is owed,
|
|
1188
|
+
and the attempt cap pages the operator once PER REQUEST and stops.
|
|
1189
|
+
|
|
1190
|
+
The attempt budget is per maintenance REQUEST, not per PR lifetime:
|
|
1191
|
+
completion (the session's label removal) is invisible to the daemon
|
|
1192
|
+
and rows are never deleted, so on the stale path the budget is
|
|
1193
|
+
re-based on the newest `labeled` event -- attempts spawned before
|
|
1194
|
+
the current labeling belong to a prior, completed request, and must
|
|
1195
|
+
neither trigger the false "presumed dead" warning nor eat the new
|
|
1196
|
+
request's budget (the edge exists to be re-triggered). The probe is
|
|
1197
|
+
lazy: no prior rows, or a fresh in-flight spawn (always the current
|
|
1198
|
+
request's), never pays for the events fetch."""
|
|
1199
|
+
pr = self.github.pull_request(owner, repo, number)
|
|
1200
|
+
|
|
1201
|
+
# Closed/merged between search and fetch: nothing left to maintain.
|
|
1202
|
+
if pr.state != "open":
|
|
1203
|
+
return Decision(Action.SKIPPED, "PR closed between search and fetch")
|
|
1204
|
+
|
|
1205
|
+
# Mirror of the fix edge's author guard: a maintenance session
|
|
1206
|
+
# merges main into the head branch and pushes it, and a foreign PR
|
|
1207
|
+
# must never grow a session pushing to its branch -- whoever labeled
|
|
1208
|
+
# it, the daemon only maintains its own work.
|
|
1209
|
+
if pr.author != self.github.login:
|
|
1210
|
+
return Decision(
|
|
1211
|
+
Action.SKIPPED,
|
|
1212
|
+
f"PR author is {pr.author!r}, not the developer identity — "
|
|
1213
|
+
f"not this daemon's to maintain",
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
attempts = self.state.maintain_attempt_count(pr.full_name, number)
|
|
1217
|
+
age = self.state.maintain_spawn_age(pr.full_name, number)
|
|
1218
|
+
|
|
1219
|
+
if age is not None and age < self.config.stale_minutes * 60:
|
|
1220
|
+
return Decision(
|
|
1221
|
+
Action.IN_FLIGHT,
|
|
1222
|
+
f"maintenance attempt {attempts} enqueued {int(age)}s ago "
|
|
1223
|
+
f"(the label stays until the session completes)",
|
|
1224
|
+
attempts,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
# Stale path with history: re-base the budget on the current
|
|
1228
|
+
# request. An unreadable boundary (no visible labeled event, or an
|
|
1229
|
+
# unparseable timestamp) conservatively leaves the lifetime budget
|
|
1230
|
+
# in place -- under-filtering can only page the operator early,
|
|
1231
|
+
# never spawn past a real request's cap.
|
|
1232
|
+
labeled_ts: "int | None" = None
|
|
1233
|
+
if attempts:
|
|
1234
|
+
labeled_ts = parse_github_timestamp(
|
|
1235
|
+
self.github.maintain_labeled_at(
|
|
1236
|
+
owner, repo, number, self.config.maintain_label
|
|
1237
|
+
)
|
|
1238
|
+
)
|
|
1239
|
+
if labeled_ts is not None:
|
|
1240
|
+
attempts = self.state.maintain_attempt_count(
|
|
1241
|
+
pr.full_name, number, since=labeled_ts
|
|
1242
|
+
)
|
|
1243
|
+
age = self.state.maintain_spawn_age(
|
|
1244
|
+
pr.full_name, number, since=labeled_ts
|
|
1245
|
+
)
|
|
1246
|
+
|
|
1247
|
+
# This request's budget ran dry while the label is still present:
|
|
1248
|
+
# page the operator ON the PR once (the epoch-folded maintain-cap
|
|
1249
|
+
# kind, so a later request's cap-out pages again) and stop. No
|
|
1250
|
+
# deferral tier above this, unlike the other edges: the label is an
|
|
1251
|
+
# explicit operator ask, and its removal -- by the session on
|
|
1252
|
+
# completion, or by the operator to park the PR -- is the only
|
|
1253
|
+
# convergence signal this edge has.
|
|
1254
|
+
if attempts >= self.config.attempt_cap:
|
|
1255
|
+
kind = maintain_cap_kind(labeled_ts)
|
|
1256
|
+
if self.state.escalated(pr.full_name, number, kind):
|
|
1257
|
+
return Decision(Action.CAPPED, "already escalated", attempts)
|
|
1258
|
+
self._escalate_maintain_cap(pr, attempts, kind)
|
|
1259
|
+
return Decision(
|
|
1260
|
+
Action.ESCALATED,
|
|
1261
|
+
f"{attempts} maintenance attempts this request, cap "
|
|
1262
|
+
f"{self.config.attempt_cap} — the label is still present; "
|
|
1263
|
+
f"operator paged on the PR",
|
|
1264
|
+
attempts,
|
|
1265
|
+
)
|
|
1266
|
+
|
|
1267
|
+
if age is not None:
|
|
1268
|
+
log.warning(
|
|
1269
|
+
"%s maintenance attempt %d has been in flight %.0f min and "
|
|
1270
|
+
"the %r label is still present — re-enqueuing (maintenance "
|
|
1271
|
+
"session presumed dead; a live one removes the label as its "
|
|
1272
|
+
"terminal act)",
|
|
1273
|
+
pr.pr_slug,
|
|
1274
|
+
attempts,
|
|
1275
|
+
age / 60,
|
|
1276
|
+
self.config.maintain_label,
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
# Numbering is lifetime-monotonic even when the budget reset: the
|
|
1280
|
+
# attempt number is folded into the deterministic session name, and
|
|
1281
|
+
# a fresh request reusing attempt 1 could collide with a prior
|
|
1282
|
+
# epoch's still-idling session.
|
|
1283
|
+
return self._spawn_maintain(
|
|
1284
|
+
pr, self.state.maintain_max_attempt(pr.full_name, number) + 1
|
|
1285
|
+
)
|
|
1286
|
+
|
|
1287
|
+
def _spawn_maintain(self, pr: PullRequest, attempt: int) -> Decision:
|
|
1288
|
+
skipped = self._foreign_head_skip(pr, attempt)
|
|
1289
|
+
if skipped is not None:
|
|
1290
|
+
return skipped
|
|
1291
|
+
|
|
1292
|
+
hub, problem = self._ensure_hub(pr.owner, pr.repo)
|
|
1293
|
+
if problem is not None:
|
|
1294
|
+
return Decision(Action.SKIPPED, problem, attempt)
|
|
1295
|
+
|
|
1296
|
+
# The resolved reviewers, re-requested after the push (which stales
|
|
1297
|
+
# any approve). With none resolved the session falls back to the
|
|
1298
|
+
# PR's own review history -- always recoverable from the PR itself.
|
|
1299
|
+
reviewers = ", ".join(self.config.reviewers) or (
|
|
1300
|
+
"the PR's existing reviewers (none resolved in config — "
|
|
1301
|
+
"re-request whoever reviewed it)"
|
|
1302
|
+
)
|
|
1303
|
+
name = maintain_session_name(pr, attempt)
|
|
1304
|
+
directive = MAINTAIN_DIRECTIVE.format(
|
|
1305
|
+
pr_url=pr.url,
|
|
1306
|
+
number=pr.number,
|
|
1307
|
+
label=self.config.maintain_label,
|
|
1308
|
+
attempt=attempt,
|
|
1309
|
+
cap=self.config.attempt_cap,
|
|
1310
|
+
head_ref=pr.head_ref,
|
|
1311
|
+
reviewers=reviewers,
|
|
1312
|
+
session=name,
|
|
1313
|
+
)
|
|
1314
|
+
|
|
1315
|
+
# No self-assignment (authorship is the marker, like the fix edge)
|
|
1316
|
+
# and NO label removal: remove-on-completion is the design decision.
|
|
1317
|
+
# The label stays as the visible in-flight state; the ledger row
|
|
1318
|
+
# below is what dedupes the next poll.
|
|
1319
|
+
self.alissa.enqueue_developer(
|
|
1320
|
+
session=name,
|
|
1321
|
+
directive=directive,
|
|
1322
|
+
cwd=hub,
|
|
1323
|
+
agent=self.config.agent_profile,
|
|
1324
|
+
dry_run=self.config.dry_run,
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
if not self.config.dry_run:
|
|
1328
|
+
self.state.record_maintain_spawn(
|
|
1329
|
+
repo_slug=pr.full_name,
|
|
1330
|
+
number=pr.number,
|
|
1331
|
+
attempt=attempt,
|
|
1332
|
+
session=name,
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
return Decision(
|
|
1336
|
+
Action.SPAWNED,
|
|
1337
|
+
f"maintenance session {name} (label {self.config.maintain_label!r} "
|
|
1338
|
+
f"stays until the session removes it)",
|
|
1339
|
+
attempt,
|
|
1340
|
+
)
|
|
1341
|
+
|
|
1342
|
+
def _escalate_maintain_cap(
|
|
1343
|
+
self, pr: PullRequest, attempts: int, kind: str
|
|
1344
|
+
) -> None:
|
|
1345
|
+
"""Operator page for one maintenance request's cap-out, posted ON
|
|
1346
|
+
the PR (the fix edge's _escalate_fix_cap shape): log, comment
|
|
1347
|
+
(dry-run gated), and record the epoch-folded dedupe row (`kind` is
|
|
1348
|
+
maintain_cap_kind's output) even if the comment fails -- a
|
|
1349
|
+
terminal-state page, same trade as cap/assignment/fix-cap."""
|
|
1350
|
+
body = MAINTAIN_CAP_COMMENT.format(
|
|
1351
|
+
attempts=attempts,
|
|
1352
|
+
cap=self.config.attempt_cap,
|
|
1353
|
+
label=self.config.maintain_label,
|
|
1354
|
+
)
|
|
1355
|
+
log.error(
|
|
1356
|
+
"MAINTAIN CAP-OUT %s after %d maintenance attempt(s) with the %r "
|
|
1357
|
+
"label still present — escalating to operator on the PR",
|
|
1358
|
+
pr.pr_slug,
|
|
1359
|
+
attempts,
|
|
1360
|
+
self.config.maintain_label,
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
if self.config.dry_run:
|
|
1364
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.pr_slug, body)
|
|
1365
|
+
return
|
|
1366
|
+
|
|
1367
|
+
try:
|
|
1368
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
1369
|
+
except CommandError as exc:
|
|
1370
|
+
log.error(
|
|
1371
|
+
"could not post the maintain cap-out comment on %s: %s",
|
|
1372
|
+
pr.pr_slug,
|
|
1373
|
+
exc,
|
|
1374
|
+
)
|
|
1375
|
+
self.state.record_escalation(pr.full_name, pr.number, kind)
|
|
1376
|
+
|
|
1065
1377
|
def _ensure_hub(self, owner: str, repo: str) -> tuple[Path, str | None]:
|
|
1066
1378
|
"""Resolve a spawn's cwd (the hub ROOT), hub-ifying the repo first if
|
|
1067
|
-
configured. Shared by
|
|
1068
|
-
takes the bare owner/repo. Returns (hub, problem);
|
|
1069
|
-
non-None when the spawn cannot run."""
|
|
1379
|
+
configured. Shared by all three edges (issue, fix, and maintenance
|
|
1380
|
+
spawns), so it takes the bare owner/repo. Returns (hub, problem);
|
|
1381
|
+
`problem` is non-None when the spawn cannot run."""
|
|
1070
1382
|
full_name = f"{owner}/{repo}"
|
|
1071
1383
|
hub = self.config.hub_for(owner, repo)
|
|
1072
1384
|
if hub.is_dir():
|
|
@@ -1280,16 +1592,21 @@ class DevWatcher:
|
|
|
1280
1592
|
def poll_once(self) -> list[tuple[str, Decision]]:
|
|
1281
1593
|
"""One pass: the reaper sweep FIRST (unconditional and
|
|
1282
1594
|
search-independent — a broken or empty search must never starve
|
|
1283
|
-
it), then
|
|
1284
|
-
behind the fix_rounds_enabled gate — the
|
|
1285
|
-
open PRs authored by the token
|
|
1286
|
-
|
|
1287
|
-
|
|
1595
|
+
it), then the three edges in order: labeled issues (the primary
|
|
1596
|
+
trigger), then — behind the fix_rounds_enabled gate — the
|
|
1597
|
+
review-response edge over open PRs authored by the token, then —
|
|
1598
|
+
behind the maintain_enabled gate — the maintenance edge over PRs
|
|
1599
|
+
the operator labeled. One Decision list covers all edges (the INFO
|
|
1600
|
+
summary counts them together); the slug shape tells issues from PRs
|
|
1601
|
+
in the logs (`owner/repo#7` vs `owner/repo#pr16`), and the decision
|
|
1602
|
+
reason names the edge. The pass ends with one INFO summary line so
|
|
1288
1603
|
unattended logs stay legible without -v."""
|
|
1289
1604
|
reaped = self.reap_finished()
|
|
1290
1605
|
results = self._poll_issues()
|
|
1291
1606
|
if self.config.fix_rounds_enabled:
|
|
1292
1607
|
results.extend(self._poll_prs())
|
|
1608
|
+
if self.config.maintain_enabled:
|
|
1609
|
+
results.extend(self._poll_maintain())
|
|
1293
1610
|
|
|
1294
1611
|
counts = Counter(decision.action for _, decision in results)
|
|
1295
1612
|
log.info(
|
|
@@ -1388,6 +1705,42 @@ class DevWatcher:
|
|
|
1388
1705
|
results.append((slug, decision))
|
|
1389
1706
|
return results
|
|
1390
1707
|
|
|
1708
|
+
def _poll_maintain(self) -> list[tuple[str, Decision]]:
|
|
1709
|
+
found = self.github.search_maintain_prs(
|
|
1710
|
+
self.config.maintain_label, self.config.repos
|
|
1711
|
+
)
|
|
1712
|
+
# "candidates" like the other edges: every labeled PR comes back
|
|
1713
|
+
# each poll -- the label is the in-flight state by design (the
|
|
1714
|
+
# ledger, not the label, dedupes), so in-flight work is re-seen
|
|
1715
|
+
# until its session removes the label.
|
|
1716
|
+
log.info(
|
|
1717
|
+
"%d candidate PR(s) labeled %r (in-flight maintenance included "
|
|
1718
|
+
"by design — the session removes the label on completion)",
|
|
1719
|
+
len(found),
|
|
1720
|
+
self.config.maintain_label,
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
results: list[tuple[str, Decision]] = []
|
|
1724
|
+
for owner, repo, number in found:
|
|
1725
|
+
slug = f"{owner}/{repo}#pr{number}"
|
|
1726
|
+
# Defense in depth, same as the other edges: the search is
|
|
1727
|
+
# already repo-scoped, but nothing outside the allowlist is
|
|
1728
|
+
# evaluated.
|
|
1729
|
+
if not self.config.watches(f"{owner}/{repo}"):
|
|
1730
|
+
continue
|
|
1731
|
+
try:
|
|
1732
|
+
decision = self.evaluate_maintain(owner, repo, number)
|
|
1733
|
+
except RateLimited:
|
|
1734
|
+
raise
|
|
1735
|
+
except CommandError as exc:
|
|
1736
|
+
log.error("%s: %s", slug, exc)
|
|
1737
|
+
decision = Decision(Action.SKIPPED, str(exc))
|
|
1738
|
+
|
|
1739
|
+
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
1740
|
+
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
1741
|
+
results.append((slug, decision))
|
|
1742
|
+
return results
|
|
1743
|
+
|
|
1391
1744
|
def run_forever(self) -> None:
|
|
1392
1745
|
# preflight() is the caller's responsibility -- the CLI runs it once
|
|
1393
1746
|
# for every mode, so calling it here too would double every check.
|
|
@@ -16,6 +16,23 @@ leaves every existing ledger untouched. Fix-round cap-outs reuse the
|
|
|
16
16
|
prefixed (`fix-cap:r<k>`, see loop.fix_cap_kind) so an issue and a PR sharing
|
|
17
17
|
a number can never collide on a row.
|
|
18
18
|
|
|
19
|
+
The maintenance edge keeps its own additive table too, `maintain_spawns`,
|
|
20
|
+
keyed (repo_slug, number, attempt): `number` is the PULL REQUEST number and
|
|
21
|
+
`attempt` retries a presumed-dead maintenance session while the operator's
|
|
22
|
+
label stays on the PR. No `round` column -- the edge has no review rounds:
|
|
23
|
+
the label is the whole trigger, applied and RE-applied by the operator, and
|
|
24
|
+
completion is the SESSION removing it -- a GitHub-side event this ledger
|
|
25
|
+
never observes, so rows are never deleted. Instead the count/age readers
|
|
26
|
+
take a `since` floor: the loop passes the newest `labeled` event's
|
|
27
|
+
timestamp, so each re-label starts a fresh attempt budget while the
|
|
28
|
+
lifetime rows stay put (maintain_max_attempt keeps session names monotonic
|
|
29
|
+
across those epochs -- a fresh budget must never reuse an old attempt
|
|
30
|
+
number, or the deterministic session name could collide with a zombie).
|
|
31
|
+
Cap-outs reuse the `escalations` table under `maintain-cap` kinds
|
|
32
|
+
(prefixed, so a PR can never collide with an issue-edge `cap` row when
|
|
33
|
+
they share a number; the epoch timestamp folds into the kind so each
|
|
34
|
+
request's cap-out pages the operator again -- see loop.maintain_cap_kind).
|
|
35
|
+
|
|
19
36
|
Escalations are keyed per KIND ("cap", "assignment", "stalled", ...): each
|
|
20
37
|
condition has its own operator remedy (raise attempt_cap; grant push access;
|
|
21
38
|
check the developer session / close its PR), so each kind's comment must
|
|
@@ -62,6 +79,15 @@ CREATE TABLE IF NOT EXISTS fix_spawns (
|
|
|
62
79
|
spawned_at INTEGER NOT NULL,
|
|
63
80
|
PRIMARY KEY (repo_slug, number, round, attempt)
|
|
64
81
|
);
|
|
82
|
+
|
|
83
|
+
CREATE TABLE IF NOT EXISTS maintain_spawns (
|
|
84
|
+
repo_slug TEXT NOT NULL,
|
|
85
|
+
number INTEGER NOT NULL,
|
|
86
|
+
attempt INTEGER NOT NULL,
|
|
87
|
+
session TEXT NOT NULL,
|
|
88
|
+
spawned_at INTEGER NOT NULL,
|
|
89
|
+
PRIMARY KEY (repo_slug, number, attempt)
|
|
90
|
+
);
|
|
65
91
|
"""
|
|
66
92
|
|
|
67
93
|
|
|
@@ -147,6 +173,64 @@ class State:
|
|
|
147
173
|
).fetchone()
|
|
148
174
|
return None if row["ts"] is None else max(0.0, time.time() - row["ts"])
|
|
149
175
|
|
|
176
|
+
def record_maintain_spawn(
|
|
177
|
+
self, *, repo_slug: str, number: int, attempt: int, session: str
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Record a maintenance-session spawn. `repo_slug` is `owner/repo`;
|
|
180
|
+
`number` is the PR number."""
|
|
181
|
+
self._db.execute(
|
|
182
|
+
"INSERT OR REPLACE INTO maintain_spawns "
|
|
183
|
+
"(repo_slug, number, attempt, session, spawned_at) "
|
|
184
|
+
"VALUES (?,?,?,?,?)",
|
|
185
|
+
(repo_slug, number, attempt, session, int(time.time())),
|
|
186
|
+
)
|
|
187
|
+
self._db.commit()
|
|
188
|
+
|
|
189
|
+
def maintain_attempt_count(
|
|
190
|
+
self, repo_slug: str, number: int, since: int = 0
|
|
191
|
+
) -> int:
|
|
192
|
+
"""Maintenance attempts for this PR spawned at or after `since` (unix
|
|
193
|
+
seconds). The default floor of 0 counts the PR's lifetime; the loop
|
|
194
|
+
passes the newest `labeled` event's timestamp so the budget covers
|
|
195
|
+
only the CURRENT maintenance request (completion never deletes
|
|
196
|
+
rows -- see the module docstring)."""
|
|
197
|
+
row = self._db.execute(
|
|
198
|
+
"SELECT COUNT(*) AS n FROM maintain_spawns "
|
|
199
|
+
"WHERE repo_slug=? AND number=? AND spawned_at>=?",
|
|
200
|
+
(repo_slug, number, since),
|
|
201
|
+
).fetchone()
|
|
202
|
+
return int(row["n"])
|
|
203
|
+
|
|
204
|
+
def maintain_spawn_age(
|
|
205
|
+
self, repo_slug: str, number: int, since: int = 0
|
|
206
|
+
) -> float | None:
|
|
207
|
+
"""Seconds since the NEWEST maintenance spawn at or after `since` for
|
|
208
|
+
this PR, or None if none exists there. Newest, and clamped at 0.0,
|
|
209
|
+
for the same reasons as spawn_age: a retry resets the staleness
|
|
210
|
+
clock, and a wall-clock step backwards must read as "just spawned",
|
|
211
|
+
never as a negative age. With the epoch floor, spawns from a PRIOR
|
|
212
|
+
request read as None -- a fresh re-label must not inherit a
|
|
213
|
+
weeks-old "stale" age (and the presumed-dead warning behind it)."""
|
|
214
|
+
row = self._db.execute(
|
|
215
|
+
"SELECT MAX(spawned_at) AS ts FROM maintain_spawns "
|
|
216
|
+
"WHERE repo_slug=? AND number=? AND spawned_at>=?",
|
|
217
|
+
(repo_slug, number, since),
|
|
218
|
+
).fetchone()
|
|
219
|
+
return None if row["ts"] is None else max(0.0, time.time() - row["ts"])
|
|
220
|
+
|
|
221
|
+
def maintain_max_attempt(self, repo_slug: str, number: int) -> int:
|
|
222
|
+
"""The highest attempt number ever spawned for this PR (0 when
|
|
223
|
+
none) -- the LIFETIME horizon for numbering the next attempt.
|
|
224
|
+
Deliberately unfiltered: a fresh epoch resets the budget, not the
|
|
225
|
+
numbering, because the deterministic session name folds the attempt
|
|
226
|
+
number in and an old epoch's session can still be idling in tmux."""
|
|
227
|
+
row = self._db.execute(
|
|
228
|
+
"SELECT MAX(attempt) AS m FROM maintain_spawns "
|
|
229
|
+
"WHERE repo_slug=? AND number=?",
|
|
230
|
+
(repo_slug, number),
|
|
231
|
+
).fetchone()
|
|
232
|
+
return int(row["m"] or 0)
|
|
233
|
+
|
|
150
234
|
def spawn_for_session(self, session: str) -> "sqlite3.Row | None":
|
|
151
235
|
"""The spawn row behind a developer session name, or None when the
|
|
152
236
|
name is not on THIS ledger. Session names are deterministic per
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.4.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.3.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
|