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.
Files changed (29) hide show
  1. {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
  2. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/__main__.py +61 -0
  3. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/config.py +100 -0
  4. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/ghclient.py +275 -10
  5. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/loop.py +1817 -48
  6. alissa_tools_github_devloop-0.8.0/src/main/alissa/tools/github/devloop/version +1 -0
  7. {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
  8. {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
  9. alissa_tools_github_devloop-0.6.3/src/main/alissa/tools/github/devloop/version +0 -1
  10. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/MANIFEST.in +0 -0
  11. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/README.md +0 -0
  12. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/requirements.txt +0 -0
  13. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/setup.cfg +0 -0
  14. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/setup.py +0 -0
  15. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  16. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  17. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  18. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/state.py +0 -0
  19. {alissa_tools_github_devloop-0.6.3 → alissa_tools_github_devloop-0.8.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
  20. {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
  21. {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
  22. {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
  23. {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
  24. {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
  25. {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
  26. {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
  27. {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
  28. {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
  29. {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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-devloop
3
- Version: 0.6.3
3
+ Version: 0.8.0
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -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(self, repos: tuple[str, ...] = ()) -> list[tuple[str, str, int]]:
356
- """Open non-draft PRs AUTHORED by the token identity across the
357
- watched repos, as `(owner, repo, number)` hits -- the candidate set of
358
- the review-response edge.
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. `draft:false` keeps the daemon's own fresh
364
- draft PRs (the pre-review norm) from costing a pull_request() re-fetch
365
- every poll; evaluate_pr() re-checks `draft` anyway, since a PR can
366
- flip back to draft between search and fetch (same belt-and-braces as
367
- reviewloop's review_requests + CR1 re-check).
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
- query = "is:pr is:open draft:false author:@me"
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.