alissa-tools-github-devloop 0.6.2__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {alissa_tools_github_devloop-0.6.2/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.7.0}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__main__.py +31 -1
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/alissa.py +33 -11
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/config.py +49 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/ghclient.py +102 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/loop.py +993 -38
- alissa_tools_github_devloop-0.7.0/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- alissa_tools_github_devloop-0.6.2/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/README.md +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/setup.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/state.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.6.2 → alissa_tools_github_devloop-0.7.0}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -165,6 +165,32 @@ 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
|
+
|
|
168
194
|
claims = over.add_mutually_exclusive_group()
|
|
169
195
|
claims.add_argument(
|
|
170
196
|
"--activity-claims",
|
|
@@ -221,6 +247,8 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
221
247
|
"maintain_label": args.maintain_label,
|
|
222
248
|
"maintain_enabled": args.maintain_enabled,
|
|
223
249
|
"resume_enabled": args.resume_enabled,
|
|
250
|
+
"rerequest_enabled": args.rerequest_enabled,
|
|
251
|
+
"rerequest_grace_minutes": args.rerequest_grace_minutes,
|
|
224
252
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
225
253
|
"dry_run": args.dry_run,
|
|
226
254
|
}
|
|
@@ -254,13 +282,15 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
254
282
|
log.info(
|
|
255
283
|
"poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
|
|
256
284
|
"max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
|
|
257
|
-
"activity_claims=%s",
|
|
285
|
+
"rerequest=%s (grace %s min); activity_claims=%s",
|
|
258
286
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
259
287
|
config.stale_minutes,
|
|
260
288
|
config.max_sessions or "unlimited",
|
|
261
289
|
config.fix_rounds_enabled,
|
|
262
290
|
config.maintain_enabled, config.maintain_label,
|
|
263
291
|
config.resume_enabled,
|
|
292
|
+
config.rerequest_enabled,
|
|
293
|
+
config.rerequest_grace_seconds // 60,
|
|
264
294
|
config.activity_claims_enabled,
|
|
265
295
|
)
|
|
266
296
|
log.debug("hub_template: %s", config.hub_template)
|
|
@@ -70,23 +70,45 @@ class Alissa:
|
|
|
70
70
|
|
|
71
71
|
def list_sessions(self) -> "list[dict]":
|
|
72
72
|
"""Managed tmux sessions as `alissa tmux ls --json` reports them
|
|
73
|
-
(dicts carrying at least `name` and `status`).
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
73
|
+
(dicts carrying at least `name` and `status`).
|
|
74
|
+
|
|
75
|
+
Raises CommandError whenever the CLI does not answer USABLY: a
|
|
76
|
+
non-zero exit, or a zero exit whose stdout is empty, unparseable, or
|
|
77
|
+
not a JSON list. `--json` serialises its array and returns BEFORE the
|
|
78
|
+
human-readable empty case, so a working CLI with nothing to report
|
|
79
|
+
prints `[]`, never nothing -- which makes empty/garbled output a CLI
|
|
80
|
+
that failed to answer, not an answer of "no sessions". A PARSED empty
|
|
81
|
+
list is the genuine zero-session listing and is returned as such.
|
|
82
|
+
|
|
83
|
+
That distinction is load-bearing, not pedantry: callers feed this to
|
|
84
|
+
SessionLiveness, where a successful listing missing a name means the
|
|
85
|
+
session is DEAD. Degrading unusable output to `[]` would hand every
|
|
86
|
+
caller a confident, empty, WRONG roster -- respawning over sessions
|
|
87
|
+
that are merely unlistable, which is the double-spawn this daemon's
|
|
88
|
+
staleness matrix exists to prevent.
|
|
89
|
+
|
|
90
|
+
Raising cannot crash a poll: every caller already treats CommandError
|
|
91
|
+
as skip-this-pass (the reaper skips its sweep, the max_sessions budget
|
|
92
|
+
fails open, and the roster reads INDETERMINATE, so neither a respawn
|
|
93
|
+
nor a resume can fire on a listing that did not happen)."""
|
|
94
|
+
argv = ["alissa", "tmux", "ls", "--json"]
|
|
95
|
+
out = run(argv, timeout=60)
|
|
78
96
|
if not out.strip():
|
|
79
|
-
|
|
97
|
+
raise CommandError(
|
|
98
|
+
argv, 0, "`alissa tmux ls --json` printed nothing (a working "
|
|
99
|
+
"CLI prints `[]` when there are no sessions)",
|
|
100
|
+
)
|
|
80
101
|
try:
|
|
81
102
|
data = json.loads(out)
|
|
82
103
|
except json.JSONDecodeError:
|
|
83
|
-
|
|
84
|
-
"`alissa tmux ls --json` returned unparseable output
|
|
85
|
-
"treating as no sessions this sweep"
|
|
104
|
+
raise CommandError(
|
|
105
|
+
argv, 0, "`alissa tmux ls --json` returned unparseable output",
|
|
86
106
|
)
|
|
87
|
-
return []
|
|
88
107
|
if not isinstance(data, list):
|
|
89
|
-
|
|
108
|
+
raise CommandError(
|
|
109
|
+
argv, 0, "`alissa tmux ls --json` returned "
|
|
110
|
+
f"{type(data).__name__}, not a JSON list",
|
|
111
|
+
)
|
|
90
112
|
return [entry for entry in data if isinstance(entry, dict)]
|
|
91
113
|
|
|
92
114
|
def kill_session(self, name: str) -> None:
|
|
@@ -63,6 +63,8 @@ CONFIG_KEYS = (
|
|
|
63
63
|
"maintain_label",
|
|
64
64
|
"maintain_enabled",
|
|
65
65
|
"resume_enabled",
|
|
66
|
+
"rerequest_enabled",
|
|
67
|
+
"rerequest_grace_minutes",
|
|
66
68
|
"activity_claims_enabled",
|
|
67
69
|
"dry_run",
|
|
68
70
|
)
|
|
@@ -181,6 +183,30 @@ class Config:
|
|
|
181
183
|
# operator who wants a dead-with-PR lane to wait for a human.
|
|
182
184
|
resume_enabled: bool = True
|
|
183
185
|
|
|
186
|
+
# The wedge self-heal (issue #61): the fix edge's crash-recovery net under
|
|
187
|
+
# FIX_DIRECTIVE's terminal step. A fix session that posted its triage
|
|
188
|
+
# replies and PUSHED, then died before re-requesting review, leaves the PR
|
|
189
|
+
# in a state NEITHER daemon can exit -- revloop only sees PRs with a review
|
|
190
|
+
# requested from its identity, and the fix edge reads the push as "an
|
|
191
|
+
# implementer is already active". Rather than spawn a session, the daemon
|
|
192
|
+
# completes the dead one's protocol itself: it re-requests review from the
|
|
193
|
+
# reviewer whose verdict is being answered, exactly once per (PR, round,
|
|
194
|
+
# head). Default ON -- it closes a 3+ hour operator-only wedge (observed on
|
|
195
|
+
# fahera-mx/studio.alissa.app#242, 2026-07-27), it re-requests rather than
|
|
196
|
+
# spawns (so it can never double-drive a branch), and every side effect
|
|
197
|
+
# still honors dry_run. false restores the legacy read: the push closes the
|
|
198
|
+
# edge and a human re-requests.
|
|
199
|
+
rerequest_enabled: bool = True
|
|
200
|
+
|
|
201
|
+
# How long the pushed head must have been public before the self-heal
|
|
202
|
+
# fires. A live session re-requests SECONDS after its push, so firing on
|
|
203
|
+
# sight would race it; the window is what turns "a push exists" into "a
|
|
204
|
+
# push exists and nobody followed it up". 0 means "the fix-session stale
|
|
205
|
+
# window" (stale_minutes) -- the same clock every other presumed-dead
|
|
206
|
+
# decision on this edge is made on, and the documented default. Set a
|
|
207
|
+
# positive value to widen or narrow it independently.
|
|
208
|
+
rerequest_grace_minutes: int = 0
|
|
209
|
+
|
|
184
210
|
# Cross-instance spawn dedupe (issue #53): before spawning on any edge,
|
|
185
211
|
# read the trigger artifact's mechanical activity comment and treat a
|
|
186
212
|
# spawn line younger than stale_minutes as an in-flight claim EVEN when
|
|
@@ -219,6 +245,15 @@ class Config:
|
|
|
219
245
|
return default_state_path(self.workspace_root)
|
|
220
246
|
return Path(self.state_path).expanduser()
|
|
221
247
|
|
|
248
|
+
@property
|
|
249
|
+
def rerequest_grace_seconds(self) -> int:
|
|
250
|
+
"""How long a pushed head must have been public before the wedge
|
|
251
|
+
self-heal fires, in seconds. Read this, never the raw field: 0 is the
|
|
252
|
+
sentinel for "the fix-session stale window", so the fallback has to
|
|
253
|
+
live in ONE place or a caller reading the field directly would fire
|
|
254
|
+
the re-request instantly on a default config."""
|
|
255
|
+
return (self.rerequest_grace_minutes or self.stale_minutes) * 60
|
|
256
|
+
|
|
222
257
|
@property
|
|
223
258
|
def manifest_path(self) -> Path:
|
|
224
259
|
return self.workspace_root / "alissa-workspace.yaml"
|
|
@@ -321,6 +356,16 @@ class Config:
|
|
|
321
356
|
f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
|
|
322
357
|
)
|
|
323
358
|
|
|
359
|
+
# 0 is the sentinel for "fall back to stale_minutes" (the documented
|
|
360
|
+
# default), so the floor is 0 like max_sessions' -- a negative grace
|
|
361
|
+
# would make the self-heal fire on a head pushed in the future.
|
|
362
|
+
grace = int(raw.get("rerequest_grace_minutes", cls.rerequest_grace_minutes))
|
|
363
|
+
if grace < 0:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"rerequest_grace_minutes must be >= 0 (0 = use stale_minutes), "
|
|
366
|
+
f"got {grace}"
|
|
367
|
+
)
|
|
368
|
+
|
|
324
369
|
interval = int(raw.get("poll_interval", cls.poll_interval))
|
|
325
370
|
if interval < MIN_POLL_INTERVAL:
|
|
326
371
|
raise ValueError(
|
|
@@ -353,6 +398,10 @@ class Config:
|
|
|
353
398
|
resume_enabled=bool(
|
|
354
399
|
raw.get("resume_enabled", cls.resume_enabled)
|
|
355
400
|
),
|
|
401
|
+
rerequest_enabled=bool(
|
|
402
|
+
raw.get("rerequest_enabled", cls.rerequest_enabled)
|
|
403
|
+
),
|
|
404
|
+
rerequest_grace_minutes=grace,
|
|
356
405
|
activity_claims_enabled=bool(
|
|
357
406
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
358
407
|
),
|
|
@@ -176,6 +176,35 @@ class IdentityMismatch(RuntimeError):
|
|
|
176
176
|
"""Configured developer identity disagrees with the gh token."""
|
|
177
177
|
|
|
178
178
|
|
|
179
|
+
class ReviewRequestRejected(RuntimeError):
|
|
180
|
+
"""`request_review` returned 2xx but the reviewer is not on the PR.
|
|
181
|
+
|
|
182
|
+
The `requested_reviewers` endpoint answers with the (possibly unchanged)
|
|
183
|
+
pull request, so a request GitHub declined to honour -- a reviewer without
|
|
184
|
+
read access, a login that has since been renamed, an author who cannot
|
|
185
|
+
request themselves -- can come back 2xx with nothing added. The mechanical
|
|
186
|
+
re-request leans on that field being TRUE afterwards (it is the whole
|
|
187
|
+
remedy: the reviewer daemon's only entry point is a
|
|
188
|
+
`review-requested:@me` search), so the response is checked exactly as
|
|
189
|
+
`assign_self` checks its own.
|
|
190
|
+
|
|
191
|
+
The caller's contract is escalate-don't-retry, the same one
|
|
192
|
+
AssignmentRejected gets on the issue edge and for the same reason: every
|
|
193
|
+
cause above is PERMANENT and operator-fixable, so retrying achieves
|
|
194
|
+
nothing but write traffic against an endpoint GitHub has already refused.
|
|
195
|
+
`loop.DevWatcher._escalate_rerequest_rejected` pages the operator on the
|
|
196
|
+
PR once and records `loop.rerequest_rejected_kind`, which short-circuits
|
|
197
|
+
the next poll before any probe is paid for; a NEW head is a new key and
|
|
198
|
+
gets a fresh attempt.
|
|
199
|
+
|
|
200
|
+
What still separates this from AssignmentRejected is the OTHER ledger row
|
|
201
|
+
it must not write: a refused re-request is never recorded as DONE
|
|
202
|
+
(`loop.rerequest_kind`). The remedy did not happen, and a row saying it
|
|
203
|
+
did would re-wedge the PR permanently with nothing left that can see it.
|
|
204
|
+
Distinct from a transient CommandError on the same call, which keeps the
|
|
205
|
+
plain retry."""
|
|
206
|
+
|
|
207
|
+
|
|
179
208
|
class AssignmentRejected(RuntimeError):
|
|
180
209
|
"""`assign_self` returned 2xx but the login is not on the issue.
|
|
181
210
|
|
|
@@ -609,6 +638,79 @@ class GitHub:
|
|
|
609
638
|
if isinstance(r, dict)
|
|
610
639
|
]
|
|
611
640
|
|
|
641
|
+
def commit_time(self, owner: str, repo: str, sha: str) -> "int | None":
|
|
642
|
+
"""A commit's COMMITTER date as unix seconds, or None when it cannot
|
|
643
|
+
be read -- the grace window's clock behind the mechanical re-request.
|
|
644
|
+
|
|
645
|
+
GitHub's REST API exposes no "when was this ref pushed" field on a pull
|
|
646
|
+
request, and plain pushes raise no timeline event (only force-pushes
|
|
647
|
+
do), so the head commit's committer date is the cheapest instant
|
|
648
|
+
available. It is a LOWER BOUND on the push: a commit created locally
|
|
649
|
+
and pushed minutes later stamps the earlier moment, so an age measured
|
|
650
|
+
from it over-estimates how long the head has been public and the grace
|
|
651
|
+
window can expire marginally early. For the sessions this daemon
|
|
652
|
+
spawns, commit and push are seconds apart (the directives push
|
|
653
|
+
immediately after committing), and the caller tightens the bound
|
|
654
|
+
further by taking the later of this and the review the push answers --
|
|
655
|
+
the push necessarily postdates that review.
|
|
656
|
+
|
|
657
|
+
The GIT-OBJECT endpoint, not `repos/:o/:r/commits/:sha`: the latter
|
|
658
|
+
ships the commit's full file list and patches (up to 300 files) to
|
|
659
|
+
deliver one date, while `git/commits/:sha` returns the object alone --
|
|
660
|
+
a few hundred bytes whatever the commit's size, with the date one
|
|
661
|
+
level shallower. Immaterial on the steady-state path (the done-marker
|
|
662
|
+
short-circuits ahead of this call once the remedy has fired) and not
|
|
663
|
+
immaterial on the transient-retry path, where a poll that keeps
|
|
664
|
+
failing would otherwise re-fetch a possibly-large merge commit every
|
|
665
|
+
cycle.
|
|
666
|
+
|
|
667
|
+
None on anything unreadable (no such commit, a malformed payload, a
|
|
668
|
+
date GitHub did not spell in UTC `Z`): the caller treats an unknown
|
|
669
|
+
push instant as "cannot prove the grace has passed" and defers, which
|
|
670
|
+
is the fail-safe direction for a call that ends in a mutation."""
|
|
671
|
+
data = self._api(f"repos/{owner}/{repo}/git/commits/{sha}") or {}
|
|
672
|
+
committer = data.get("committer") or {}
|
|
673
|
+
return parse_github_timestamp(committer.get("date"))
|
|
674
|
+
|
|
675
|
+
def request_review(self, owner: str, repo: str, number: int, reviewer: str) -> None:
|
|
676
|
+
"""Request review from ONE login on a pull request.
|
|
677
|
+
|
|
678
|
+
The API call behind a fix session's terminal step (`gh pr edit
|
|
679
|
+
--add-reviewer`), performed by the daemon itself when the session that
|
|
680
|
+
owed it died first. `--input` rather than `-f reviewers[]=...` for the
|
|
681
|
+
same reason `assign_self` uses it: gh 2.4.0 predates the array syntax
|
|
682
|
+
and the endpoint wants `{"reviewers": [...]}`.
|
|
683
|
+
|
|
684
|
+
The response is the pull request, so the request is verified to have
|
|
685
|
+
LANDED (see ReviewRequestRejected) -- casefolded, because GitHub logins
|
|
686
|
+
are case-insensitive while the reviewer set is operator-typed."""
|
|
687
|
+
payload = json.dumps({"reviewers": [reviewer]})
|
|
688
|
+
fd, body_path = tempfile.mkstemp(prefix="devloop-rereview-", suffix=".json")
|
|
689
|
+
try:
|
|
690
|
+
with os.fdopen(fd, "w") as handle:
|
|
691
|
+
handle.write(payload)
|
|
692
|
+
data = self._api(
|
|
693
|
+
"-X",
|
|
694
|
+
"POST",
|
|
695
|
+
f"repos/{owner}/{repo}/pulls/{number}/requested_reviewers",
|
|
696
|
+
"--input",
|
|
697
|
+
body_path,
|
|
698
|
+
)
|
|
699
|
+
finally:
|
|
700
|
+
os.unlink(body_path)
|
|
701
|
+
|
|
702
|
+
landed = tuple(
|
|
703
|
+
(r.get("login") or "")
|
|
704
|
+
for r in ((data or {}).get("requested_reviewers") or [])
|
|
705
|
+
)
|
|
706
|
+
if reviewer.casefold() not in {name.casefold() for name in landed}:
|
|
707
|
+
raise ReviewRequestRejected(
|
|
708
|
+
f"{owner}/{repo}#{number}: GitHub accepted the request but "
|
|
709
|
+
f"{reviewer!r} is not among the requested reviewers {landed!r} "
|
|
710
|
+
f"-- the review request did not land, so the reviewer daemon "
|
|
711
|
+
f"still cannot see this PR."
|
|
712
|
+
)
|
|
713
|
+
|
|
612
714
|
def issue(self, owner: str, repo: str, number: int) -> Issue:
|
|
613
715
|
# `or {}`: run_json returns None on empty stdout, and the siblings
|
|
614
716
|
# (search_issues, assign_self) already guard the same way.
|