alissa-tools-github-devloop 0.7.0__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.7.0/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.0}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/__main__.py +32 -1
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/config.py +51 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/ghclient.py +173 -10
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/loop.py +1066 -22
- alissa_tools_github_devloop-0.8.0/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/sources.py +2 -2
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.7.0/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/README.md +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/setup.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.7.0 → 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.7.0 → 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.7.0 → alissa_tools_github_devloop-0.8.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -191,6 +191,32 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
191
191
|
"wedge self-heal fires; 0 = use stale_minutes (the default)",
|
|
192
192
|
)
|
|
193
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
|
+
|
|
194
220
|
claims = over.add_mutually_exclusive_group()
|
|
195
221
|
claims.add_argument(
|
|
196
222
|
"--activity-claims",
|
|
@@ -249,6 +275,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
249
275
|
"resume_enabled": args.resume_enabled,
|
|
250
276
|
"rerequest_enabled": args.rerequest_enabled,
|
|
251
277
|
"rerequest_grace_minutes": args.rerequest_grace_minutes,
|
|
278
|
+
"orphan_sweep_enabled": args.orphan_sweep_enabled,
|
|
279
|
+
"orphan_grace_minutes": args.orphan_grace_minutes,
|
|
252
280
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
253
281
|
"dry_run": args.dry_run,
|
|
254
282
|
}
|
|
@@ -282,7 +310,8 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
282
310
|
log.info(
|
|
283
311
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
284
312
|
"max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
|
|
285
|
-
"rerequest=%s (grace %s min);
|
|
313
|
+
"rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
|
|
314
|
+
"activity_claims=%s",
|
|
286
315
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
287
316
|
config.stale_minutes,
|
|
288
317
|
config.max_sessions or "unlimited",
|
|
@@ -291,6 +320,8 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
291
320
|
config.resume_enabled,
|
|
292
321
|
config.rerequest_enabled,
|
|
293
322
|
config.rerequest_grace_seconds // 60,
|
|
323
|
+
config.orphan_sweep_enabled,
|
|
324
|
+
config.orphan_grace_seconds // 60,
|
|
294
325
|
config.activity_claims_enabled,
|
|
295
326
|
)
|
|
296
327
|
log.debug("hub_template: %s", config.hub_template)
|
|
@@ -65,6 +65,8 @@ CONFIG_KEYS = (
|
|
|
65
65
|
"resume_enabled",
|
|
66
66
|
"rerequest_enabled",
|
|
67
67
|
"rerequest_grace_minutes",
|
|
68
|
+
"orphan_sweep_enabled",
|
|
69
|
+
"orphan_grace_minutes",
|
|
68
70
|
"activity_claims_enabled",
|
|
69
71
|
"dry_run",
|
|
70
72
|
)
|
|
@@ -207,6 +209,30 @@ class Config:
|
|
|
207
209
|
# positive value to widen or narrow it independently.
|
|
208
210
|
rerequest_grace_minutes: int = 0
|
|
209
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
|
+
|
|
210
236
|
# Cross-instance spawn dedupe (issue #53): before spawning on any edge,
|
|
211
237
|
# read the trigger artifact's mechanical activity comment and treat a
|
|
212
238
|
# spawn line younger than stale_minutes as an in-flight claim EVEN when
|
|
@@ -254,6 +280,15 @@ class Config:
|
|
|
254
280
|
the re-request instantly on a default config."""
|
|
255
281
|
return (self.rerequest_grace_minutes or self.stale_minutes) * 60
|
|
256
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
|
+
|
|
257
292
|
@property
|
|
258
293
|
def manifest_path(self) -> Path:
|
|
259
294
|
return self.workspace_root / "alissa-workspace.yaml"
|
|
@@ -366,6 +401,18 @@ class Config:
|
|
|
366
401
|
f"got {grace}"
|
|
367
402
|
)
|
|
368
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
|
+
|
|
369
416
|
interval = int(raw.get("poll_interval", cls.poll_interval))
|
|
370
417
|
if interval < MIN_POLL_INTERVAL:
|
|
371
418
|
raise ValueError(
|
|
@@ -402,6 +449,10 @@ class Config:
|
|
|
402
449
|
raw.get("rerequest_enabled", cls.rerequest_enabled)
|
|
403
450
|
),
|
|
404
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,
|
|
405
456
|
activity_claims_enabled=bool(
|
|
406
457
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
407
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:
|
|
@@ -205,6 +214,27 @@ class ReviewRequestRejected(RuntimeError):
|
|
|
205
214
|
plain retry."""
|
|
206
215
|
|
|
207
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
|
+
|
|
208
238
|
class AssignmentRejected(RuntimeError):
|
|
209
239
|
"""`assign_self` returned 2xx but the login is not on the issue.
|
|
210
240
|
|
|
@@ -381,19 +411,27 @@ class GitHub:
|
|
|
381
411
|
out.append((parts[-2], parts[-1], number, assignees))
|
|
382
412
|
return out
|
|
383
413
|
|
|
384
|
-
def search_prs(
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
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.
|
|
388
419
|
|
|
389
420
|
`author:@me` resolves server-side from the gh token (the same token
|
|
390
421
|
verify_identity() pins at startup), so the daemon only ever considers
|
|
391
422
|
its own PRs -- the developer identity is the author of every PR its
|
|
392
|
-
spawned sessions push.
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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.
|
|
397
435
|
|
|
398
436
|
Fails closed on an empty allowlist for the same reason search_issues
|
|
399
437
|
does: an unscoped query would return every PR the token ever authored
|
|
@@ -410,7 +448,11 @@ class GitHub:
|
|
|
410
448
|
)
|
|
411
449
|
return []
|
|
412
450
|
|
|
413
|
-
|
|
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"
|
|
414
456
|
for full_name in repos:
|
|
415
457
|
query += f" repo:{full_name}"
|
|
416
458
|
|
|
@@ -605,6 +647,7 @@ class GitHub:
|
|
|
605
647
|
# `or ""`: GitHub sends null for an empty description.
|
|
606
648
|
body=data.get("body") or "",
|
|
607
649
|
requested_reviewers=requested,
|
|
650
|
+
node_id=data.get("node_id") or "",
|
|
608
651
|
)
|
|
609
652
|
|
|
610
653
|
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|
|
@@ -672,6 +715,126 @@ class GitHub:
|
|
|
672
715
|
committer = data.get("committer") or {}
|
|
673
716
|
return parse_github_timestamp(committer.get("date"))
|
|
674
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
|
+
|
|
675
838
|
def request_review(self, owner: str, repo: str, number: int, reviewer: str) -> None:
|
|
676
839
|
"""Request review from ONE login on a pull request.
|
|
677
840
|
|