alissa-tools-github-revloop 0.16.4__tar.gz → 0.16.6__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 (32) hide show
  1. {alissa_tools_github_revloop-0.16.4/src/main/alissa_tools_github_revloop.egg-info → alissa_tools_github_revloop-0.16.6}/PKG-INFO +1 -1
  2. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/ghclient.py +65 -2
  3. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/loop.py +282 -0
  4. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/proc.py +11 -1
  5. alissa_tools_github_revloop-0.16.6/src/main/alissa/tools/github/revloop/version +1 -0
  6. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6/src/main/alissa_tools_github_revloop.egg-info}/PKG-INFO +1 -1
  7. alissa_tools_github_revloop-0.16.4/src/main/alissa/tools/github/revloop/version +0 -1
  8. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/LICENSE +0 -0
  9. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/MANIFEST.in +0 -0
  10. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/NOTICE +0 -0
  11. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/README.md +0 -0
  12. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/requirements.txt +0 -0
  13. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/setup.cfg +0 -0
  14. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/setup.py +0 -0
  15. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/__init__.py +0 -0
  16. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/__main__.py +0 -0
  17. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/alissa.py +0 -0
  18. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/config.py +0 -0
  19. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/prreview.py +0 -0
  20. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/state.py +0 -0
  21. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/version.py +0 -0
  22. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/webui/__init__.py +0 -0
  23. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/webui/__main__.py +0 -0
  24. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/webui/auth.py +0 -0
  25. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/webui/page.py +0 -0
  26. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/webui/server.py +0 -0
  27. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/webui/sources.py +0 -0
  28. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa/tools/github/revloop/webui/sysinfo.py +0 -0
  29. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa_tools_github_revloop.egg-info/SOURCES.txt +0 -0
  30. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa_tools_github_revloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa_tools_github_revloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_revloop-0.16.4 → alissa_tools_github_revloop-0.16.6}/src/main/alissa_tools_github_revloop.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.4
3
+ Version: 0.16.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -15,6 +15,7 @@ without it the client inherits, exactly as it always did.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import json
18
19
  import logging
19
20
  import os
20
21
  import re
@@ -96,6 +97,12 @@ class PullRequest:
96
97
  # "open" or "closed"; merged PRs report state "closed" AND merged True.
97
98
  state: str = "open"
98
99
  merged: bool = False
100
+ # Logins GitHub currently holds a pending review request against. Read off
101
+ # the same PR payload as everything else here, so knowing whether this
102
+ # daemon's own request is still dangling costs no extra call. USERS only:
103
+ # `requested_teams` is a separate field and is deliberately not carried,
104
+ # because nothing in the loop may ever withdraw a team's request.
105
+ requested_reviewers: tuple[str, ...] = ()
99
106
 
100
107
  @property
101
108
  def full_name(self) -> str:
@@ -331,9 +338,23 @@ class GitHub:
331
338
  )
332
339
  return actual
333
340
 
334
- def _api(self, *args: str, timeout: int = 60, forbidden_is_rate_limit: bool = True):
341
+ def _api(
342
+ self,
343
+ *args: str,
344
+ timeout: int = 60,
345
+ forbidden_is_rate_limit: bool = True,
346
+ body: "dict | None" = None,
347
+ ):
335
348
  """One `gh api` call, with GitHub's throttling mapped to RateLimited.
336
349
 
350
+ `body` is for requests whose payload must have a particular JSON SHAPE.
351
+ `gh`'s `-f` fields cannot express one portably: `-f 'k[]=v'` is encoded
352
+ as the array `{"k": ["v"]}` only by modern `gh`, and as a string field
353
+ NAMED `k[]` by the 2.4.0 this client targets -- silently, so the
354
+ request goes out well-formed and simply missing the key the endpoint
355
+ wants. Passing the body on stdin through `--input -` (which 2.4.0 does
356
+ support) takes every `gh` version out of the encoding decision.
357
+
337
358
  `forbidden_is_rate_limit` is about one ambiguity: GitHub answers a
338
359
  secondary rate limit with 403, so a bare "403" in stderr is read as
339
360
  throttling by default, and `run_forever` backs off. That default is
@@ -348,8 +369,13 @@ class GitHub:
348
369
  that pass False: an explicit throttling marker still raises
349
370
  RateLimited, and everything else stays a CommandError they can handle.
350
371
  """
372
+ argv = ["gh", "api", *args]
373
+ stdin = None
374
+ if body is not None:
375
+ argv += ["--input", "-"]
376
+ stdin = json.dumps(body)
351
377
  try:
352
- return run_json(["gh", "api", *args], timeout=timeout, env=self._env())
378
+ return run_json(argv, timeout=timeout, env=self._env(), stdin=stdin)
353
379
  except CommandError as exc:
354
380
  blob = exc.stderr.lower()
355
381
  throttled = any(marker in blob for marker in RATE_LIMIT_MARKERS)
@@ -402,6 +428,11 @@ class GitHub:
402
428
  url=data.get("html_url", ""),
403
429
  state=data.get("state") or "open",
404
430
  merged=bool(data.get("merged")),
431
+ requested_reviewers=tuple(
432
+ str((u or {}).get("login") or "")
433
+ for u in (data.get("requested_reviewers") or [])
434
+ if (u or {}).get("login")
435
+ ),
405
436
  )
406
437
 
407
438
  def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
@@ -547,6 +578,38 @@ class GitHub:
547
578
  data = self._api(*argv, forbidden_is_rate_limit=False) or {}
548
579
  return str(data.get("html_url") or "")
549
580
 
581
+ def remove_review_request(
582
+ self, owner: str, repo: str, number: int, login: str
583
+ ) -> None:
584
+ """Withdraw the pending review request held against ONE login.
585
+
586
+ The payload names exactly one reviewer, never the whole list: GitHub's
587
+ DELETE removes only what it is given, so a human reviewer or a second
588
+ bot sitting in the same `requested_reviewers` array is untouched. The
589
+ caller is the round close-out (loop._clear_own_review_request), and the
590
+ only login it ever passes is the daemon's own.
591
+
592
+ The body goes out through `--input` rather than `-f 'reviewers[]=…'`,
593
+ because that field syntax means different things on different `gh`
594
+ versions and this client targets 2.4.0, where it produces a string
595
+ field literally named `reviewers[]` -- no `reviewers` key, a 422, and a
596
+ feature that never worked. See `_api`.
597
+
598
+ `forbidden_is_rate_limit=False` for the same reason `submit_review`
599
+ passes it: a 403 here is this identity not being allowed to edit the
600
+ PR's reviewers, which is a fact about the deployment that its caller
601
+ logs and degrades on -- not a throttle worth backing the whole poll
602
+ pass off for. Note the permission is `pull_requests: write`, strictly
603
+ more than reviewing needs.
604
+ """
605
+ self._api(
606
+ "-X",
607
+ "DELETE",
608
+ f"repos/{owner}/{repo}/pulls/{number}/requested_reviewers",
609
+ body={"reviewers": [login]},
610
+ forbidden_is_rate_limit=False,
611
+ )
612
+
550
613
  def comment(self, owner: str, repo: str, number: int, body: str) -> None:
551
614
  run_json(
552
615
  [
@@ -399,6 +399,70 @@ def capout_kind(head_sha: str, granted: int) -> str:
399
399
  return f"capout:{head_sha}:{granted}"
400
400
 
401
401
 
402
+ def identity_drift_kind(requested: str, author: str) -> str:
403
+ """The ping-ledger kind that dedupes ONE request/author identity-drift warning.
404
+
405
+ Scope is once per PR per ordered login pair -- the ledger keys on
406
+ `(repo, number, kind)`, so folding the pair into the kind narrows the
407
+ dedupe WITHIN a PR and cannot lift it above one. What the pair buys is that
408
+ a drift which later becomes a DIFFERENT drift announces itself again
409
+ instead of staying quiet because this PR once warned about something else.
410
+
411
+ Durable rather than in-memory because it is a configuration alarm, not a
412
+ per-poll observation: a restart re-stating it on every open PR would be a
413
+ wall of noise. Per-PR is bounded in practice by the withdrawal itself --
414
+ once the request is gone the PR leaves the poll set, so a drifted
415
+ deployment emits one block per PR it closes a round on, not one per poll.
416
+ """
417
+ return f"reqdrift:{requested}->{author}"
418
+
419
+
420
+ def drift_probe_kind(head_sha: str) -> str:
421
+ """The ping-ledger kind that bounds the drift check's EXTRA read.
422
+
423
+ The check needs the unfiltered review list, which `my_reviews` cannot
424
+ supply -- a review by another identity is exactly what it filters out. That
425
+ is one more API call, and on a PR whose withdrawal fails permanently (a
426
+ reviewer identity without `pull_requests: write`) the close-out path runs
427
+ every poll, so "one extra call on the rare path" would quietly become one
428
+ per poll forever.
429
+
430
+ Keyed on the head so the probe re-runs when the code moves -- a new head
431
+ means a new round and a new chance for its verdict to land under the wrong
432
+ login -- and recorded only once the review list has actually been READ, so
433
+ an unreadable list retries instead of being settled by a failure.
434
+ """
435
+ return f"driftprobe:{head_sha}"
436
+
437
+
438
+ def withdraw_failed_kind(exc: BaseException) -> str:
439
+ """The ping-ledger kind that dedupes a failing review-request withdrawal.
440
+
441
+ Retrying a failed DELETE is right -- a 403 can be a permission grant away
442
+ from working. Re-LOGGING it every poll is not: the common failure is
443
+ permanent and per-PR, so at a 30s poll interval one such PR would emit
444
+ ~2,880 warning lines a day that never converge on anything.
445
+
446
+ Keyed on the exception class, not its message: a message carries the PR
447
+ and the moment, which would defeat the dedupe, while a 403 that later
448
+ becomes a 422 is a genuinely different condition and says so once.
449
+ """
450
+ return f"withdraw-failed:{type(exc).__name__}"
451
+
452
+
453
+ def withdrawn_kind(head_sha: str) -> str:
454
+ """The ping-ledger kind that marks a review request already withdrawn at
455
+ this head -- so a request that COMES BACK is distinguishable from the first
456
+ one.
457
+
458
+ Withdrawing is the daemon undoing a state change a human or an automation
459
+ made on GitHub. Doing it once at a head is routine close-out; doing it
460
+ again means somebody deliberately asked for another look and the daemon
461
+ took it away, which is worth a louder line than the first.
462
+ """
463
+ return f"withdrawn:{head_sha}"
464
+
465
+
402
466
  def _now() -> str:
403
467
  """The activity comment's timestamp format (UTC, seconds)."""
404
468
  return time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
@@ -580,6 +644,13 @@ class ReviewWatcher:
580
644
  # The surviving-session set the cap alarm last paged on, so a standing
581
645
  # over-cap condition pages once per episode -- see _check_session_cap.
582
646
  self._paged_cap: frozenset[str] | None = None
647
+ # (repo slug, number, drift kind) already announced by THIS process in
648
+ # dry-run. Deliberately in-memory: a dry-run pass must never be
649
+ # suppressed by anything durable (a production pass writing the ledger
650
+ # would silence the operator's diagnostic), but a daemon left in
651
+ # dry-run should not re-announce the same drift every poll either --
652
+ # see _warn_identity_drift.
653
+ self._dry_run_drift: set[tuple[str, int, str]] = set()
583
654
 
584
655
  # -- per-PR decision ---------------------------------------------------
585
656
 
@@ -646,6 +717,13 @@ class ReviewWatcher:
646
717
 
647
718
  converged = self._convergence_reason(my_reviews, task, pr.head_sha)
648
719
  if converged is not None:
720
+ # THE terminal branch: a verdict of record exists at the current
721
+ # head, so no round k+1 can be owed from here -- every path that
722
+ # could open one (head moved, non-approve verdict, cap re-entry
723
+ # ack) is either upstream of this return or unreachable past it.
724
+ # That is what makes it the only safe place to withdraw a dangling
725
+ # self review request; see _clear_own_review_request.
726
+ self._clear_own_review_request(pr, my_reviews, converged)
649
727
  return Decision(Action.CONVERGED, converged, completed)
650
728
 
651
729
  # The effective cap is the configured one plus every re-entry an
@@ -1290,6 +1368,210 @@ class ReviewWatcher:
1290
1368
 
1291
1369
  return None
1292
1370
 
1371
+ # -- round close-out ---------------------------------------------------
1372
+
1373
+ def _clear_own_review_request(
1374
+ self, pr: PullRequest, my_reviews: list[Review], why: str
1375
+ ) -> None:
1376
+ """Withdraw this daemon's own dangling review request on a closed round.
1377
+
1378
+ Normally there is nothing to do: GitHub consumes a review request the
1379
+ moment the requested identity submits a review, so a converged PR has
1380
+ no request left and drops straight out of `review_requests`. The
1381
+ request survives when the review that closed the round was NOT posted
1382
+ by the requested identity -- studio #298, where a ready-flip
1383
+ auto-requested `alissa-app` while the round ran under another login.
1384
+ Nothing then ever consumes it, so the PR stays in the search set and
1385
+ every poll pays a full re-verification (PR fetch, reviews, task,
1386
+ envelope count) to reach this same no-op. Removing the request is what
1387
+ ends that: the search is the evaluation set, so the PR leaves it.
1388
+
1389
+ Four properties this must not violate:
1390
+
1391
+ * Only ever OUR login. Human reviewers and any second bot in the same
1392
+ `requested_reviewers` array are somebody else's pending work, and a
1393
+ daemon that withdrew them would silently cancel real reviews.
1394
+ * Only from the terminal branch. The caller is the one return where a
1395
+ verdict of record stands at the current head and no further round can
1396
+ be owed; a request withdrawn while a round could still open would
1397
+ delete the very trigger that surfaces the PR.
1398
+ * Only on a HEAD-BOUND verdict. `_convergence_reason` cannot check a
1399
+ review that carries no `commit_id` and lets it converge anyway; that
1400
+ convergence is not about the current head, so withdrawing on it would
1401
+ take a PR out of the daemon's sight on a verdict that might be about
1402
+ old code. Such a review is not producible today -- `submit_review`
1403
+ always pins a commit and GitHub populates it -- but the terminal
1404
+ argument must hold on its own terms rather than by inheritance, so
1405
+ the check is repeated here.
1406
+ * Never blocking. The removal is a side effect of a decision already
1407
+ made, so any failure -- including a throttled one -- is logged and
1408
+ dropped rather than raised: the pass keeps walking and the next poll
1409
+ retries, which is strictly better than converting a no-op into an
1410
+ aborted pass. (Deliberately unlike the read paths, which let
1411
+ RateLimited reach run_forever's backoff.)
1412
+
1413
+ `my_reviews` is non-empty at every call site -- `_convergence_reason`
1414
+ returns None on an empty one -- so its newest entry IS the verdict of
1415
+ record, read positionally rather than defended against.
1416
+ """
1417
+ mine = self.github.login
1418
+ if mine not in pr.requested_reviewers:
1419
+ return
1420
+
1421
+ verdict = my_reviews[-1]
1422
+ if not verdict.commit_id:
1423
+ log.debug(
1424
+ "%s: not withdrawing the review request — the converged review "
1425
+ "%s carries no commit_id, so convergence is not head-bound",
1426
+ pr.slug, verdict.url or "with no url",
1427
+ )
1428
+ return
1429
+
1430
+ # Above the dry-run guard: the drift check reads and logs but takes
1431
+ # nothing on GitHub, and in dry-run it records nothing either -- it
1432
+ # OBSERVES the pass rather than acting in it. Dry-run is the mode an
1433
+ # operator reaches for to diagnose exactly this, so staying silent
1434
+ # about it there is the worst place. See _warn_identity_drift for why
1435
+ # the ledger is untouched in BOTH directions there.
1436
+ self._warn_identity_drift(pr)
1437
+
1438
+ if self.config.dry_run:
1439
+ log.info(
1440
+ "[dry-run] would withdraw the dangling review request for %s on "
1441
+ "%s — the round is closed (%s)",
1442
+ mine, pr.slug, why,
1443
+ )
1444
+ return
1445
+
1446
+ try:
1447
+ self.github.remove_review_request(pr.owner, pr.repo, pr.number, mine)
1448
+ except Exception as exc:
1449
+ kind = withdraw_failed_kind(exc)
1450
+ if self.state.pinged(pr.full_name, pr.number, kind):
1451
+ # Still retried, just not re-announced; see withdraw_failed_kind.
1452
+ log.debug(
1453
+ "%s: withdrawal still failing for %s (%s)", pr.slug, mine, exc
1454
+ )
1455
+ return
1456
+ self.state.record_ping(pr.full_name, pr.number, kind)
1457
+ log.warning(
1458
+ "%s: could not withdraw the dangling review request for %s (%s) "
1459
+ "— the closed round will be re-evaluated next poll and the "
1460
+ "removal retried. Withdrawing needs `pull_requests: write`, "
1461
+ "which is more than reviewing needs; further failures of this "
1462
+ "kind log at debug",
1463
+ pr.slug, mine, exc,
1464
+ )
1465
+ return
1466
+
1467
+ others = [login for login in pr.requested_reviewers if login != mine]
1468
+ left = ", ".join(others) if others else "no other reviewers"
1469
+ kind = withdrawn_kind(pr.head_sha)
1470
+ if self.state.pinged(pr.full_name, pr.number, kind):
1471
+ # The request came back at a head this daemon already closed out.
1472
+ # Someone asked for another look at code that still carries its
1473
+ # approve -- and the daemon has just taken their request away, so
1474
+ # say so where they will find it.
1475
+ log.warning(
1476
+ "%s round close-out: the review request for %s came back at "
1477
+ "head %s and was withdrawn AGAIN — the approve at that head "
1478
+ "still stands (%s), so no round is owed; only a new commit "
1479
+ "opens one. Verdict of record %s. Left in place: %s",
1480
+ pr.slug, mine, pr.head_sha[:8], why, verdict.url, left,
1481
+ )
1482
+ return
1483
+ self.state.record_ping(pr.full_name, pr.number, kind)
1484
+ log.info(
1485
+ "%s round close-out: withdrew the dangling review request for %s — "
1486
+ "%s; verdict of record %s at head %s. Left in place: %s",
1487
+ pr.slug, mine, why, verdict.url, pr.head_sha[:8], left,
1488
+ )
1489
+
1490
+ def _drift_gated(self, pr: PullRequest, kind: str, record: bool) -> bool:
1491
+ """Whether this drift-check gate is already closed for `pr`.
1492
+
1493
+ The two backing stores are not interchangeable and the split is the
1494
+ whole point: durable in production, process-lifetime in dry-run, so
1495
+ neither mode can ever silence the other. See _warn_identity_drift.
1496
+ """
1497
+ if record:
1498
+ return self.state.pinged(pr.full_name, pr.number, kind)
1499
+ return (pr.full_name, pr.number, kind) in self._dry_run_drift
1500
+
1501
+ def _note_drift_gate(self, pr: PullRequest, kind: str, record: bool) -> None:
1502
+ if record:
1503
+ self.state.record_ping(pr.full_name, pr.number, kind)
1504
+ else:
1505
+ self._dry_run_drift.add((pr.full_name, pr.number, kind))
1506
+
1507
+ def _warn_identity_drift(self, pr: PullRequest) -> None:
1508
+ """Page once when the round's write-up landed under a login GitHub does
1509
+ not hold the request against.
1510
+
1511
+ This is the #298 shape: the request names one identity, the newest
1512
+ review on the PR carries another, and GitHub only ever consumes a
1513
+ request when the REQUESTED login submits. Withdrawing the request
1514
+ cleans up this PR; the config that produced it will produce the next
1515
+ one too, so it is worth one loud line naming both identities.
1516
+
1517
+ Diagnostic only -- it never gates the removal, and an unreadable review
1518
+ list just means no warning, not a stalled close-out.
1519
+
1520
+ Two gates, both of them once-per-condition: the extra read runs once per
1521
+ (PR, head) and the warning once per (PR, login pair). See
1522
+ drift_probe_kind and identity_drift_kind.
1523
+
1524
+ In `--dry-run` the SAME two gates apply, held in memory instead of in
1525
+ the ping ledger -- which the run must not touch in either direction.
1526
+ Writing would let a diagnostic pass durably silence the daemon it was
1527
+ run to diagnose (`state_db` has no dry-run branch, so the rows land in
1528
+ the same `state.db` production reads, and the alarm is once-per-PR).
1529
+ Reading would silence the DIAGNOSTIC instead: a production pass that
1530
+ already probed this head would make the operator's dry-run print
1531
+ nothing. Process-lifetime scope gives both -- a fresh dry-run always
1532
+ announces, and a daemon left running in dry-run does not re-emit the
1533
+ same block every poll. Same reasoning as `_ignored_acks`: a statement
1534
+ about one process belongs in the process, not in the ledger.
1535
+ """
1536
+ record = not self.config.dry_run
1537
+
1538
+ probe = drift_probe_kind(pr.head_sha)
1539
+ if self._drift_gated(pr, probe, record):
1540
+ return
1541
+
1542
+ try:
1543
+ reviews = self.github.reviews(pr.owner, pr.repo, pr.number)
1544
+ except Exception as exc:
1545
+ # Deliberately not recorded: a read that failed settles nothing.
1546
+ log.debug("%s: could not read reviews for the drift check: %s", pr.slug, exc)
1547
+ return
1548
+ self._note_drift_gate(pr, probe, record)
1549
+
1550
+ substantive = [r for r in reviews if r.is_substantive]
1551
+ if not substantive:
1552
+ return
1553
+ newest = max(substantive, key=lambda r: r.submitted_at)
1554
+ if newest.author == self.github.login:
1555
+ return
1556
+
1557
+ kind = identity_drift_kind(self.github.login, newest.author)
1558
+ if self._drift_gated(pr, kind, record):
1559
+ return
1560
+ self._note_drift_gate(pr, kind, record)
1561
+ log.warning(
1562
+ "IDENTITY DRIFT on %s: the review request is held against %r but "
1563
+ "the round's newest review was submitted by %r (%s). GitHub only "
1564
+ "consumes a review request when the REQUESTED login submits, so "
1565
+ "native review consumption cannot work for this deployment: every "
1566
+ "closed round will leave a dangling request for the daemon to "
1567
+ "withdraw. Point reviewer_login/reviewer_token_env at one identity "
1568
+ "and have that identity post the verdict of record.",
1569
+ pr.slug,
1570
+ self.github.login,
1571
+ newest.author,
1572
+ newest.url or "no url",
1573
+ )
1574
+
1293
1575
  # -- reap sweep --------------------------------------------------------
1294
1576
 
1295
1577
  def sweep_sessions(self) -> int:
@@ -36,12 +36,20 @@ def run(
36
36
  check: bool = True,
37
37
  cwd: "str | os.PathLike[str] | None" = None,
38
38
  env: "Mapping[str, str] | None" = None,
39
+ stdin: "str | None" = None,
39
40
  ) -> str:
40
41
  """Run a command, return stdout. Never uses shell=True.
41
42
 
42
43
  `env`, when given, REPLACES the child's environment rather than adding to
43
44
  it -- that is what makes it an identity guarantee: a credential the caller
44
45
  did not put in the mapping cannot reach the child by inheritance.
46
+
47
+ `stdin` feeds the child's standard input. It exists for `gh api --input -`:
48
+ `gh`'s `-f key=value` fields are encoded differently across `gh` versions
49
+ (`key[]=v` is a JSON array only on modern builds, a string field named
50
+ `key[]` on the 2.4.0 this client targets), so a request whose body must
51
+ have a particular JSON SHAPE is built by the caller and piped in, where no
52
+ version gets a say in it.
45
53
  """
46
54
  log.debug("exec: %s", " ".join(argv))
47
55
  try:
@@ -52,6 +60,7 @@ def run(
52
60
  timeout=timeout,
53
61
  cwd=str(cwd) if cwd is not None else None,
54
62
  env=dict(env) if env is not None else None,
63
+ input=stdin,
55
64
  )
56
65
  except subprocess.TimeoutExpired as exc:
57
66
  raise CommandError(argv, -1, f"timed out after {timeout}s") from exc
@@ -66,9 +75,10 @@ def run_json(
66
75
  *,
67
76
  timeout: int = 60,
68
77
  env: "Mapping[str, str] | None" = None,
78
+ stdin: "str | None" = None,
69
79
  ):
70
80
  """Run a command whose stdout is JSON."""
71
- out = run(argv, timeout=timeout, env=env).strip()
81
+ out = run(argv, timeout=timeout, env=env, stdin=stdin).strip()
72
82
  if not out:
73
83
  return None
74
84
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: alissa-tools-github-revloop
3
- Version: 0.16.4
3
+ Version: 0.16.6
4
4
  Summary: ALISSA-TOOLS-GITHUB-REVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera