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