alissa-tools-github-reviewloop 0.9.0__tar.gz → 0.11.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.9.0/src/main/alissa_tools_github_reviewloop.egg-info → alissa_tools_github_reviewloop-0.11.0}/PKG-INFO +1 -1
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/config.py +2 -2
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/ghclient.py +40 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/loop.py +92 -3
- alissa_tools_github_reviewloop-0.11.0/src/main/alissa/tools/github/reviewloop/version +1 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0/src/main/alissa_tools_github_reviewloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_reviewloop-0.9.0/src/main/alissa/tools/github/reviewloop/version +0 -1
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/MANIFEST.in +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/README.md +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/requirements.txt +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/setup.cfg +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/setup.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/__init__.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/__main__.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/alissa.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/proc.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/prreview.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/state.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa/tools/github/reviewloop/version.py +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa_tools_github_reviewloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa_tools_github_reviewloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa_tools_github_reviewloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.0}/src/main/alissa_tools_github_reviewloop.egg-info/top_level.txt +0 -0
|
@@ -63,7 +63,7 @@ class Config:
|
|
|
63
63
|
|
|
64
64
|
hub_template: str = "{root}/{repo}/main"
|
|
65
65
|
poll_interval: int = 60
|
|
66
|
-
round_cap: int =
|
|
66
|
+
round_cap: int = 10 # CR9 default
|
|
67
67
|
|
|
68
68
|
# Empty tuple means "every repo that requests a review from me".
|
|
69
69
|
repos: tuple[str, ...] = ()
|
|
@@ -158,7 +158,7 @@ class Config:
|
|
|
158
158
|
"whatever repo requests a review is unbounded"
|
|
159
159
|
)
|
|
160
160
|
|
|
161
|
-
cap = int(raw.get("round_cap",
|
|
161
|
+
cap = int(raw.get("round_cap", cls.round_cap))
|
|
162
162
|
if cap < 1:
|
|
163
163
|
raise ValueError(f"round_cap must be >= 1, got {cap}")
|
|
164
164
|
|
|
@@ -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
|
+
)
|
|
@@ -123,12 +123,38 @@ STALLED_COMMENT = (
|
|
|
123
123
|
"finish the round by hand."
|
|
124
124
|
)
|
|
125
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
|
+
|
|
126
138
|
# The ping-ledger kind prefix for the stalled-deferral operator ping. Unlike
|
|
127
139
|
# the cap-out escalation (terminal per head), a stall can recur, so the kind
|
|
128
140
|
# is narrowed per episode -- see stalled_kind.
|
|
129
141
|
ESCALATION_STALLED = "stalled"
|
|
130
142
|
|
|
131
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
|
+
|
|
132
158
|
def stalled_kind(session: str) -> str:
|
|
133
159
|
"""The ping-ledger kind that dedupes ONE deferral episode's operator ping.
|
|
134
160
|
|
|
@@ -252,7 +278,7 @@ class ReviewWatcher:
|
|
|
252
278
|
age / 60,
|
|
253
279
|
)
|
|
254
280
|
|
|
255
|
-
return self._spawn(pr, round_, task)
|
|
281
|
+
return self._spawn(pr, round_, task, reenqueued=age is not None)
|
|
256
282
|
|
|
257
283
|
def _defer_stale_round(self, pr: PullRequest, round_: int, age: float) -> Decision | None:
|
|
258
284
|
"""The liveness signal under the stale timer: a deferral, or None to
|
|
@@ -310,10 +336,24 @@ class ReviewWatcher:
|
|
|
310
336
|
and not self.state.pinged(pr.full_name, pr.number, stalled_kind(session))
|
|
311
337
|
):
|
|
312
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
|
+
|
|
313
353
|
return Decision(
|
|
314
354
|
Action.IN_FLIGHT,
|
|
315
355
|
f"round {round_} is stale ({int(age / 60)} min) but session "
|
|
316
|
-
f"{session} is still {
|
|
356
|
+
f"{session} is still {life} — not "
|
|
317
357
|
f"respawning over a live reviewer",
|
|
318
358
|
round_,
|
|
319
359
|
)
|
|
@@ -495,7 +535,9 @@ class ReviewWatcher:
|
|
|
495
535
|
|
|
496
536
|
# -- actions -----------------------------------------------------------
|
|
497
537
|
|
|
498
|
-
def _spawn(
|
|
538
|
+
def _spawn(
|
|
539
|
+
self, pr: PullRequest, round_: int, task: Task | None, *, reenqueued: bool = False
|
|
540
|
+
) -> Decision:
|
|
499
541
|
if task is None:
|
|
500
542
|
if self.config.on_missing_review_task == ON_MISSING_SKIP:
|
|
501
543
|
return Decision(
|
|
@@ -543,6 +585,13 @@ class ReviewWatcher:
|
|
|
543
585
|
task_ref=task.ref if task else None,
|
|
544
586
|
)
|
|
545
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
|
+
|
|
546
595
|
return Decision(
|
|
547
596
|
Action.SPAWNED,
|
|
548
597
|
f"session {name} → {task.ref if task else 'no task'}",
|
|
@@ -621,6 +670,46 @@ class ReviewWatcher:
|
|
|
621
670
|
|
|
622
671
|
return warnings
|
|
623
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
|
+
|
|
624
713
|
def _escalate_stalled(
|
|
625
714
|
self, pr: PullRequest, round_: int, session: str, age: float
|
|
626
715
|
) -> None:
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.11.0
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
0.9.0
|
|
File without changes
|
|
File without changes
|
{alissa_tools_github_reviewloop-0.9.0 → alissa_tools_github_reviewloop-0.11.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
|