alissa-tools-github-devloop 0.6.3__tar.gz → 0.8.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.6.3/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.0}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/__main__.py +61 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/config.py +100 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/ghclient.py +275 -10
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/loop.py +1817 -48
- alissa_tools_github_devloop-0.8.0/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/sources.py +2 -2
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.6.3/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/README.md +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/setup.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -165,6 +165,58 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
165
165
|
"never auto-resume — a dead-with-PR lane waits for an operator",
|
|
166
166
|
)
|
|
167
167
|
|
|
168
|
+
rerequest = over.add_mutually_exclusive_group()
|
|
169
|
+
rerequest.add_argument(
|
|
170
|
+
"--rerequest",
|
|
171
|
+
dest="rerequest_enabled",
|
|
172
|
+
action="store_true",
|
|
173
|
+
default=None,
|
|
174
|
+
help="self-heal a PR whose fix session pushed and then died before "
|
|
175
|
+
"re-requesting review: once no worker session for it is listed and "
|
|
176
|
+
"the head has been public past the grace window, re-request review "
|
|
177
|
+
"mechanically, once per PR/round/head (the default)",
|
|
178
|
+
)
|
|
179
|
+
rerequest.add_argument(
|
|
180
|
+
"--no-rerequest",
|
|
181
|
+
dest="rerequest_enabled",
|
|
182
|
+
action="store_false",
|
|
183
|
+
help="never re-request review mechanically — a push whose session "
|
|
184
|
+
"died before its terminal act waits for an operator",
|
|
185
|
+
)
|
|
186
|
+
over.add_argument(
|
|
187
|
+
"--rerequest-grace-minutes",
|
|
188
|
+
type=int,
|
|
189
|
+
metavar="MINUTES",
|
|
190
|
+
help="how long the pushed head must have been public before the "
|
|
191
|
+
"wedge self-heal fires; 0 = use stale_minutes (the default)",
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
orphan = over.add_mutually_exclusive_group()
|
|
195
|
+
orphan.add_argument(
|
|
196
|
+
"--orphan-sweep",
|
|
197
|
+
dest="orphan_sweep_enabled",
|
|
198
|
+
action="store_true",
|
|
199
|
+
default=None,
|
|
200
|
+
help="complete the handoff of a DRAFT PR whose session died before "
|
|
201
|
+
"readying it: once no worker session for it is listed and its head "
|
|
202
|
+
"has been public past the grace window, mark it ready and request the "
|
|
203
|
+
"configured reviewer(s), once per PR/head (the default)",
|
|
204
|
+
)
|
|
205
|
+
orphan.add_argument(
|
|
206
|
+
"--no-orphan-sweep",
|
|
207
|
+
dest="orphan_sweep_enabled",
|
|
208
|
+
action="store_false",
|
|
209
|
+
help="never complete a draft's handoff mechanically — an unhanded-off "
|
|
210
|
+
"draft waits for an operator (also narrows the PR search to non-drafts)",
|
|
211
|
+
)
|
|
212
|
+
over.add_argument(
|
|
213
|
+
"--orphan-grace-minutes",
|
|
214
|
+
type=int,
|
|
215
|
+
metavar="MINUTES",
|
|
216
|
+
help="how long the draft's head must have been public before the "
|
|
217
|
+
"orphan sweep fires; 0 = use stale_minutes (the default)",
|
|
218
|
+
)
|
|
219
|
+
|
|
168
220
|
claims = over.add_mutually_exclusive_group()
|
|
169
221
|
claims.add_argument(
|
|
170
222
|
"--activity-claims",
|
|
@@ -221,6 +273,10 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
221
273
|
"maintain_label": args.maintain_label,
|
|
222
274
|
"maintain_enabled": args.maintain_enabled,
|
|
223
275
|
"resume_enabled": args.resume_enabled,
|
|
276
|
+
"rerequest_enabled": args.rerequest_enabled,
|
|
277
|
+
"rerequest_grace_minutes": args.rerequest_grace_minutes,
|
|
278
|
+
"orphan_sweep_enabled": args.orphan_sweep_enabled,
|
|
279
|
+
"orphan_grace_minutes": args.orphan_grace_minutes,
|
|
224
280
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
225
281
|
"dry_run": args.dry_run,
|
|
226
282
|
}
|
|
@@ -254,6 +310,7 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
254
310
|
log.info(
|
|
255
311
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
256
312
|
"max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
|
|
313
|
+
"rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
|
|
257
314
|
"activity_claims=%s",
|
|
258
315
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
259
316
|
config.stale_minutes,
|
|
@@ -261,6 +318,10 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
261
318
|
config.fix_rounds_enabled,
|
|
262
319
|
config.maintain_enabled, config.maintain_label,
|
|
263
320
|
config.resume_enabled,
|
|
321
|
+
config.rerequest_enabled,
|
|
322
|
+
config.rerequest_grace_seconds // 60,
|
|
323
|
+
config.orphan_sweep_enabled,
|
|
324
|
+
config.orphan_grace_seconds // 60,
|
|
264
325
|
config.activity_claims_enabled,
|
|
265
326
|
)
|
|
266
327
|
log.debug("hub_template: %s", config.hub_template)
|
|
@@ -63,6 +63,10 @@ CONFIG_KEYS = (
|
|
|
63
63
|
"maintain_label",
|
|
64
64
|
"maintain_enabled",
|
|
65
65
|
"resume_enabled",
|
|
66
|
+
"rerequest_enabled",
|
|
67
|
+
"rerequest_grace_minutes",
|
|
68
|
+
"orphan_sweep_enabled",
|
|
69
|
+
"orphan_grace_minutes",
|
|
66
70
|
"activity_claims_enabled",
|
|
67
71
|
"dry_run",
|
|
68
72
|
)
|
|
@@ -181,6 +185,54 @@ class Config:
|
|
|
181
185
|
# operator who wants a dead-with-PR lane to wait for a human.
|
|
182
186
|
resume_enabled: bool = True
|
|
183
187
|
|
|
188
|
+
# The wedge self-heal (issue #61): the fix edge's crash-recovery net under
|
|
189
|
+
# FIX_DIRECTIVE's terminal step. A fix session that posted its triage
|
|
190
|
+
# replies and PUSHED, then died before re-requesting review, leaves the PR
|
|
191
|
+
# in a state NEITHER daemon can exit -- revloop only sees PRs with a review
|
|
192
|
+
# requested from its identity, and the fix edge reads the push as "an
|
|
193
|
+
# implementer is already active". Rather than spawn a session, the daemon
|
|
194
|
+
# completes the dead one's protocol itself: it re-requests review from the
|
|
195
|
+
# reviewer whose verdict is being answered, exactly once per (PR, round,
|
|
196
|
+
# head). Default ON -- it closes a 3+ hour operator-only wedge (observed on
|
|
197
|
+
# fahera-mx/studio.alissa.app#242, 2026-07-27), it re-requests rather than
|
|
198
|
+
# spawns (so it can never double-drive a branch), and every side effect
|
|
199
|
+
# still honors dry_run. false restores the legacy read: the push closes the
|
|
200
|
+
# edge and a human re-requests.
|
|
201
|
+
rerequest_enabled: bool = True
|
|
202
|
+
|
|
203
|
+
# How long the pushed head must have been public before the self-heal
|
|
204
|
+
# fires. A live session re-requests SECONDS after its push, so firing on
|
|
205
|
+
# sight would race it; the window is what turns "a push exists" into "a
|
|
206
|
+
# push exists and nobody followed it up". 0 means "the fix-session stale
|
|
207
|
+
# window" (stale_minutes) -- the same clock every other presumed-dead
|
|
208
|
+
# decision on this edge is made on, and the documented default. Set a
|
|
209
|
+
# positive value to widen or narrow it independently.
|
|
210
|
+
rerequest_grace_minutes: int = 0
|
|
211
|
+
|
|
212
|
+
# The orphan-draft sweep (issue #68), the ready-flip sibling of the wedge
|
|
213
|
+
# self-heal above. That one completes a dead session's terminal act on a
|
|
214
|
+
# PR already in review; this one completes the act that PUTS a PR in
|
|
215
|
+
# review. A developer session's handoff is `gh pr ready` then a review
|
|
216
|
+
# request, and a session that died before both leaves a DRAFT nothing owns:
|
|
217
|
+
# the reviewer daemon sees only PRs with a request on them, the fix edge
|
|
218
|
+
# refuses drafts outright, and the issue-edge resume keys off an OPEN
|
|
219
|
+
# labeled issue -- which a replayed or post-merge draft does not have
|
|
220
|
+
# (observed on fahera-mx/studio.alissa.app#279: found by an operator, by
|
|
221
|
+
# accident). With this on, a draft that is provably ownerless is readied
|
|
222
|
+
# and review-requested mechanically, at most once per (PR, head). Default
|
|
223
|
+
# ON, on the same grounds as rerequest_enabled: it spawns nothing, every
|
|
224
|
+
# gate defers on absent evidence, and dry_run still suppresses every side
|
|
225
|
+
# effect. false skips the sweep AND narrows the PR search back to
|
|
226
|
+
# non-drafts, restoring the previous cost profile exactly.
|
|
227
|
+
orphan_sweep_enabled: bool = True
|
|
228
|
+
|
|
229
|
+
# How long the draft's head must have been public before the sweep fires --
|
|
230
|
+
# rerequest_grace_minutes' twin, and load-bearing for the same reason: a
|
|
231
|
+
# live session opens its draft and hands it off minutes later, so firing on
|
|
232
|
+
# sight would race the very session that is about to do the job. 0 means
|
|
233
|
+
# "use stale_minutes", the documented default.
|
|
234
|
+
orphan_grace_minutes: int = 0
|
|
235
|
+
|
|
184
236
|
# Cross-instance spawn dedupe (issue #53): before spawning on any edge,
|
|
185
237
|
# read the trigger artifact's mechanical activity comment and treat a
|
|
186
238
|
# spawn line younger than stale_minutes as an in-flight claim EVEN when
|
|
@@ -219,6 +271,24 @@ class Config:
|
|
|
219
271
|
return default_state_path(self.workspace_root)
|
|
220
272
|
return Path(self.state_path).expanduser()
|
|
221
273
|
|
|
274
|
+
@property
|
|
275
|
+
def rerequest_grace_seconds(self) -> int:
|
|
276
|
+
"""How long a pushed head must have been public before the wedge
|
|
277
|
+
self-heal fires, in seconds. Read this, never the raw field: 0 is the
|
|
278
|
+
sentinel for "the fix-session stale window", so the fallback has to
|
|
279
|
+
live in ONE place or a caller reading the field directly would fire
|
|
280
|
+
the re-request instantly on a default config."""
|
|
281
|
+
return (self.rerequest_grace_minutes or self.stale_minutes) * 60
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def orphan_grace_seconds(self) -> int:
|
|
285
|
+
"""How long a draft's head must have been public before the orphan
|
|
286
|
+
sweep fires, in seconds. Read this, never the raw field --
|
|
287
|
+
`rerequest_grace_seconds`' contract verbatim: 0 is the sentinel for
|
|
288
|
+
"the stale window", and a caller reading the field directly would
|
|
289
|
+
ready-and-request a draft opened seconds ago on a default config."""
|
|
290
|
+
return (self.orphan_grace_minutes or self.stale_minutes) * 60
|
|
291
|
+
|
|
222
292
|
@property
|
|
223
293
|
def manifest_path(self) -> Path:
|
|
224
294
|
return self.workspace_root / "alissa-workspace.yaml"
|
|
@@ -321,6 +391,28 @@ class Config:
|
|
|
321
391
|
f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
|
|
322
392
|
)
|
|
323
393
|
|
|
394
|
+
# 0 is the sentinel for "fall back to stale_minutes" (the documented
|
|
395
|
+
# default), so the floor is 0 like max_sessions' -- a negative grace
|
|
396
|
+
# would make the self-heal fire on a head pushed in the future.
|
|
397
|
+
grace = int(raw.get("rerequest_grace_minutes", cls.rerequest_grace_minutes))
|
|
398
|
+
if grace < 0:
|
|
399
|
+
raise ValueError(
|
|
400
|
+
f"rerequest_grace_minutes must be >= 0 (0 = use stale_minutes), "
|
|
401
|
+
f"got {grace}"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# The orphan sweep's own grace, validated exactly like the one above --
|
|
405
|
+
# same 0 sentinel, same reason a negative value is the only invalid
|
|
406
|
+
# one (it would fire on a draft pushed in the future).
|
|
407
|
+
orphan_grace = int(
|
|
408
|
+
raw.get("orphan_grace_minutes", cls.orphan_grace_minutes)
|
|
409
|
+
)
|
|
410
|
+
if orphan_grace < 0:
|
|
411
|
+
raise ValueError(
|
|
412
|
+
f"orphan_grace_minutes must be >= 0 (0 = use stale_minutes), "
|
|
413
|
+
f"got {orphan_grace}"
|
|
414
|
+
)
|
|
415
|
+
|
|
324
416
|
interval = int(raw.get("poll_interval", cls.poll_interval))
|
|
325
417
|
if interval < MIN_POLL_INTERVAL:
|
|
326
418
|
raise ValueError(
|
|
@@ -353,6 +445,14 @@ class Config:
|
|
|
353
445
|
resume_enabled=bool(
|
|
354
446
|
raw.get("resume_enabled", cls.resume_enabled)
|
|
355
447
|
),
|
|
448
|
+
rerequest_enabled=bool(
|
|
449
|
+
raw.get("rerequest_enabled", cls.rerequest_enabled)
|
|
450
|
+
),
|
|
451
|
+
rerequest_grace_minutes=grace,
|
|
452
|
+
orphan_sweep_enabled=bool(
|
|
453
|
+
raw.get("orphan_sweep_enabled", cls.orphan_sweep_enabled)
|
|
454
|
+
),
|
|
455
|
+
orphan_grace_minutes=orphan_grace,
|
|
356
456
|
activity_claims_enabled=bool(
|
|
357
457
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
358
458
|
),
|
|
@@ -119,6 +119,15 @@ class PullRequest:
|
|
|
119
119
|
# review-response edge is closed. GitHub clears an entry when that
|
|
120
120
|
# reviewer submits and re-adds it on re-request.
|
|
121
121
|
requested_reviewers: tuple[str, ...]
|
|
122
|
+
# The GraphQL node id, carried ONLY for `mark_ready`: REST cannot take a
|
|
123
|
+
# pull request out of draft at all (there is no `draft` field on the PATCH
|
|
124
|
+
# endpoint), so the orphan sweep's ready flip has to go through GraphQL's
|
|
125
|
+
# `markPullRequestReadyForReview`, which addresses PRs by node id. It rides
|
|
126
|
+
# along on the `pull_request()` fetch every caller already pays rather than
|
|
127
|
+
# costing a lookup of its own. Defaulted so the many test fakes and the
|
|
128
|
+
# other three edges -- none of which flip drafts -- need not supply it;
|
|
129
|
+
# `mark_ready` refuses an empty one rather than POSTing a nonsense mutation.
|
|
130
|
+
node_id: str = ""
|
|
122
131
|
|
|
123
132
|
@property
|
|
124
133
|
def full_name(self) -> str:
|
|
@@ -176,6 +185,56 @@ class IdentityMismatch(RuntimeError):
|
|
|
176
185
|
"""Configured developer identity disagrees with the gh token."""
|
|
177
186
|
|
|
178
187
|
|
|
188
|
+
class ReviewRequestRejected(RuntimeError):
|
|
189
|
+
"""`request_review` returned 2xx but the reviewer is not on the PR.
|
|
190
|
+
|
|
191
|
+
The `requested_reviewers` endpoint answers with the (possibly unchanged)
|
|
192
|
+
pull request, so a request GitHub declined to honour -- a reviewer without
|
|
193
|
+
read access, a login that has since been renamed, an author who cannot
|
|
194
|
+
request themselves -- can come back 2xx with nothing added. The mechanical
|
|
195
|
+
re-request leans on that field being TRUE afterwards (it is the whole
|
|
196
|
+
remedy: the reviewer daemon's only entry point is a
|
|
197
|
+
`review-requested:@me` search), so the response is checked exactly as
|
|
198
|
+
`assign_self` checks its own.
|
|
199
|
+
|
|
200
|
+
The caller's contract is escalate-don't-retry, the same one
|
|
201
|
+
AssignmentRejected gets on the issue edge and for the same reason: every
|
|
202
|
+
cause above is PERMANENT and operator-fixable, so retrying achieves
|
|
203
|
+
nothing but write traffic against an endpoint GitHub has already refused.
|
|
204
|
+
`loop.DevWatcher._escalate_rerequest_rejected` pages the operator on the
|
|
205
|
+
PR once and records `loop.rerequest_rejected_kind`, which short-circuits
|
|
206
|
+
the next poll before any probe is paid for; a NEW head is a new key and
|
|
207
|
+
gets a fresh attempt.
|
|
208
|
+
|
|
209
|
+
What still separates this from AssignmentRejected is the OTHER ledger row
|
|
210
|
+
it must not write: a refused re-request is never recorded as DONE
|
|
211
|
+
(`loop.rerequest_kind`). The remedy did not happen, and a row saying it
|
|
212
|
+
did would re-wedge the PR permanently with nothing left that can see it.
|
|
213
|
+
Distinct from a transient CommandError on the same call, which keeps the
|
|
214
|
+
plain retry."""
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
class ReadyForReviewRejected(RuntimeError):
|
|
218
|
+
"""`mark_ready` completed but the pull request is still a draft.
|
|
219
|
+
|
|
220
|
+
The draft→ready flip is the FIRST half of the orphan sweep's remedy and the
|
|
221
|
+
half the second depends on: a reviewer requested on a PR that is still a
|
|
222
|
+
draft is not in review, so a flip that silently did not happen would leave
|
|
223
|
+
the sweep recording a handoff that never occurred.
|
|
224
|
+
|
|
225
|
+
GraphQL's `markPullRequestReadyForReview` answers with the pull request
|
|
226
|
+
itself, so the flip is verified exactly as `assign_self` and
|
|
227
|
+
`request_review` verify theirs. It can decline for reasons no retry
|
|
228
|
+
changes -- a PR that is not a draft to begin with is fine (the mutation is
|
|
229
|
+
idempotent and reports `isDraft: false`), but a node id that resolves to
|
|
230
|
+
something else, or a token without write access on the repo, is not.
|
|
231
|
+
|
|
232
|
+
Caller contract: escalate-don't-retry, `ReviewRequestRejected`'s doctrine
|
|
233
|
+
verbatim, and for the same reason -- the daemon must not POST into a
|
|
234
|
+
permanent refusal every poll. The DONE marker is never recorded on this
|
|
235
|
+
path: the handoff did not happen."""
|
|
236
|
+
|
|
237
|
+
|
|
179
238
|
class AssignmentRejected(RuntimeError):
|
|
180
239
|
"""`assign_self` returned 2xx but the login is not on the issue.
|
|
181
240
|
|
|
@@ -352,19 +411,27 @@ class GitHub:
|
|
|
352
411
|
out.append((parts[-2], parts[-1], number, assignees))
|
|
353
412
|
return out
|
|
354
413
|
|
|
355
|
-
def search_prs(
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
the
|
|
414
|
+
def search_prs(
|
|
415
|
+
self, repos: tuple[str, ...] = (), include_drafts: bool = False
|
|
416
|
+
) -> list[tuple[str, str, int]]:
|
|
417
|
+
"""Open PRs AUTHORED by the token identity across the watched repos, as
|
|
418
|
+
`(owner, repo, number)` hits -- the candidate set of the PR-side edges.
|
|
359
419
|
|
|
360
420
|
`author:@me` resolves server-side from the gh token (the same token
|
|
361
421
|
verify_identity() pins at startup), so the daemon only ever considers
|
|
362
422
|
its own PRs -- the developer identity is the author of every PR its
|
|
363
|
-
spawned sessions push.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
423
|
+
spawned sessions push.
|
|
424
|
+
|
|
425
|
+
`include_drafts` widens the query by dropping `draft:false`. Off (the
|
|
426
|
+
default) it keeps the daemon's own fresh draft PRs -- the pre-review
|
|
427
|
+
norm -- from costing a pull_request() re-fetch every poll, which is the
|
|
428
|
+
only reason that qualifier was ever there; the review-response edge
|
|
429
|
+
re-checks `draft` anyway, since a PR can flip back to draft between
|
|
430
|
+
search and fetch (same belt-and-braces as reviewloop's review_requests
|
|
431
|
+
+ CR1 re-check). The orphan sweep turns it ON because a draft is
|
|
432
|
+
precisely its subject, and the re-fetch it costs is not avoidable
|
|
433
|
+
overhead there: draft-ness alone says nothing, and only the fetch can
|
|
434
|
+
show whether a reviewer is on the PR.
|
|
368
435
|
|
|
369
436
|
Fails closed on an empty allowlist for the same reason search_issues
|
|
370
437
|
does: an unscoped query would return every PR the token ever authored
|
|
@@ -381,7 +448,11 @@ class GitHub:
|
|
|
381
448
|
)
|
|
382
449
|
return []
|
|
383
450
|
|
|
384
|
-
|
|
451
|
+
# Assembled so the non-draft query is byte-identical to what this
|
|
452
|
+
# method has always sent -- the qualifier is dropped in place, not
|
|
453
|
+
# re-ordered around.
|
|
454
|
+
draft = "" if include_drafts else " draft:false"
|
|
455
|
+
query = f"is:pr is:open{draft} author:@me"
|
|
385
456
|
for full_name in repos:
|
|
386
457
|
query += f" repo:{full_name}"
|
|
387
458
|
|
|
@@ -576,6 +647,7 @@ class GitHub:
|
|
|
576
647
|
# `or ""`: GitHub sends null for an empty description.
|
|
577
648
|
body=data.get("body") or "",
|
|
578
649
|
requested_reviewers=requested,
|
|
650
|
+
node_id=data.get("node_id") or "",
|
|
579
651
|
)
|
|
580
652
|
|
|
581
653
|
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
@@ -609,6 +681,199 @@ class GitHub:
|
|
|
609
681
|
if isinstance(r, dict)
|
|
610
682
|
]
|
|
611
683
|
|
|
684
|
+
def commit_time(self, owner: str, repo: str, sha: str) -> "int | None":
|
|
685
|
+
"""A commit's COMMITTER date as unix seconds, or None when it cannot
|
|
686
|
+
be read -- the grace window's clock behind the mechanical re-request.
|
|
687
|
+
|
|
688
|
+
GitHub's REST API exposes no "when was this ref pushed" field on a pull
|
|
689
|
+
request, and plain pushes raise no timeline event (only force-pushes
|
|
690
|
+
do), so the head commit's committer date is the cheapest instant
|
|
691
|
+
available. It is a LOWER BOUND on the push: a commit created locally
|
|
692
|
+
and pushed minutes later stamps the earlier moment, so an age measured
|
|
693
|
+
from it over-estimates how long the head has been public and the grace
|
|
694
|
+
window can expire marginally early. For the sessions this daemon
|
|
695
|
+
spawns, commit and push are seconds apart (the directives push
|
|
696
|
+
immediately after committing), and the caller tightens the bound
|
|
697
|
+
further by taking the later of this and the review the push answers --
|
|
698
|
+
the push necessarily postdates that review.
|
|
699
|
+
|
|
700
|
+
The GIT-OBJECT endpoint, not `repos/:o/:r/commits/:sha`: the latter
|
|
701
|
+
ships the commit's full file list and patches (up to 300 files) to
|
|
702
|
+
deliver one date, while `git/commits/:sha` returns the object alone --
|
|
703
|
+
a few hundred bytes whatever the commit's size, with the date one
|
|
704
|
+
level shallower. Immaterial on the steady-state path (the done-marker
|
|
705
|
+
short-circuits ahead of this call once the remedy has fired) and not
|
|
706
|
+
immaterial on the transient-retry path, where a poll that keeps
|
|
707
|
+
failing would otherwise re-fetch a possibly-large merge commit every
|
|
708
|
+
cycle.
|
|
709
|
+
|
|
710
|
+
None on anything unreadable (no such commit, a malformed payload, a
|
|
711
|
+
date GitHub did not spell in UTC `Z`): the caller treats an unknown
|
|
712
|
+
push instant as "cannot prove the grace has passed" and defers, which
|
|
713
|
+
is the fail-safe direction for a call that ends in a mutation."""
|
|
714
|
+
data = self._api(f"repos/{owner}/{repo}/git/commits/{sha}") or {}
|
|
715
|
+
committer = data.get("committer") or {}
|
|
716
|
+
return parse_github_timestamp(committer.get("date"))
|
|
717
|
+
|
|
718
|
+
_READY_MUTATION = (
|
|
719
|
+
"mutation($id:ID!){markPullRequestReadyForReview(input:{pullRequestId:"
|
|
720
|
+
"$id}){pullRequest{isDraft}}}"
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
def mark_ready(self, pr: PullRequest) -> None:
|
|
724
|
+
"""Take a draft pull request out of draft -- `gh pr ready`'s API call,
|
|
725
|
+
performed by the daemon when the session that owed it died first.
|
|
726
|
+
|
|
727
|
+
GraphQL, not REST, and not by choice: the REST pulls endpoint has no
|
|
728
|
+
writable `draft` field, so `markPullRequestReadyForReview` is the only
|
|
729
|
+
way to flip it. That mutation addresses the PR by NODE ID, which is why
|
|
730
|
+
`PullRequest` carries one.
|
|
731
|
+
|
|
732
|
+
Takes the whole PullRequest rather than owner/repo/number because the
|
|
733
|
+
node id is the only address the mutation accepts and the record is
|
|
734
|
+
where it lives -- passing the parts would mean every caller re-deriving
|
|
735
|
+
an id it already holds.
|
|
736
|
+
|
|
737
|
+
The mutation is idempotent: run against a PR that is already ready it
|
|
738
|
+
succeeds and reports `isDraft: false`, which is exactly the state the
|
|
739
|
+
caller wants, so a retry after a lost response is safe.
|
|
740
|
+
|
|
741
|
+
The response carries `isDraft`, so the flip is VERIFIED rather than
|
|
742
|
+
assumed (see ReadyForReviewRejected) -- the sweep's second half
|
|
743
|
+
(requesting a reviewer) is meaningless on a PR that is still a draft,
|
|
744
|
+
and a handoff recorded on an unflipped PR would be a lie the ledger
|
|
745
|
+
then refuses to revisit. An empty node id is refused before the call:
|
|
746
|
+
it is unaddressable, and GraphQL would answer a confusing type error
|
|
747
|
+
rather than a useful one.
|
|
748
|
+
|
|
749
|
+
GraphQL errors make `gh` exit non-zero, so they arrive as CommandError
|
|
750
|
+
(transient to the caller) or RateLimited, exactly like every REST call
|
|
751
|
+
here."""
|
|
752
|
+
if not pr.node_id:
|
|
753
|
+
raise ReadyForReviewRejected(
|
|
754
|
+
f"{pr.pr_slug}: no GraphQL node id on the pull request record, "
|
|
755
|
+
f"so the draft→ready flip cannot be addressed -- refusing to "
|
|
756
|
+
f"call markPullRequestReadyForReview without one."
|
|
757
|
+
)
|
|
758
|
+
data = self._api(
|
|
759
|
+
"graphql",
|
|
760
|
+
"-f",
|
|
761
|
+
f"query={self._READY_MUTATION}",
|
|
762
|
+
"-f",
|
|
763
|
+
f"id={pr.node_id}",
|
|
764
|
+
)
|
|
765
|
+
payload = (
|
|
766
|
+
((data or {}).get("data") or {}).get("markPullRequestReadyForReview")
|
|
767
|
+
or {}
|
|
768
|
+
)
|
|
769
|
+
still_draft = (payload.get("pullRequest") or {}).get("isDraft")
|
|
770
|
+
# `is not False`, not `if still_draft`: a missing or malformed payload
|
|
771
|
+
# yields None, and "the response did not say it worked" must read as a
|
|
772
|
+
# refusal, never as success.
|
|
773
|
+
if still_draft is not False:
|
|
774
|
+
raise ReadyForReviewRejected(
|
|
775
|
+
f"{pr.pr_slug}: markPullRequestReadyForReview returned "
|
|
776
|
+
f"isDraft={still_draft!r} -- the draft→ready flip did not land, "
|
|
777
|
+
f"so the pull request is still out of review."
|
|
778
|
+
)
|
|
779
|
+
|
|
780
|
+
def request_reviews(
|
|
781
|
+
self, owner: str, repo: str, number: int, reviewers: "tuple[str, ...]"
|
|
782
|
+
) -> "tuple[str, ...]":
|
|
783
|
+
"""Request review from SEVERAL logins at once; returns the ones that
|
|
784
|
+
landed. `request_review`'s shape for the orphan sweep, which has no
|
|
785
|
+
single review author to name and must request the CONFIGURED reviewer
|
|
786
|
+
set instead.
|
|
787
|
+
|
|
788
|
+
One POST for the whole set (the endpoint takes a list), so a three-name
|
|
789
|
+
roster costs one call rather than three.
|
|
790
|
+
|
|
791
|
+
The success rule is deliberately WEAKER than `request_review`'s: this
|
|
792
|
+
raises only when NOTHING landed. The remedy's purpose is to make the PR
|
|
793
|
+
visible to the reviewer daemon's `review-requested:@me` search, and one
|
|
794
|
+
honoured request achieves that completely -- failing the whole handoff
|
|
795
|
+
because a second, mistyped reviewer was dropped would re-orphan a PR
|
|
796
|
+
that is, in fact, now in review. Names GitHub silently dropped are
|
|
797
|
+
returned-by-omission so the caller can log them; only an empty landing
|
|
798
|
+
set is a rejection.
|
|
799
|
+
|
|
800
|
+
Casefolded like `request_review`'s check, because GitHub logins are
|
|
801
|
+
case-insensitive while the reviewer set is operator-typed."""
|
|
802
|
+
wanted = tuple(r for r in reviewers if r and r.strip())
|
|
803
|
+
if not wanted:
|
|
804
|
+
raise ReviewRequestRejected(
|
|
805
|
+
f"{owner}/{repo}#{number}: no reviewers to request -- the "
|
|
806
|
+
f"configured reviewer set is empty, so no review request can "
|
|
807
|
+
f"be made."
|
|
808
|
+
)
|
|
809
|
+
payload = json.dumps({"reviewers": list(wanted)})
|
|
810
|
+
fd, body_path = tempfile.mkstemp(prefix="devloop-orphan-", suffix=".json")
|
|
811
|
+
try:
|
|
812
|
+
with os.fdopen(fd, "w") as handle:
|
|
813
|
+
handle.write(payload)
|
|
814
|
+
data = self._api(
|
|
815
|
+
"-X",
|
|
816
|
+
"POST",
|
|
817
|
+
f"repos/{owner}/{repo}/pulls/{number}/requested_reviewers",
|
|
818
|
+
"--input",
|
|
819
|
+
body_path,
|
|
820
|
+
)
|
|
821
|
+
finally:
|
|
822
|
+
os.unlink(body_path)
|
|
823
|
+
|
|
824
|
+
on_pr = {
|
|
825
|
+
(r.get("login") or "").casefold()
|
|
826
|
+
for r in ((data or {}).get("requested_reviewers") or [])
|
|
827
|
+
}
|
|
828
|
+
landed = tuple(r for r in wanted if r.casefold() in on_pr)
|
|
829
|
+
if not landed:
|
|
830
|
+
raise ReviewRequestRejected(
|
|
831
|
+
f"{owner}/{repo}#{number}: GitHub accepted the request but none "
|
|
832
|
+
f"of {wanted!r} are among the requested reviewers -- the review "
|
|
833
|
+
f"request did not land, so the reviewer daemon still cannot see "
|
|
834
|
+
f"this PR."
|
|
835
|
+
)
|
|
836
|
+
return landed
|
|
837
|
+
|
|
838
|
+
def request_review(self, owner: str, repo: str, number: int, reviewer: str) -> None:
|
|
839
|
+
"""Request review from ONE login on a pull request.
|
|
840
|
+
|
|
841
|
+
The API call behind a fix session's terminal step (`gh pr edit
|
|
842
|
+
--add-reviewer`), performed by the daemon itself when the session that
|
|
843
|
+
owed it died first. `--input` rather than `-f reviewers[]=...` for the
|
|
844
|
+
same reason `assign_self` uses it: gh 2.4.0 predates the array syntax
|
|
845
|
+
and the endpoint wants `{"reviewers": [...]}`.
|
|
846
|
+
|
|
847
|
+
The response is the pull request, so the request is verified to have
|
|
848
|
+
LANDED (see ReviewRequestRejected) -- casefolded, because GitHub logins
|
|
849
|
+
are case-insensitive while the reviewer set is operator-typed."""
|
|
850
|
+
payload = json.dumps({"reviewers": [reviewer]})
|
|
851
|
+
fd, body_path = tempfile.mkstemp(prefix="devloop-rereview-", suffix=".json")
|
|
852
|
+
try:
|
|
853
|
+
with os.fdopen(fd, "w") as handle:
|
|
854
|
+
handle.write(payload)
|
|
855
|
+
data = self._api(
|
|
856
|
+
"-X",
|
|
857
|
+
"POST",
|
|
858
|
+
f"repos/{owner}/{repo}/pulls/{number}/requested_reviewers",
|
|
859
|
+
"--input",
|
|
860
|
+
body_path,
|
|
861
|
+
)
|
|
862
|
+
finally:
|
|
863
|
+
os.unlink(body_path)
|
|
864
|
+
|
|
865
|
+
landed = tuple(
|
|
866
|
+
(r.get("login") or "")
|
|
867
|
+
for r in ((data or {}).get("requested_reviewers") or [])
|
|
868
|
+
)
|
|
869
|
+
if reviewer.casefold() not in {name.casefold() for name in landed}:
|
|
870
|
+
raise ReviewRequestRejected(
|
|
871
|
+
f"{owner}/{repo}#{number}: GitHub accepted the request but "
|
|
872
|
+
f"{reviewer!r} is not among the requested reviewers {landed!r} "
|
|
873
|
+
f"-- the review request did not land, so the reviewer daemon "
|
|
874
|
+
f"still cannot see this PR."
|
|
875
|
+
)
|
|
876
|
+
|
|
612
877
|
def issue(self, owner: str, repo: str, number: int) -> Issue:
|
|
613
878
|
# `or {}`: run_json returns None on empty stdout, and the siblings
|
|
614
879
|
# (search_issues, assign_self) already guard the same way.
|