alissa-tools-github-devloop 0.1.0__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.
- alissa/tools/github/devloop/__init__.py +11 -0
- alissa/tools/github/devloop/__main__.py +217 -0
- alissa/tools/github/devloop/alissa.py +75 -0
- alissa/tools/github/devloop/config.py +359 -0
- alissa/tools/github/devloop/ghclient.py +406 -0
- alissa/tools/github/devloop/loop.py +620 -0
- alissa/tools/github/devloop/proc.py +56 -0
- alissa/tools/github/devloop/state.py +113 -0
- alissa/tools/github/devloop/version +1 -0
- alissa/tools/github/devloop/version.py +46 -0
- alissa_tools_github_devloop-0.1.0.dist-info/METADATA +61 -0
- alissa_tools_github_devloop-0.1.0.dist-info/RECORD +15 -0
- alissa_tools_github_devloop-0.1.0.dist-info/WHEEL +5 -0
- alissa_tools_github_devloop-0.1.0.dist-info/entry_points.txt +2 -0
- alissa_tools_github_devloop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
"""The watcher loop.
|
|
2
|
+
|
|
3
|
+
One pass = poll GitHub for open issues carrying the trigger label, decide per
|
|
4
|
+
issue whether a fresh developer session is owed, and enqueue it. The search
|
|
5
|
+
deliberately returns assigned issues too: the daemon must re-see its own
|
|
6
|
+
in-flight work to age it, retry a dead session, and escalate a cap-out.
|
|
7
|
+
GitHub assignment is the server-side in-flight marker (the daemon
|
|
8
|
+
self-assigns BEFORE spawning); assignment-based filtering happens in
|
|
9
|
+
evaluate() via the issue() re-fetch -- a foreign assignee is skipped, a
|
|
10
|
+
fresh self-spawn is in flight. (poll_once pre-skips issues whose search
|
|
11
|
+
payload already shows a foreign assignee, sparing the re-fetch; the
|
|
12
|
+
authoritative re-fetch guard covers everything the daemon might act on.)
|
|
13
|
+
Staleness is a two-signal rule: a spawn is presumed dead only when it is
|
|
14
|
+
older than stale_minutes AND the issue has no linked open PR -- elapsed
|
|
15
|
+
time alone cannot tell a dead session from a live implement→PR→review
|
|
16
|
+
cycle that outlasts the timer. The linked-PR deferral has a floor: once it
|
|
17
|
+
outlasts STALLED_DEFER_MULTIPLE stale windows, a "stalled" operator comment
|
|
18
|
+
is posted -- once per deferral episode (per attempt), so a later attempt
|
|
19
|
+
dying behind its own open PR pings again -- while the loop keeps
|
|
20
|
+
deferring: a session that died with its draft PR open must not be silent
|
|
21
|
+
forever. The local
|
|
22
|
+
ledger only counts attempts, ages the newest spawn so a dead session can
|
|
23
|
+
be retried, and remembers which escalation kinds were already raised.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from enum import Enum
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
from .alissa import Alissa
|
|
36
|
+
from .config import HUB_ADD, ON_MISSING_SKIP, ON_MISSING_WARN, Config
|
|
37
|
+
from .ghclient import AssignmentRejected, GitHub, Issue, RateLimited, parse_task_ref
|
|
38
|
+
from .proc import CommandError
|
|
39
|
+
from .state import State
|
|
40
|
+
|
|
41
|
+
log = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
# The origin-task instruction is split in two because the downstream rule is
|
|
44
|
+
# the same either way -- only the starting point differs: a present ref is
|
|
45
|
+
# authoritative; an absent one (spawn_anyway / warn_and_spawn) makes locating
|
|
46
|
+
# or creating the origin the developer's first job.
|
|
47
|
+
_TASK_WITH_ORIGIN = (
|
|
48
|
+
"The origin Alissa task is {task_ref} — before writing code, create your "
|
|
49
|
+
"implementation task DOWNSTREAM of it (create_downstream_task), and keep "
|
|
50
|
+
"its status honest: in_progress while you work, pending_validation at "
|
|
51
|
+
"handoff."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_TASK_WITHOUT_ORIGIN = (
|
|
55
|
+
"The issue carries NO origin Alissa task ref — locate the origin task "
|
|
56
|
+
"behind this issue, or create one, then create your implementation task "
|
|
57
|
+
"DOWNSTREAM of it before writing code, and keep its status honest: "
|
|
58
|
+
"in_progress while you work, pending_validation at handoff."
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
DEV_DIRECTIVE = (
|
|
62
|
+
"You are an IMPLEMENTER, not a reviewer. Implement GitHub issue "
|
|
63
|
+
"{issue_url} (attempt {attempt} of {cap}) — the issue is your "
|
|
64
|
+
"self-contained spec and its acceptance criteria are authoritative. "
|
|
65
|
+
"{task_instruction} "
|
|
66
|
+
"Golden path: your cwd is the repo's worktree hub ROOT — create a task "
|
|
67
|
+
"worktree with `git worktree add TASK-<n>-<DESC>` from there (one task = "
|
|
68
|
+
"one branch = one worktree; never clone, never work directly on main). "
|
|
69
|
+
"Implement to the issue's acceptance criteria and VERIFY locally (run the "
|
|
70
|
+
"repo's test/style/type scripts) before publishing. Open a DRAFT PR whose "
|
|
71
|
+
"body contains `Closes #{number}` and the task ref, and attach the PR URL "
|
|
72
|
+
"to the task as evidence. Then drive the review handoff with the "
|
|
73
|
+
"alissa-pr-review skill, requesting reviewer(s): {reviewers}. In every "
|
|
74
|
+
"review round the ORDER is load-bearing: post your CR8 triage replies "
|
|
75
|
+
"FIRST, then push the fixes and re-request review — the review daemon "
|
|
76
|
+
"re-enters on head movement, so pushing before the triage lands orphans "
|
|
77
|
+
"your replies. NEVER merge, never push to main, and do NOT create further "
|
|
78
|
+
"ali-* sessions — this loop is depth-1."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
ESCALATION_COMMENT = (
|
|
82
|
+
"**Dev loop cap-out** — {attempts} developer attempt(s) were spawned on "
|
|
83
|
+
"this issue without it converging (attempt cap: {cap}). The daemon will "
|
|
84
|
+
"not spawn another; this needs an operator decision: finish the work by "
|
|
85
|
+
"hand, raise `attempt_cap` to re-enter the loop, or remove the "
|
|
86
|
+
"`{label}` label to park the issue."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
STALLED_COMMENT = (
|
|
90
|
+
"**Dev loop stalled?** — attempt {attempts} has been in flight "
|
|
91
|
+
"{minutes} min; PR {urls} is still open — is that session alive? The "
|
|
92
|
+
"daemon keeps deferring the retry while the PR stays open. Operator "
|
|
93
|
+
"options: check (and if dead, kill) the developer session and close the "
|
|
94
|
+
"PR to let the retry proceed, or finish the work by hand. Unassigning "
|
|
95
|
+
"the issue alone is not enough — the daemon's ledger keeps aging this "
|
|
96
|
+
"attempt while the PR is open."
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
ASSIGNMENT_REJECTED_COMMENT = (
|
|
100
|
+
"**Dev loop cannot start** — the daemon tried to self-assign this issue "
|
|
101
|
+
"as its in-flight marker, but GitHub silently dropped the assignment: "
|
|
102
|
+
"`{login}` most likely lacks push access on `{full_name}`. Grant the "
|
|
103
|
+
"account push access, or remove the repo from the daemon's allowlist; "
|
|
104
|
+
"until then no developer session will be spawned for this issue."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# Escalation-ledger kinds. Cap-out, assignment-rejection, and a stalled
|
|
109
|
+
# linked-PR deferral have different operator remedies (raise attempt_cap vs
|
|
110
|
+
# grant push access vs check the session / close the PR), so each kind
|
|
111
|
+
# dedupes its operator comment independently -- one must never silence
|
|
112
|
+
# another. Cap and assignment dedupe once-ever per issue: a cap-out is
|
|
113
|
+
# terminal and a push-access failure is permanent. A stall is neither --
|
|
114
|
+
# see stalled_kind() below.
|
|
115
|
+
ESCALATION_CAP = "cap"
|
|
116
|
+
ESCALATION_ASSIGNMENT = "assignment"
|
|
117
|
+
ESCALATION_STALLED = "stalled"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def stalled_kind(attempt: int) -> str:
|
|
121
|
+
"""The ledger kind for the stalled ping of ONE deferral episode.
|
|
122
|
+
|
|
123
|
+
Unlike cap/assignment, a stall can recur: every attempt can die behind
|
|
124
|
+
its own open PR, and attempt k's ping must not silence attempt k+1's --
|
|
125
|
+
keyed on the bare kind, the TERMINAL attempt stalling would defer
|
|
126
|
+
silently forever, with the cap-out unreachable behind the deferral.
|
|
127
|
+
`kind` is free-form TEXT, so the attempt number folds into the key (no
|
|
128
|
+
schema change): one ping per (issue, attempt). A side benefit: the
|
|
129
|
+
record-even-if-the-comment-fails contract now costs at most one lost
|
|
130
|
+
ping per EPISODE, where a bare-kind row would have silenced the issue
|
|
131
|
+
permanently after a single transient comment failure.
|
|
132
|
+
"""
|
|
133
|
+
return f"{ESCALATION_STALLED}:a{attempt}"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# The floor under the linked-PR deferral: an open linked PR defers the stale
|
|
137
|
+
# retry indefinitely -- correct for a live review loop, silent forever for a
|
|
138
|
+
# session that died with its draft PR open (the deferral sits ABOVE the cap
|
|
139
|
+
# check, so even ESCALATION_COMMENT is unreachable while the PR stays open).
|
|
140
|
+
# Once the newest spawn's age reaches this multiple of stale_minutes, the
|
|
141
|
+
# loop posts one "stalled" operator comment per deferral episode and keeps
|
|
142
|
+
# deferring. 2 means the
|
|
143
|
+
# deferral itself has lasted a full extra stale window beyond the point the
|
|
144
|
+
# timer first fired -- long enough that a healthy review loop has usually
|
|
145
|
+
# converged, early enough that a dead session surfaces the same day.
|
|
146
|
+
STALLED_DEFER_MULTIPLE = 2
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class Action(str, Enum):
|
|
150
|
+
SPAWNED = "spawned"
|
|
151
|
+
IN_FLIGHT = "in-flight"
|
|
152
|
+
CAPPED = "capped"
|
|
153
|
+
ESCALATED = "escalated"
|
|
154
|
+
SKIPPED = "skipped"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@dataclass(frozen=True)
|
|
158
|
+
class Decision:
|
|
159
|
+
action: Action
|
|
160
|
+
reason: str = ""
|
|
161
|
+
attempt: int | None = None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def session_name(issue: Issue, attempt: int) -> str:
|
|
165
|
+
"""A tmux-safe developer session name:
|
|
166
|
+
`develop-<owner>-<repo>-i<n>-a<attempt>`.
|
|
167
|
+
|
|
168
|
+
Deterministic on purpose (no nonce, unlike reviewloop): the attempt number
|
|
169
|
+
comes from the ledger's row count, so every spawn gets a fresh number and
|
|
170
|
+
the name cannot collide with a still-live session. The owner is folded in
|
|
171
|
+
because the attempt count is keyed per `owner/repo` -- two owners' ledgers
|
|
172
|
+
count independently, so `acme/widgets#7` and `other/widgets#7` can both
|
|
173
|
+
legitimately sit at attempt 1 and a repo-only name would collide.
|
|
174
|
+
"""
|
|
175
|
+
owner = re.sub(r"[^A-Za-z0-9-]", "-", issue.owner).strip("-").lower()
|
|
176
|
+
repo = re.sub(r"[^A-Za-z0-9-]", "-", issue.repo).strip("-").lower()
|
|
177
|
+
return f"develop-{owner}-{repo}-i{issue.number}-a{attempt}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class DevWatcher:
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
config: Config,
|
|
184
|
+
github: GitHub | None = None,
|
|
185
|
+
alissa: Alissa | None = None,
|
|
186
|
+
state: State | None = None,
|
|
187
|
+
):
|
|
188
|
+
self.config = config
|
|
189
|
+
self.github = github or GitHub(config.developer_login)
|
|
190
|
+
self.alissa = alissa or Alissa()
|
|
191
|
+
self.state = state or State(config.state_db)
|
|
192
|
+
|
|
193
|
+
# -- per-issue decision ------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def evaluate(self, owner: str, repo: str, number: int) -> Decision:
|
|
196
|
+
issue = self.github.issue(owner, repo, number)
|
|
197
|
+
login = self.github.login
|
|
198
|
+
|
|
199
|
+
# Closed between search and fetch: nothing left to implement.
|
|
200
|
+
if issue.state != "open":
|
|
201
|
+
return Decision(Action.SKIPPED, "issue closed between search and fetch")
|
|
202
|
+
|
|
203
|
+
# The re-fetch is the consistency guard: the search deliberately
|
|
204
|
+
# returns assigned issues (so the daemon re-sees its own in-flight
|
|
205
|
+
# work), and the fetched assignment is authoritative over any search
|
|
206
|
+
# staleness -- a foreign assignee means someone else owns the work.
|
|
207
|
+
others = tuple(a for a in issue.assignees if a and a != login)
|
|
208
|
+
if others:
|
|
209
|
+
return Decision(
|
|
210
|
+
Action.SKIPPED,
|
|
211
|
+
f"assigned to {', '.join(others)} — not this daemon's to work",
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
attempts = self.state.attempt_count(issue.full_name, number)
|
|
215
|
+
age = self.state.spawn_age(issue.full_name, number)
|
|
216
|
+
|
|
217
|
+
if age is not None and age < self.config.stale_minutes * 60:
|
|
218
|
+
return Decision(
|
|
219
|
+
Action.IN_FLIGHT,
|
|
220
|
+
f"attempt {attempts} enqueued {int(age)}s ago",
|
|
221
|
+
attempts,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
if login in issue.assignees and age is None:
|
|
225
|
+
# Self-assigned but the ledger never spawned: assigned outside this
|
|
226
|
+
# daemon (by hand, or under another workspace's ledger). Not a
|
|
227
|
+
# fresh self-spawn, so not ours to double-book.
|
|
228
|
+
return Decision(
|
|
229
|
+
Action.SKIPPED,
|
|
230
|
+
f"assigned to {login} outside this daemon (no spawn on the "
|
|
231
|
+
f"ledger) — unassign the issue to let the loop take it",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
# Staleness needs TWO signals, not one: the ledger timer says the
|
|
235
|
+
# newest spawn is old, but elapsed time alone cannot tell a dead
|
|
236
|
+
# session from a live one -- a healthy implement → draft-PR → review
|
|
237
|
+
# loop routinely outlasts stale_minutes. An open PR linked to the
|
|
238
|
+
# issue is external evidence the work is alive, so the retry (and any
|
|
239
|
+
# cap-out it would feed) is deferred until that PR closes. The probe
|
|
240
|
+
# runs ONLY on the stale path: the fresh-in-flight return above keeps
|
|
241
|
+
# its per-poll cost off the common case. The deferral is floored, not
|
|
242
|
+
# unbounded: past STALLED_DEFER_MULTIPLE stale windows it posts a
|
|
243
|
+
# "stalled" operator comment (a dead session's draft PR stays open
|
|
244
|
+
# forever, and this deferral sits above the cap check, so no other
|
|
245
|
+
# escalation can reach the issue) -- and then keeps deferring. The
|
|
246
|
+
# ping dedupes per deferral EPISODE (stalled_kind), not per issue:
|
|
247
|
+
# every attempt can stall behind its own PR, including the terminal
|
|
248
|
+
# one.
|
|
249
|
+
if age is not None:
|
|
250
|
+
linked = self.github.linked_open_prs(issue.owner, issue.repo, number)
|
|
251
|
+
if linked:
|
|
252
|
+
stalled_after = STALLED_DEFER_MULTIPLE * self.config.stale_minutes * 60
|
|
253
|
+
if age >= stalled_after and not self.state.escalated(
|
|
254
|
+
issue.full_name, number, stalled_kind(attempts)
|
|
255
|
+
):
|
|
256
|
+
self._escalate_stalled(issue, attempts, age, linked)
|
|
257
|
+
return Decision(
|
|
258
|
+
Action.IN_FLIGHT,
|
|
259
|
+
f"attempt {attempts} is stale ({int(age / 60)} min) but an "
|
|
260
|
+
f"open PR is linked ({', '.join(linked)}) — work is alive "
|
|
261
|
+
f"(the review loop can outlast stale_minutes); not "
|
|
262
|
+
f"respawning",
|
|
263
|
+
attempts,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Never spawn attempt cap+1. Escalate ONCE; `escalated()` dedupes, and
|
|
267
|
+
# raising attempt_cap re-enters the loop without clearing the ledger.
|
|
268
|
+
if attempts >= self.config.attempt_cap:
|
|
269
|
+
if self.state.escalated(issue.full_name, number, ESCALATION_CAP):
|
|
270
|
+
return Decision(Action.CAPPED, "already escalated", attempts)
|
|
271
|
+
self._escalate(issue, attempts)
|
|
272
|
+
return Decision(
|
|
273
|
+
Action.ESCALATED,
|
|
274
|
+
f"{attempts} attempts, cap {self.config.attempt_cap}",
|
|
275
|
+
attempts,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
if age is not None:
|
|
279
|
+
log.warning(
|
|
280
|
+
"%s attempt %d has been in flight %.0f min with no completion "
|
|
281
|
+
"and no linked open PR — re-enqueuing as attempt %d (developer "
|
|
282
|
+
"session presumed dead)",
|
|
283
|
+
issue.issue_slug,
|
|
284
|
+
attempts,
|
|
285
|
+
age / 60,
|
|
286
|
+
attempts + 1,
|
|
287
|
+
)
|
|
288
|
+
attempt = attempts + 1
|
|
289
|
+
|
|
290
|
+
task_number = parse_task_ref(issue.body)
|
|
291
|
+
if task_number is None:
|
|
292
|
+
if self.config.on_missing_origin_task == ON_MISSING_SKIP:
|
|
293
|
+
return Decision(
|
|
294
|
+
Action.SKIPPED,
|
|
295
|
+
"no origin Alissa task ref on the issue body "
|
|
296
|
+
"(on_missing_origin_task=skip)",
|
|
297
|
+
attempt,
|
|
298
|
+
)
|
|
299
|
+
if self.config.on_missing_origin_task == ON_MISSING_WARN:
|
|
300
|
+
log.warning(
|
|
301
|
+
"%s has no origin Alissa task ref — spawning anyway; the "
|
|
302
|
+
"developer must locate or create the origin task first",
|
|
303
|
+
issue.issue_slug,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
return self._spawn(issue, attempt, task_number)
|
|
307
|
+
|
|
308
|
+
# -- actions -----------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
def _spawn(self, issue: Issue, attempt: int, task_number: int | None) -> Decision:
|
|
311
|
+
hub, problem = self._ensure_hub(issue)
|
|
312
|
+
if problem is not None:
|
|
313
|
+
return Decision(Action.SKIPPED, problem, attempt)
|
|
314
|
+
|
|
315
|
+
task_ref = f"TASK-{task_number}" if task_number is not None else None
|
|
316
|
+
task_instruction = (
|
|
317
|
+
_TASK_WITH_ORIGIN.format(task_ref=task_ref)
|
|
318
|
+
if task_ref is not None
|
|
319
|
+
else _TASK_WITHOUT_ORIGIN
|
|
320
|
+
)
|
|
321
|
+
reviewers = ", ".join(self.config.reviewers) or (
|
|
322
|
+
"none resolved — follow the alissa-pr-review skill's reviewer "
|
|
323
|
+
"resolution"
|
|
324
|
+
)
|
|
325
|
+
directive = DEV_DIRECTIVE.format(
|
|
326
|
+
issue_url=issue.url,
|
|
327
|
+
number=issue.number,
|
|
328
|
+
attempt=attempt,
|
|
329
|
+
cap=self.config.attempt_cap,
|
|
330
|
+
task_instruction=task_instruction,
|
|
331
|
+
reviewers=reviewers,
|
|
332
|
+
)
|
|
333
|
+
name = session_name(issue, attempt)
|
|
334
|
+
|
|
335
|
+
# assign_self FIRST: the assignment is the server-side in-flight
|
|
336
|
+
# marker, so it must land before anything is enqueued -- a crash
|
|
337
|
+
# between the two leaves an assigned-but-sessionless issue (safe:
|
|
338
|
+
# evaluate() sees a self-assignment with no ledger spawn and skips;
|
|
339
|
+
# the operator unassigns to retry), never an unmarked session racing
|
|
340
|
+
# a second spawn.
|
|
341
|
+
if self.config.dry_run:
|
|
342
|
+
log.info(
|
|
343
|
+
"[dry-run] would assign %s to %s", self.github.login, issue.issue_slug
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
try:
|
|
347
|
+
self.github.assign_self(issue.owner, issue.repo, issue.number)
|
|
348
|
+
except AssignmentRejected as exc:
|
|
349
|
+
return self._escalate_rejection(issue, exc)
|
|
350
|
+
|
|
351
|
+
self.alissa.enqueue_developer(
|
|
352
|
+
session=name,
|
|
353
|
+
directive=directive,
|
|
354
|
+
cwd=hub,
|
|
355
|
+
agent=self.config.agent_profile,
|
|
356
|
+
dry_run=self.config.dry_run,
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
if not self.config.dry_run:
|
|
360
|
+
self.state.record_spawn(
|
|
361
|
+
repo_slug=issue.full_name,
|
|
362
|
+
issue=issue.number,
|
|
363
|
+
attempt=attempt,
|
|
364
|
+
session=name,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
return Decision(
|
|
368
|
+
Action.SPAWNED,
|
|
369
|
+
f"session {name} → {task_ref or 'no origin task'}",
|
|
370
|
+
attempt,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
def _escalate_rejection(self, issue: Issue, exc: AssignmentRejected) -> Decision:
|
|
374
|
+
"""Escalate-don't-retry: a silently dropped assignment is a permanent,
|
|
375
|
+
operator-fixable condition (push access), and without the in-flight
|
|
376
|
+
marker the daemon would respawn a developer against the issue every
|
|
377
|
+
cycle. No spawn record, no enqueue -- comment once and stop."""
|
|
378
|
+
log.error("%s: assignment rejected — %s", issue.issue_slug, exc)
|
|
379
|
+
if self.state.escalated(issue.full_name, issue.number, ESCALATION_ASSIGNMENT):
|
|
380
|
+
return Decision(Action.CAPPED, "assignment rejected; already escalated")
|
|
381
|
+
|
|
382
|
+
body = ASSIGNMENT_REJECTED_COMMENT.format(
|
|
383
|
+
login=self.github.login, full_name=issue.full_name
|
|
384
|
+
)
|
|
385
|
+
try:
|
|
386
|
+
self.github.comment(issue.owner, issue.repo, issue.number, body)
|
|
387
|
+
except CommandError as comment_exc:
|
|
388
|
+
log.error(
|
|
389
|
+
"could not post the assignment-rejected comment on %s: %s",
|
|
390
|
+
issue.issue_slug,
|
|
391
|
+
comment_exc,
|
|
392
|
+
)
|
|
393
|
+
self.state.record_escalation(
|
|
394
|
+
issue.full_name, issue.number, ESCALATION_ASSIGNMENT
|
|
395
|
+
)
|
|
396
|
+
return Decision(
|
|
397
|
+
Action.ESCALATED,
|
|
398
|
+
"assignment rejected — the account likely lacks push access",
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def _escalate_stalled(
|
|
402
|
+
self, issue: Issue, attempts: int, age: float, linked: list[str]
|
|
403
|
+
) -> None:
|
|
404
|
+
"""Operator ping when the linked-PR deferral itself runs long: the PR
|
|
405
|
+
being open is the only thing holding the retry (and the cap-out)
|
|
406
|
+
back, so a human must check whether the session behind it is still
|
|
407
|
+
alive. Posted once per deferral EPISODE (the ledger row is keyed
|
|
408
|
+
stalled_kind(attempts), so a later attempt stalling pings again).
|
|
409
|
+
The decision stays IN_FLIGHT either way -- this comments, it never
|
|
410
|
+
respawns."""
|
|
411
|
+
body = STALLED_COMMENT.format(
|
|
412
|
+
attempts=attempts, minutes=int(age / 60), urls=", ".join(linked)
|
|
413
|
+
)
|
|
414
|
+
log.warning(
|
|
415
|
+
"STALLED %s attempt %d has been deferred %.0f min behind open "
|
|
416
|
+
"PR(s) %s — escalating to operator (once per attempt)",
|
|
417
|
+
issue.issue_slug,
|
|
418
|
+
attempts,
|
|
419
|
+
age / 60,
|
|
420
|
+
", ".join(linked),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if self.config.dry_run:
|
|
424
|
+
log.info("[dry-run] would comment on %s:\n%s", issue.issue_slug, body)
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
self.github.comment(issue.owner, issue.repo, issue.number, body)
|
|
429
|
+
except CommandError as exc:
|
|
430
|
+
log.error(
|
|
431
|
+
"could not post the stalled-deferral comment on %s: %s",
|
|
432
|
+
issue.issue_slug,
|
|
433
|
+
exc,
|
|
434
|
+
)
|
|
435
|
+
self.state.record_escalation(
|
|
436
|
+
issue.full_name, issue.number, stalled_kind(attempts)
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
def _escalate(self, issue: Issue, attempts: int) -> None:
|
|
440
|
+
body = ESCALATION_COMMENT.format(
|
|
441
|
+
attempts=attempts, cap=self.config.attempt_cap, label=self.config.label
|
|
442
|
+
)
|
|
443
|
+
log.error(
|
|
444
|
+
"CAP-OUT %s after %d attempt(s) — escalating to operator",
|
|
445
|
+
issue.issue_slug,
|
|
446
|
+
attempts,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
if self.config.dry_run:
|
|
450
|
+
log.info("[dry-run] would comment on %s:\n%s", issue.issue_slug, body)
|
|
451
|
+
return
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
self.github.comment(issue.owner, issue.repo, issue.number, body)
|
|
455
|
+
except CommandError as exc:
|
|
456
|
+
log.error(
|
|
457
|
+
"could not post escalation comment on %s: %s", issue.issue_slug, exc
|
|
458
|
+
)
|
|
459
|
+
self.state.record_escalation(issue.full_name, issue.number, ESCALATION_CAP)
|
|
460
|
+
|
|
461
|
+
def _ensure_hub(self, issue: Issue) -> tuple[Path, str | None]:
|
|
462
|
+
"""Resolve the developer's cwd (the hub ROOT), hub-ifying the repo
|
|
463
|
+
first if configured. Returns (hub, problem); `problem` is non-None
|
|
464
|
+
when the spawn cannot run."""
|
|
465
|
+
hub = self.config.hub_for(issue.owner, issue.repo)
|
|
466
|
+
if hub.is_dir():
|
|
467
|
+
return hub, None
|
|
468
|
+
|
|
469
|
+
if self.config.on_missing_hub != HUB_ADD:
|
|
470
|
+
return hub, (
|
|
471
|
+
f"no worktree hub at {hub} — add the repo with "
|
|
472
|
+
f"`alissa code workspace add {issue.full_name}`, or set "
|
|
473
|
+
f"on_missing_hub='add' (requires a repos allowlist)"
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Guarded twice: config.build() rejects 'add' without an allowlist,
|
|
477
|
+
# and poll_once() only reaches here for watched repos. Belt and
|
|
478
|
+
# braces -- this path clones code onto the machine and opens it as an
|
|
479
|
+
# agent cwd.
|
|
480
|
+
if not self.config.watches(issue.full_name):
|
|
481
|
+
return hub, f"{issue.full_name} is not in the repos allowlist"
|
|
482
|
+
|
|
483
|
+
if not self.config.manifest_path.is_file():
|
|
484
|
+
return hub, (
|
|
485
|
+
f"{self.config.workspace_root} is not an Alissa Code Workspace "
|
|
486
|
+
f"(no alissa-workspace.yaml) — run `alissa code workspace init`"
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
self.alissa.add_repo_to_workspace(
|
|
491
|
+
issue.owner,
|
|
492
|
+
issue.repo,
|
|
493
|
+
self.config.workspace_root,
|
|
494
|
+
dry_run=self.config.dry_run,
|
|
495
|
+
)
|
|
496
|
+
except CommandError as exc:
|
|
497
|
+
return hub, f"could not hub-ify {issue.full_name}: {exc}"
|
|
498
|
+
|
|
499
|
+
if self.config.dry_run:
|
|
500
|
+
return hub, None
|
|
501
|
+
if not hub.is_dir():
|
|
502
|
+
return hub, (
|
|
503
|
+
f"`alissa code workspace add {issue.full_name}` reported "
|
|
504
|
+
f"success but {hub} still does not exist — check hub_template "
|
|
505
|
+
f"against the manifest's `dir:` override"
|
|
506
|
+
)
|
|
507
|
+
return hub, None
|
|
508
|
+
|
|
509
|
+
# -- preflight ---------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
def preflight(self) -> list[str]:
|
|
512
|
+
"""Startup checks. Returns warnings; raises on anything fatal."""
|
|
513
|
+
warnings: list[str] = []
|
|
514
|
+
|
|
515
|
+
# Fatal: a mismatched identity would search one account's issues and
|
|
516
|
+
# assign / push branches as another, so the in-flight marker breaks.
|
|
517
|
+
# (The CLI logs the resolved identity via log_effective_config.)
|
|
518
|
+
login = self.github.verify_identity()
|
|
519
|
+
|
|
520
|
+
for reviewer in self.config.reviewers:
|
|
521
|
+
if reviewer == login:
|
|
522
|
+
warnings.append(
|
|
523
|
+
f"reviewer {reviewer!r} is the developer identity — GitHub "
|
|
524
|
+
f"refuses review requests from the PR author, so this "
|
|
525
|
+
f"reviewer will never be requested"
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
if not self.config.workspace_root.is_dir():
|
|
529
|
+
warnings.append(
|
|
530
|
+
f"workspace_root {self.config.workspace_root} does not exist"
|
|
531
|
+
)
|
|
532
|
+
elif not self.config.manifest_path.is_file():
|
|
533
|
+
warnings.append(
|
|
534
|
+
f"{self.config.workspace_root} has no alissa-workspace.yaml — "
|
|
535
|
+
f"it is not an Alissa Code Workspace yet "
|
|
536
|
+
f"(`alissa code workspace init`)"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
if not self.config.dry_run and not self.alissa.worker_running():
|
|
540
|
+
warnings.append(
|
|
541
|
+
"`alissa worker` does not appear to be running — queued "
|
|
542
|
+
"developer sessions will not spawn until it is "
|
|
543
|
+
"(`alissa worker start`)"
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
return warnings
|
|
547
|
+
|
|
548
|
+
# -- polling -----------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
def poll_once(self) -> list[tuple[str, Decision]]:
|
|
551
|
+
found = self.github.search_issues(self.config.label, self.config.repos)
|
|
552
|
+
log.info(
|
|
553
|
+
"%d open issue(s) labeled %r awaiting a developer",
|
|
554
|
+
len(found),
|
|
555
|
+
self.config.label,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
results = []
|
|
559
|
+
for owner, repo, number, assignees in found:
|
|
560
|
+
slug = f"{owner}/{repo}#{number}"
|
|
561
|
+
# Defense in depth: the search is already repo-scoped, but nothing
|
|
562
|
+
# outside the allowlist is ever evaluated.
|
|
563
|
+
if not self.config.watches(f"{owner}/{repo}"):
|
|
564
|
+
continue
|
|
565
|
+
# Cheap pre-filter on the search payload's own assignee data: a
|
|
566
|
+
# foreign login visible in the search item means the work is
|
|
567
|
+
# someone else's, so the issue() re-fetch is skipped (an issue
|
|
568
|
+
# parked on a human would otherwise cost one re-fetch per poll,
|
|
569
|
+
# forever). Search data can lag the live issue; the worst case is
|
|
570
|
+
# a mis-skip that self-heals on a later poll, which is why ONLY
|
|
571
|
+
# the foreign case short-circuits -- self-assigned, unassigned,
|
|
572
|
+
# and assignee-data-free items all still go through evaluate()'s
|
|
573
|
+
# authoritative re-fetch guard.
|
|
574
|
+
foreign = tuple(
|
|
575
|
+
a for a in (assignees or ()) if a and a != self.github.login
|
|
576
|
+
)
|
|
577
|
+
if foreign:
|
|
578
|
+
decision = Decision(
|
|
579
|
+
Action.SKIPPED,
|
|
580
|
+
f"search already shows assignee(s) "
|
|
581
|
+
f"{', '.join(foreign)} — foreign-owned, skipped without "
|
|
582
|
+
f"the issue() re-fetch",
|
|
583
|
+
)
|
|
584
|
+
else:
|
|
585
|
+
try:
|
|
586
|
+
decision = self.evaluate(owner, repo, number)
|
|
587
|
+
except RateLimited:
|
|
588
|
+
raise
|
|
589
|
+
except CommandError as exc:
|
|
590
|
+
log.error("%s: %s", slug, exc)
|
|
591
|
+
decision = Decision(Action.SKIPPED, str(exc))
|
|
592
|
+
|
|
593
|
+
level = logging.INFO if decision.action != Action.SKIPPED else logging.DEBUG
|
|
594
|
+
log.log(level, "%s → %s (%s)", slug, decision.action.value, decision.reason)
|
|
595
|
+
results.append((slug, decision))
|
|
596
|
+
return results
|
|
597
|
+
|
|
598
|
+
def run_forever(self) -> None:
|
|
599
|
+
# preflight() is the caller's responsibility -- the CLI runs it once
|
|
600
|
+
# for every mode, so calling it here too would double every check.
|
|
601
|
+
backoff = self.config.poll_interval
|
|
602
|
+
while True:
|
|
603
|
+
# The sleep lives INSIDE the KeyboardInterrupt guard: with a 60s
|
|
604
|
+
# poll interval (up to 900s backing off) the loop spends nearly
|
|
605
|
+
# all its wall-clock sleeping, so a real Ctrl-C almost always
|
|
606
|
+
# lands there and must hit the same clean-exit path.
|
|
607
|
+
try:
|
|
608
|
+
try:
|
|
609
|
+
self.poll_once()
|
|
610
|
+
backoff = self.config.poll_interval
|
|
611
|
+
except RateLimited as exc:
|
|
612
|
+
backoff = min(backoff * 2, 900)
|
|
613
|
+
log.warning("rate limited (%s) — backing off %ds", exc, backoff)
|
|
614
|
+
except CommandError as exc:
|
|
615
|
+
backoff = min(backoff * 2, 900)
|
|
616
|
+
log.error("poll failed: %s — retrying in %ds", exc, backoff)
|
|
617
|
+
time.sleep(backoff)
|
|
618
|
+
except KeyboardInterrupt:
|
|
619
|
+
log.info("stopping")
|
|
620
|
+
return
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Subprocess helpers. Everything shells out to `gh` and `alissa` so both keep
|
|
2
|
+
their own auth handling; we never touch tokens ourselves."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
from typing import Sequence
|
|
11
|
+
|
|
12
|
+
log = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CommandError(RuntimeError):
|
|
16
|
+
def __init__(self, argv: Sequence[str], returncode: int, stderr: str):
|
|
17
|
+
self.argv = list(argv)
|
|
18
|
+
self.returncode = returncode
|
|
19
|
+
self.stderr = stderr
|
|
20
|
+
super().__init__(f"{argv[0]} exited {returncode}: {stderr.strip()[:400]}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def run(
|
|
24
|
+
argv: Sequence[str],
|
|
25
|
+
*,
|
|
26
|
+
timeout: int = 60,
|
|
27
|
+
check: bool = True,
|
|
28
|
+
cwd: "str | os.PathLike[str] | None" = None,
|
|
29
|
+
) -> str:
|
|
30
|
+
"""Run a command, return stdout. Never uses shell=True."""
|
|
31
|
+
log.debug("exec: %s", " ".join(argv))
|
|
32
|
+
try:
|
|
33
|
+
proc = subprocess.run(
|
|
34
|
+
list(argv),
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
cwd=str(cwd) if cwd is not None else None,
|
|
39
|
+
)
|
|
40
|
+
except subprocess.TimeoutExpired as exc:
|
|
41
|
+
raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
|
|
42
|
+
|
|
43
|
+
if check and proc.returncode != 0:
|
|
44
|
+
raise CommandError(argv, proc.returncode, proc.stderr)
|
|
45
|
+
return proc.stdout
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_json(argv: Sequence[str], *, timeout: int = 60):
|
|
49
|
+
"""Run a command whose stdout is JSON."""
|
|
50
|
+
out = run(argv, timeout=timeout).strip()
|
|
51
|
+
if not out:
|
|
52
|
+
return None
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(out)
|
|
55
|
+
except json.JSONDecodeError as exc:
|
|
56
|
+
raise CommandError(argv, 0, f"expected JSON, got: {out[:300]}") from exc
|