alissa-tools-github-reviewloop 0.8.0__tar.gz → 0.10.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_reviewloop-0.8.0/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.10.0}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +40 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/loop.py +254 -4
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/state.py +34 -0
- alissa_tools_github_reviewloop-0.10.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_reviewloop-0.8.0/src/main/alissa/tools/github/reviewloop/version +0 -1
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/setup.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/alissa.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/config.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +0 -0
|
@@ -46,6 +46,17 @@ class PullRequest:
|
|
|
46
46
|
return self.merged or self.state != "open"
|
|
47
47
|
|
|
48
48
|
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class IssueComment:
|
|
51
|
+
"""An issue comment on a PR (a PR is an issue). Distinct from Review:
|
|
52
|
+
issue comments live on the issues endpoints and never create review
|
|
53
|
+
records, so posting one can never disturb round counting."""
|
|
54
|
+
|
|
55
|
+
id: int
|
|
56
|
+
author: str
|
|
57
|
+
body: str
|
|
58
|
+
|
|
59
|
+
|
|
49
60
|
@dataclass(frozen=True)
|
|
50
61
|
class Review:
|
|
51
62
|
author: str
|
|
@@ -221,3 +232,32 @@ class GitHub:
|
|
|
221
232
|
f"body={body}",
|
|
222
233
|
]
|
|
223
234
|
)
|
|
235
|
+
|
|
236
|
+
def issue_comments(self, owner: str, repo: str, number: int) -> list[IssueComment]:
|
|
237
|
+
data = (
|
|
238
|
+
self._api(
|
|
239
|
+
"-X",
|
|
240
|
+
"GET",
|
|
241
|
+
f"repos/{owner}/{repo}/issues/{number}/comments",
|
|
242
|
+
"-f",
|
|
243
|
+
"per_page=100",
|
|
244
|
+
)
|
|
245
|
+
or []
|
|
246
|
+
)
|
|
247
|
+
return [
|
|
248
|
+
IssueComment(
|
|
249
|
+
id=int(c.get("id") or 0),
|
|
250
|
+
author=(c.get("user") or {}).get("login", ""),
|
|
251
|
+
body=c.get("body") or "",
|
|
252
|
+
)
|
|
253
|
+
for c in data
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
def update_comment(self, owner: str, repo: str, comment_id: int, body: str) -> None:
|
|
257
|
+
self._api(
|
|
258
|
+
"-X",
|
|
259
|
+
"PATCH",
|
|
260
|
+
f"repos/{owner}/{repo}/issues/comments/{comment_id}",
|
|
261
|
+
"-f",
|
|
262
|
+
f"body={body}",
|
|
263
|
+
)
|
|
@@ -27,9 +27,25 @@ from .state import State
|
|
|
27
27
|
log = logging.getLogger(__name__)
|
|
28
28
|
|
|
29
29
|
# A reviewer session that has not submitted after this long is presumed dead
|
|
30
|
-
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued
|
|
30
|
+
# (skill failure mode: "reviewer session stalls"). The round is re-enqueued --
|
|
31
|
+
# but only with a second signal agreeing: the timer alone cannot tell a dead
|
|
32
|
+
# session from a slow one, and a timer-only re-enqueue double-spends the round
|
|
33
|
+
# (two sessions review it, both submit -- observed live twice: double round-2
|
|
34
|
+
# approves on devloop's PR #11, double approves on this repo's PR #19). See
|
|
35
|
+
# _defer_stale_round for the liveness signal.
|
|
31
36
|
STALE_ROUND_SECONDS = 90 * 60
|
|
32
37
|
|
|
38
|
+
# The floor under the liveness deferral: a live session defers the stale
|
|
39
|
+
# respawn indefinitely -- correct for a genuinely slow round, silent forever
|
|
40
|
+
# for a session that is wedged but still registers tmux activity. Once the
|
|
41
|
+
# newest spawn's age reaches this multiple of STALE_ROUND_SECONDS, the loop
|
|
42
|
+
# posts one "stalled" operator comment per deferral episode (stalled_kind)
|
|
43
|
+
# and keeps deferring. 2 means the deferral itself has lasted a full extra
|
|
44
|
+
# stale window beyond the point the timer first fired -- long enough that a
|
|
45
|
+
# healthy round has almost always submitted by then, early enough that a
|
|
46
|
+
# wedged one surfaces the same day.
|
|
47
|
+
STALLED_DEFER_MULTIPLE = 2
|
|
48
|
+
|
|
33
49
|
# The sweep only reaps a session that has been idle AND quiet this long. The
|
|
34
50
|
# GitHub review count increments the moment a review is submitted, but the
|
|
35
51
|
# reviewer still has close-out work after that (CR6 envelope, task status) --
|
|
@@ -96,6 +112,64 @@ ESCALATION_COMMENT = (
|
|
|
96
112
|
"Last verdict: `{last_state}` at `{sha}`."
|
|
97
113
|
)
|
|
98
114
|
|
|
115
|
+
STALLED_COMMENT = (
|
|
116
|
+
"**Review round stalled?** — round {round} has been in flight {minutes} min "
|
|
117
|
+
"(stale window: {stale} min), but its reviewer session `{session}` still "
|
|
118
|
+
"shows signs of life, so the daemon keeps deferring the respawn — "
|
|
119
|
+
"respawning over a live session double-spends the round: two reviewers "
|
|
120
|
+
"work it, both submit. Is that session actually making progress? Operator "
|
|
121
|
+
"options: inspect it (`alissa tmux ls`) and, if it is wedged, kill it "
|
|
122
|
+
"(`alissa tmux kill {session}`) so the respawn proceeds next poll, or "
|
|
123
|
+
"finish the round by hand."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# The hidden marker that identifies THE activity comment on a PR. Find-or-create
|
|
127
|
+
# keys on it (plus own authorship -- anyone can paste the marker into their own
|
|
128
|
+
# comment, and a spoofed marker must never be PATCHed), so every append lands in
|
|
129
|
+
# the same single comment however many rounds run.
|
|
130
|
+
ACTIVITY_MARKER = "<!-- alissa-reviewloop:activity -->"
|
|
131
|
+
|
|
132
|
+
ACTIVITY_HEADER = (
|
|
133
|
+
ACTIVITY_MARKER + "\n"
|
|
134
|
+
"**Review-loop activity** — mechanical spawn/round log; the daemon appends "
|
|
135
|
+
"a line each time it queues (or defers) a reviewer round on this PR."
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# The ping-ledger kind prefix for the stalled-deferral operator ping. Unlike
|
|
139
|
+
# the cap-out escalation (terminal per head), a stall can recur, so the kind
|
|
140
|
+
# is narrowed per episode -- see stalled_kind.
|
|
141
|
+
ESCALATION_STALLED = "stalled"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def deferral_activity_kind(session: str) -> str:
|
|
145
|
+
"""The ping-ledger kind that dedupes ONE deferral episode's activity line.
|
|
146
|
+
|
|
147
|
+
A deferral is re-decided every poll, so appending per decision would grow
|
|
148
|
+
the activity comment by one identical line a minute for as long as the
|
|
149
|
+
session holds out. One line per episode carries the same information --
|
|
150
|
+
"the daemon is deferring behind this session" -- and the session name is
|
|
151
|
+
the episode identity (nonce-unique per spawn), exactly as stalled_kind
|
|
152
|
+
reasons for the operator ping. Recorded only after the append lands, so a
|
|
153
|
+
transient failure retries next poll.
|
|
154
|
+
"""
|
|
155
|
+
return f"activity-deferred:{session}"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def stalled_kind(session: str) -> str:
|
|
159
|
+
"""The ping-ledger kind that dedupes ONE deferral episode's operator ping.
|
|
160
|
+
|
|
161
|
+
Devloop's stalled_kind reasoning, transposed: a stall can recur -- every
|
|
162
|
+
spawn of every round can wedge mid-flight, and episode k's ping must not
|
|
163
|
+
silence episode k+1's. Keyed on the bare kind (or even on the round), the
|
|
164
|
+
re-enqueue of a round that wedges AGAIN would defer silently forever. The
|
|
165
|
+
session name already IS the episode identity -- nonce-unique per spawn
|
|
166
|
+
(see session_name) -- so it folds into the key. Delivery contract: the
|
|
167
|
+
ledger row lands only AFTER the comment posts (see _escalate_stalled), so
|
|
168
|
+
a transient comment failure retries next poll and the ping lands exactly
|
|
169
|
+
once per episode.
|
|
170
|
+
"""
|
|
171
|
+
return f"{ESCALATION_STALLED}:{session}"
|
|
172
|
+
|
|
99
173
|
|
|
100
174
|
class Action(str, Enum):
|
|
101
175
|
SPAWNED = "spawned"
|
|
@@ -192,15 +266,97 @@ class ReviewWatcher:
|
|
|
192
266
|
if age is not None and age < STALE_ROUND_SECONDS:
|
|
193
267
|
return Decision(Action.IN_FLIGHT, f"round {round_} enqueued {int(age)}s ago", round_)
|
|
194
268
|
if age is not None:
|
|
269
|
+
deferred = self._defer_stale_round(pr, round_, age)
|
|
270
|
+
if deferred is not None:
|
|
271
|
+
return deferred
|
|
195
272
|
log.warning(
|
|
196
273
|
"%s round %d has been in flight %.0f min with no submitted review "
|
|
197
|
-
"— re-enqueuing (reviewer
|
|
274
|
+
"and its session is gone or finished — re-enqueuing (reviewer "
|
|
275
|
+
"session presumed dead)",
|
|
198
276
|
pr.slug,
|
|
199
277
|
round_,
|
|
200
278
|
age / 60,
|
|
201
279
|
)
|
|
202
280
|
|
|
203
|
-
return self._spawn(pr, round_, task)
|
|
281
|
+
return self._spawn(pr, round_, task, reenqueued=age is not None)
|
|
282
|
+
|
|
283
|
+
def _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
|
|
284
|
+
"""The liveness signal under the stale timer: a deferral, or None to
|
|
285
|
+
respawn.
|
|
286
|
+
|
|
287
|
+
Staleness needs TWO signals, not one: the ledger timer says the
|
|
288
|
+
newest spawn is old, but elapsed time alone cannot tell a dead
|
|
289
|
+
session from a slow one -- a thorough round can outlast
|
|
290
|
+
STALE_ROUND_SECONDS, and a timer-only re-enqueue respawns a reviewer
|
|
291
|
+
over the still-working first one; two sessions review the same
|
|
292
|
+
round and both submit. So before respawning, consult external
|
|
293
|
+
evidence of life: the round's recorded session in the live list
|
|
294
|
+
(the reap sweep's own probe). Busy, or idle without a real quiet
|
|
295
|
+
period (mid-close-out between turns; see REAP_QUIET_SECONDS) -> the
|
|
296
|
+
round is alive, defer with a reason. Gone, or idle-finished -> dead,
|
|
297
|
+
respawn (the sweep separately handles any corpse). An unprobeable
|
|
298
|
+
live list defers too: respawning on missing evidence is exactly the
|
|
299
|
+
double-spend, and the probe retries next poll.
|
|
300
|
+
|
|
301
|
+
The deferral is floored, not unbounded: past STALLED_DEFER_MULTIPLE
|
|
302
|
+
stale windows with the session still alive, one operator ping per
|
|
303
|
+
deferral episode (stalled_kind) -- then keep deferring. This method
|
|
304
|
+
never respawns over a live session; only the operator killing the
|
|
305
|
+
session (or it finishing/dying) unblocks the respawn.
|
|
306
|
+
"""
|
|
307
|
+
row = self.state.get_spawn(pr.full_name, pr.number, round_)
|
|
308
|
+
if row is None: # age came from this row; belt and braces
|
|
309
|
+
return None
|
|
310
|
+
session = row["session"]
|
|
311
|
+
try:
|
|
312
|
+
live = {s.name: s for s in self.alissa.list_review_sessions()}
|
|
313
|
+
except CommandError as exc:
|
|
314
|
+
log.warning(
|
|
315
|
+
"%s round %d is stale but the session list is unavailable (%s) "
|
|
316
|
+
"— deferring the respawn rather than risking a double-spawned "
|
|
317
|
+
"round; the probe retries next poll",
|
|
318
|
+
pr.slug,
|
|
319
|
+
round_,
|
|
320
|
+
exc,
|
|
321
|
+
)
|
|
322
|
+
return Decision(
|
|
323
|
+
Action.IN_FLIGHT,
|
|
324
|
+
f"round {round_} is stale but liveness is unprobeable — deferring",
|
|
325
|
+
round_,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
ses = live.get(session)
|
|
329
|
+
if ses is None:
|
|
330
|
+
return None # session gone -> presumed dead -> respawn
|
|
331
|
+
if ses.is_idle and time.time() - ses.last_activity >= REAP_QUIET_SECONDS:
|
|
332
|
+
return None # idle-finished: it died without submitting -> respawn
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
age >= STALLED_DEFER_MULTIPLE * STALE_ROUND_SECONDS
|
|
336
|
+
and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
|
|
337
|
+
):
|
|
338
|
+
self._escalate_stalled(pr, round_, session, age)
|
|
339
|
+
|
|
340
|
+
life = "active" if ses.is_idle else "busy"
|
|
341
|
+
if not self.state.pinged(pr.full_name, pr.number, deferral_activity_kind(session)):
|
|
342
|
+
appended = self._append_activity(
|
|
343
|
+
pr,
|
|
344
|
+
self._activity_line(
|
|
345
|
+
session, round_, f"deferred — session `{session}` still {life}"
|
|
346
|
+
),
|
|
347
|
+
)
|
|
348
|
+
if appended:
|
|
349
|
+
self.state.record_ping(
|
|
350
|
+
pr.full_name, pr.number, deferral_activity_kind(session)
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return Decision(
|
|
354
|
+
Action.IN_FLIGHT,
|
|
355
|
+
f"round {round_} is stale ({int(age / 60)} min) but session "
|
|
356
|
+
f"{session} is still {life} — not "
|
|
357
|
+
f"respawning over a live reviewer",
|
|
358
|
+
round_,
|
|
359
|
+
)
|
|
204
360
|
|
|
205
361
|
def _convergence_reason(
|
|
206
362
|
self, my_reviews: list[Review], task: Task | None, head_sha: str
|
|
@@ -379,7 +535,9 @@ class ReviewWatcher:
|
|
|
379
535
|
|
|
380
536
|
# -- actions -----------------------------------------------------------
|
|
381
537
|
|
|
382
|
-
def _spawn(
|
|
538
|
+
def _spawn(
|
|
539
|
+
self, pr: PullRequest, round_: int, task: Task | None, *, reenqueued: bool = False
|
|
540
|
+
) -> Decision:
|
|
383
541
|
if task is None:
|
|
384
542
|
if self.config.on_missing_review_task == ON_MISSING_SKIP:
|
|
385
543
|
return Decision(
|
|
@@ -427,6 +585,13 @@ class ReviewWatcher:
|
|
|
427
585
|
task_ref=task.ref if task else None,
|
|
428
586
|
)
|
|
429
587
|
|
|
588
|
+
# AFTER the enqueue on purpose: the activity comment is telemetry and
|
|
589
|
+
# must never gate the spawn it reports on.
|
|
590
|
+
context = (
|
|
591
|
+
"re-enqueued — previous session presumed dead" if reenqueued else "spawned"
|
|
592
|
+
)
|
|
593
|
+
self._append_activity(pr, self._activity_line(name, round_, context))
|
|
594
|
+
|
|
430
595
|
return Decision(
|
|
431
596
|
Action.SPAWNED,
|
|
432
597
|
f"session {name} → {task.ref if task else 'no task'}",
|
|
@@ -505,6 +670,91 @@ class ReviewWatcher:
|
|
|
505
670
|
|
|
506
671
|
return warnings
|
|
507
672
|
|
|
673
|
+
def _activity_line(self, session: str, round_: int, context: str) -> str:
|
|
674
|
+
ts = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
|
675
|
+
return f"- {ts} — `{session}` — round {round_} of {self.config.round_cap} — {context}"
|
|
676
|
+
|
|
677
|
+
def _append_activity(self, pr: PullRequest, line: str) -> bool:
|
|
678
|
+
"""Append one line to THE activity comment on the PR; True if it landed.
|
|
679
|
+
|
|
680
|
+
Find-or-create: the PR's issue comments are filtered to OWN authorship
|
|
681
|
+
AND the hidden marker, and the line is PATCH-appended to the first
|
|
682
|
+
match; with no match, one marker-carrying comment is created. A marker
|
|
683
|
+
pasted by anyone else fails the author filter and is never touched.
|
|
684
|
+
|
|
685
|
+
Best-effort by contract: this is telemetry about a spawn that has
|
|
686
|
+
already happened, so no failure here may surface to the caller. The
|
|
687
|
+
except is deliberately broad -- _api turns rate-limit errors into
|
|
688
|
+
RateLimited (not CommandError), and letting that fly out of evaluate()
|
|
689
|
+
would fail the whole poll pass over a log line.
|
|
690
|
+
"""
|
|
691
|
+
if self.config.dry_run:
|
|
692
|
+
log.info("[dry-run] would append activity line on %s: %s", pr.slug, line)
|
|
693
|
+
return False
|
|
694
|
+
try:
|
|
695
|
+
mine = [
|
|
696
|
+
c
|
|
697
|
+
for c in self.github.issue_comments(pr.owner, pr.repo, pr.number)
|
|
698
|
+
if c.author == self.github.login and ACTIVITY_MARKER in c.body
|
|
699
|
+
]
|
|
700
|
+
if mine:
|
|
701
|
+
self.github.update_comment(
|
|
702
|
+
pr.owner, pr.repo, mine[0].id, mine[0].body + "\n" + line
|
|
703
|
+
)
|
|
704
|
+
else:
|
|
705
|
+
self.github.comment(
|
|
706
|
+
pr.owner, pr.repo, pr.number, ACTIVITY_HEADER + "\n" + line
|
|
707
|
+
)
|
|
708
|
+
except Exception as exc:
|
|
709
|
+
log.warning("could not append activity line on %s: %s", pr.slug, exc)
|
|
710
|
+
return False
|
|
711
|
+
return True
|
|
712
|
+
|
|
713
|
+
def _escalate_stalled(
|
|
714
|
+
self, pr: PullRequest, round_: int, session: str, age: float
|
|
715
|
+
) -> None:
|
|
716
|
+
"""Operator ping when the liveness deferral itself runs long: the
|
|
717
|
+
session showing life is the only thing holding the respawn back, so
|
|
718
|
+
a human must check whether it is progressing or wedged. Posted once
|
|
719
|
+
per deferral EPISODE (the ping ledger row is keyed
|
|
720
|
+
stalled_kind(session); a re-enqueued round that stalls again is a
|
|
721
|
+
new session, so it pings again), and the row is recorded only AFTER
|
|
722
|
+
the comment posts: this ping is the operator's only signal for the
|
|
723
|
+
episode, so a transient comment failure must retry next poll --
|
|
724
|
+
exactly-once delivered, unlike the cap-out page (a terminal state,
|
|
725
|
+
recorded despite failure). The decision stays a deferral either way
|
|
726
|
+
-- this comments, it never respawns."""
|
|
727
|
+
body = STALLED_COMMENT.format(
|
|
728
|
+
round=round_,
|
|
729
|
+
minutes=int(age / 60),
|
|
730
|
+
stale=STALE_ROUND_SECONDS // 60,
|
|
731
|
+
session=session,
|
|
732
|
+
)
|
|
733
|
+
log.warning(
|
|
734
|
+
"STALLED %s round %d has been deferred %.0f min behind live session "
|
|
735
|
+
"%s — escalating to operator (once per episode)",
|
|
736
|
+
pr.slug,
|
|
737
|
+
round_,
|
|
738
|
+
age / 60,
|
|
739
|
+
session,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
if self.config.dry_run:
|
|
743
|
+
log.info("[dry-run] would comment on %s:\n%s", pr.slug, body)
|
|
744
|
+
return
|
|
745
|
+
|
|
746
|
+
try:
|
|
747
|
+
self.github.comment(pr.owner, pr.repo, pr.number, body)
|
|
748
|
+
except CommandError as exc:
|
|
749
|
+
log.error(
|
|
750
|
+
"could not post the stalled-round comment on %s: %s — not "
|
|
751
|
+
"recording the episode; the ping retries next poll",
|
|
752
|
+
pr.slug,
|
|
753
|
+
exc,
|
|
754
|
+
)
|
|
755
|
+
return
|
|
756
|
+
self.state.record_ping(pr.full_name, pr.number, stalled_kind(session))
|
|
757
|
+
|
|
508
758
|
def _escalate(self, pr: PullRequest, last_state: str, rounds: int) -> None:
|
|
509
759
|
body = ESCALATION_COMMENT.format(
|
|
510
760
|
rounds=rounds, last_state=last_state.lower(), sha=pr.head_sha[:8]
|
|
@@ -44,6 +44,14 @@ CREATE TABLE IF NOT EXISTS reaps (
|
|
|
44
44
|
session TEXT NOT NULL PRIMARY KEY,
|
|
45
45
|
reaped_at INTEGER NOT NULL
|
|
46
46
|
);
|
|
47
|
+
|
|
48
|
+
CREATE TABLE IF NOT EXISTS pings (
|
|
49
|
+
repo TEXT NOT NULL,
|
|
50
|
+
number INTEGER NOT NULL,
|
|
51
|
+
kind TEXT NOT NULL,
|
|
52
|
+
pinged_at INTEGER NOT NULL,
|
|
53
|
+
PRIMARY KEY (repo, number, kind)
|
|
54
|
+
);
|
|
47
55
|
"""
|
|
48
56
|
|
|
49
57
|
|
|
@@ -168,6 +176,32 @@ class State:
|
|
|
168
176
|
)
|
|
169
177
|
self._db.commit()
|
|
170
178
|
|
|
179
|
+
def pinged(self, repo: str, number: int, kind: str) -> bool:
|
|
180
|
+
"""Whether this KIND of operator ping already went out for the PR.
|
|
181
|
+
|
|
182
|
+
Kind is free-form TEXT (devloop's escalation-kind pattern): a caller
|
|
183
|
+
narrows a kind's dedupe scope by folding identity into the string --
|
|
184
|
+
e.g. one stalled ping per deferral episode, "stalled:<session>" (see
|
|
185
|
+
loop.stalled_kind). Kept apart from `escalations`, whose key is
|
|
186
|
+
(repo, number, head_sha) and whose rows page terminal states.
|
|
187
|
+
"""
|
|
188
|
+
row = self._db.execute(
|
|
189
|
+
"SELECT 1 FROM pings WHERE repo=? AND number=? AND kind=?",
|
|
190
|
+
(repo, number, kind),
|
|
191
|
+
).fetchone()
|
|
192
|
+
return row is not None
|
|
193
|
+
|
|
194
|
+
def record_ping(self, repo: str, number: int, kind: str) -> None:
|
|
195
|
+
"""Idempotent per kind: OR IGNORE keeps the FIRST ping's timestamp,
|
|
196
|
+
so `pinged_at` is an audit field for when the episode was first
|
|
197
|
+
raised, not the most recent re-raise."""
|
|
198
|
+
self._db.execute(
|
|
199
|
+
"INSERT OR IGNORE INTO pings (repo, number, kind, pinged_at) "
|
|
200
|
+
"VALUES (?,?,?,?)",
|
|
201
|
+
(repo, number, kind, int(time.time())),
|
|
202
|
+
)
|
|
203
|
+
self._db.commit()
|
|
204
|
+
|
|
171
205
|
def escalated(self, repo: str, number: int, head_sha: str) -> bool:
|
|
172
206
|
row = self._db.execute(
|
|
173
207
|
"SELECT 1 FROM escalations WHERE repo=? AND number=? AND head_sha=?",
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.10.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.8.0
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_reviewloop-0.8.0 → alissa_tools_github_reviewloop-0.10.0}/requirements.txt
RENAMED
|
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
|
|
File without changes
|