alissa-tools-github-devloop 0.8.2__tar.gz → 0.8.4__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.8.2/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.4}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/__main__.py +73 -1
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/config.py +126 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/ghclient.py +89 -26
- alissa_tools_github_devloop-0.8.4/src/main/alissa/tools/github/devloop/janitor.py +513 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/loop.py +632 -14
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/state.py +358 -57
- alissa_tools_github_devloop-0.8.4/src/main/alissa/tools/github/devloop/version +1 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +9 -13
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
- alissa_tools_github_devloop-0.8.2/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
- {alissa_tools_github_devloop-0.8.2 → alissa_tools_github_devloop-0.8.4}/src/main/alissa_tools_github_devloop.egg-info/top_level.txt +0 -0
|
@@ -243,6 +243,62 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
243
243
|
"(two overlapping instances can then both spawn on one item)",
|
|
244
244
|
)
|
|
245
245
|
|
|
246
|
+
janitor = over.add_mutually_exclusive_group()
|
|
247
|
+
janitor.add_argument(
|
|
248
|
+
"--janitor",
|
|
249
|
+
dest="janitor_enabled",
|
|
250
|
+
action="store_true",
|
|
251
|
+
default=None,
|
|
252
|
+
help="reclaim per-task workspaces whose anchor PR is terminal, whose "
|
|
253
|
+
"worker session is gone, and whose grace elapsed, and bound the "
|
|
254
|
+
"package-manager caches (the default)",
|
|
255
|
+
)
|
|
256
|
+
janitor.add_argument(
|
|
257
|
+
"--no-janitor",
|
|
258
|
+
dest="janitor_enabled",
|
|
259
|
+
action="store_false",
|
|
260
|
+
help="never reclaim workspaces or trim caches; disk telemetry stops "
|
|
261
|
+
"with it (the whole janitor pass is skipped)",
|
|
262
|
+
)
|
|
263
|
+
over.add_argument(
|
|
264
|
+
"--janitor-grace-minutes",
|
|
265
|
+
type=int,
|
|
266
|
+
metavar="MINUTES",
|
|
267
|
+
help="how long a workspace's anchor must have been terminal before it "
|
|
268
|
+
"may be reclaimed; 0 = use reap_grace_minutes (the default)",
|
|
269
|
+
)
|
|
270
|
+
over.add_argument(
|
|
271
|
+
"--janitor-interval-minutes",
|
|
272
|
+
type=int,
|
|
273
|
+
metavar="MINUTES",
|
|
274
|
+
help="how often the janitor's expensive half (hub walk, tree sizing, "
|
|
275
|
+
"anchor lookups) runs; 0 = every poll (default: 10). The disk "
|
|
276
|
+
"telemetry line still logs every poll",
|
|
277
|
+
)
|
|
278
|
+
over.add_argument(
|
|
279
|
+
"--cache-cap-mb",
|
|
280
|
+
type=int,
|
|
281
|
+
metavar="MiB",
|
|
282
|
+
help="size cap per package-manager cache directory; over it the "
|
|
283
|
+
"oldest entries are removed until under. 0 = no cache bound "
|
|
284
|
+
"(default: 2048)",
|
|
285
|
+
)
|
|
286
|
+
over.add_argument(
|
|
287
|
+
"--cache-path",
|
|
288
|
+
dest="cache_paths",
|
|
289
|
+
action="append",
|
|
290
|
+
metavar="PATH",
|
|
291
|
+
help="a cache directory the cap applies to; repeatable. Replaces the "
|
|
292
|
+
"built-in list (npm/pip/uv/pnpm/yarn/bun in their standard homes)",
|
|
293
|
+
)
|
|
294
|
+
over.add_argument(
|
|
295
|
+
"--disk-warn-percent",
|
|
296
|
+
type=int,
|
|
297
|
+
metavar="PERCENT",
|
|
298
|
+
help="volume usage at which the per-poll disk line escalates to a "
|
|
299
|
+
"warning naming the biggest workspaces; 0 = never warn (default: 80)",
|
|
300
|
+
)
|
|
301
|
+
|
|
246
302
|
dry = over.add_mutually_exclusive_group()
|
|
247
303
|
dry.add_argument(
|
|
248
304
|
"--dry-run",
|
|
@@ -287,6 +343,12 @@ def overrides_from(args: argparse.Namespace) -> dict:
|
|
|
287
343
|
"orphan_sweep_enabled": args.orphan_sweep_enabled,
|
|
288
344
|
"orphan_grace_minutes": args.orphan_grace_minutes,
|
|
289
345
|
"activity_claims_enabled": args.activity_claims_enabled,
|
|
346
|
+
"janitor_enabled": args.janitor_enabled,
|
|
347
|
+
"janitor_grace_minutes": args.janitor_grace_minutes,
|
|
348
|
+
"janitor_interval_minutes": args.janitor_interval_minutes,
|
|
349
|
+
"cache_cap_mb": args.cache_cap_mb,
|
|
350
|
+
"cache_paths": tuple(args.cache_paths) if args.cache_paths else None,
|
|
351
|
+
"disk_warn_percent": args.disk_warn_percent,
|
|
290
352
|
"dry_run": args.dry_run,
|
|
291
353
|
}
|
|
292
354
|
|
|
@@ -321,7 +383,8 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
321
383
|
"max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
|
|
322
384
|
"(label %r); resume=%s; "
|
|
323
385
|
"rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
|
|
324
|
-
"activity_claims=%s"
|
|
386
|
+
"activity_claims=%s; janitor=%s (grace %s min, every %s min, cache "
|
|
387
|
+
"cap %s MiB, warn at %s%%)",
|
|
325
388
|
config.poll_interval, config.dry_run, config.attempt_cap,
|
|
326
389
|
config.stale_minutes,
|
|
327
390
|
config.max_sessions or "unlimited",
|
|
@@ -334,8 +397,17 @@ def log_effective_config(config: Config, login: str) -> None:
|
|
|
334
397
|
config.orphan_sweep_enabled,
|
|
335
398
|
config.orphan_grace_seconds // 60,
|
|
336
399
|
config.activity_claims_enabled,
|
|
400
|
+
config.janitor_enabled,
|
|
401
|
+
config.janitor_grace_seconds // 60,
|
|
402
|
+
config.janitor_interval_minutes or "poll",
|
|
403
|
+
config.cache_cap_mb,
|
|
404
|
+
config.disk_warn_percent,
|
|
337
405
|
)
|
|
338
406
|
log.debug("hub_template: %s", config.hub_template)
|
|
407
|
+
log.debug(
|
|
408
|
+
"cache_paths: %s",
|
|
409
|
+
", ".join(config.cache_paths) or "built-in defaults",
|
|
410
|
+
)
|
|
339
411
|
log.debug("agent_profile: %s", config.agent_profile)
|
|
340
412
|
log.debug("developer_login: %s", config.developer_login or f"{login} (auto)")
|
|
341
413
|
log.debug("state_db: %s", config.state_db)
|
|
@@ -69,6 +69,12 @@ CONFIG_KEYS = (
|
|
|
69
69
|
"orphan_sweep_enabled",
|
|
70
70
|
"orphan_grace_minutes",
|
|
71
71
|
"activity_claims_enabled",
|
|
72
|
+
"janitor_enabled",
|
|
73
|
+
"janitor_grace_minutes",
|
|
74
|
+
"janitor_interval_minutes",
|
|
75
|
+
"cache_cap_mb",
|
|
76
|
+
"cache_paths",
|
|
77
|
+
"disk_warn_percent",
|
|
72
78
|
"dry_run",
|
|
73
79
|
)
|
|
74
80
|
|
|
@@ -260,6 +266,56 @@ class Config:
|
|
|
260
266
|
# that would rather not pay the extra comments fetch.
|
|
261
267
|
activity_claims_enabled: bool = True
|
|
262
268
|
|
|
269
|
+
# The workspace janitor (issue #74): the filesystem sibling of the session
|
|
270
|
+
# reaper. Every worker session materializes a per-task workspace under its
|
|
271
|
+
# repo's hub (a `TASK-<n>-<DESC>` worktree plus whatever its lane installs
|
|
272
|
+
# into it) and nothing removed them, so disk grew monotonically with PR
|
|
273
|
+
# throughput -- 3 GB to 32 GB in three days against a 50 GB cap, of which
|
|
274
|
+
# 21.4 GiB was 53 already-merged task worktrees. With this on, a workspace
|
|
275
|
+
# whose anchor PR is terminal, whose worker session is gone, and whose
|
|
276
|
+
# grace has elapsed is reclaimed (worktree removed, branch deleted), and
|
|
277
|
+
# package-manager caches are bounded rather than zeroed. Default ON, on
|
|
278
|
+
# the same grounds as the reaper: it acts only on devloop's own
|
|
279
|
+
# `TASK-<n>-…` directories under its own hubs, never on anything with
|
|
280
|
+
# uncommitted or unpushed work, and every side effect honors dry_run.
|
|
281
|
+
janitor_enabled: bool = True
|
|
282
|
+
|
|
283
|
+
# How long the anchor must have been terminal before its workspace may be
|
|
284
|
+
# reclaimed. 0 means "share the reaper's grace" (reap_grace_minutes,
|
|
285
|
+
# itself falling back to stale_minutes) -- the sentinel every other grace
|
|
286
|
+
# knob here uses, and the documented default: the two sweeps are the same
|
|
287
|
+
# decision about the same lane, so their windows should move together
|
|
288
|
+
# unless an operator deliberately parts them.
|
|
289
|
+
janitor_grace_minutes: int = 0
|
|
290
|
+
|
|
291
|
+
# How often the janitor's expensive half runs: the hub walk, the tree
|
|
292
|
+
# sizing, and the one GitHub call per candidate workspace. Deliberately
|
|
293
|
+
# NOT every poll -- at a 30s interval the walk alone measured ~8s on the
|
|
294
|
+
# real 21 GiB volume, and the anchor lookups would be ~90 API calls a
|
|
295
|
+
# minute for work whose input changes on the timescale of a merge. The
|
|
296
|
+
# cheap half (the volume's own used/free figures) still logs EVERY poll,
|
|
297
|
+
# so the telemetry line never goes quiet. 0 means every poll.
|
|
298
|
+
janitor_interval_minutes: int = 10
|
|
299
|
+
|
|
300
|
+
# The cache bound, in MiB: package-manager caches over this are trimmed
|
|
301
|
+
# oldest-entry-first back under it. 0 disables the trim entirely. A cache
|
|
302
|
+
# is a legitimate speed tradeoff -- the default is generous on purpose
|
|
303
|
+
# (the measured real-world figure was 1.4 MB, four orders of magnitude
|
|
304
|
+
# under this), because a cache that is not the problem should not be
|
|
305
|
+
# cleared to prove a point.
|
|
306
|
+
cache_cap_mb: int = 2048
|
|
307
|
+
|
|
308
|
+
# Which cache directories the bound applies to. Empty means the built-in
|
|
309
|
+
# list (`janitor.DEFAULT_CACHE_PATHS`: npm/pip/uv/pnpm/yarn/bun in their
|
|
310
|
+
# standard homes); entries that do not exist are skipped. `~` expands.
|
|
311
|
+
cache_paths: tuple[str, ...] = ()
|
|
312
|
+
|
|
313
|
+
# Percent of the workspace volume in use at which the per-poll telemetry
|
|
314
|
+
# line escalates from INFO to a WARNING naming the biggest consumers.
|
|
315
|
+
# 80 by default -- far enough ahead of full that a human still has room
|
|
316
|
+
# to act (at the incident's growth rate, ~2 days). 0 never warns.
|
|
317
|
+
disk_warn_percent: int = 80
|
|
318
|
+
|
|
263
319
|
dry_run: bool = False
|
|
264
320
|
|
|
265
321
|
# Derived at build time: `repos` casefolded for matching. GitHub names are
|
|
@@ -311,6 +367,30 @@ class Config:
|
|
|
311
367
|
ready-and-request a draft opened seconds ago on a default config."""
|
|
312
368
|
return (self.orphan_grace_minutes or self.stale_minutes) * 60
|
|
313
369
|
|
|
370
|
+
@property
|
|
371
|
+
def janitor_grace_seconds(self) -> int:
|
|
372
|
+
"""How long an anchor must have been terminal before its workspace may
|
|
373
|
+
be reclaimed, in seconds. Read this, never the raw field -- the
|
|
374
|
+
`reap_grace_seconds` contract, one rung longer: 0 means "the reaper's
|
|
375
|
+
grace", which is itself 0-means-stale_minutes, so a caller reading the
|
|
376
|
+
field directly would delete a workspace the instant its PR merged on a
|
|
377
|
+
default config."""
|
|
378
|
+
return (self.janitor_grace_minutes * 60) or self.reap_grace_seconds
|
|
379
|
+
|
|
380
|
+
@property
|
|
381
|
+
def janitor_interval_seconds(self) -> int:
|
|
382
|
+
"""How often the janitor's expensive half may run, in seconds. 0 means
|
|
383
|
+
every poll, and needs no sentinel: unlike the grace knobs, "every
|
|
384
|
+
pass" is a coherent answer to "how often"."""
|
|
385
|
+
return max(0, self.janitor_interval_minutes) * 60
|
|
386
|
+
|
|
387
|
+
@property
|
|
388
|
+
def cache_cap_bytes(self) -> int:
|
|
389
|
+
"""The cache bound in bytes (0 = no bound). MiB in the config because
|
|
390
|
+
that is the unit an operator thinks about a cache in; bytes here
|
|
391
|
+
because that is what the walker measures."""
|
|
392
|
+
return max(0, self.cache_cap_mb) * 1024 * 1024
|
|
393
|
+
|
|
314
394
|
@property
|
|
315
395
|
def manifest_path(self) -> Path:
|
|
316
396
|
return self.workspace_root / "alissa-workspace.yaml"
|
|
@@ -446,6 +526,44 @@ class Config:
|
|
|
446
526
|
f"got {orphan_grace}"
|
|
447
527
|
)
|
|
448
528
|
|
|
529
|
+
# The janitor's grace, the reaper's sentinel one rung longer (0 = use
|
|
530
|
+
# reap_grace_minutes). Negative is the only invalid value, and it is
|
|
531
|
+
# the dangerous one: it would license reclaiming a workspace the
|
|
532
|
+
# instant its PR merged, racing the close-out work the grace exists
|
|
533
|
+
# for.
|
|
534
|
+
janitor_grace = int(
|
|
535
|
+
raw.get("janitor_grace_minutes", cls.janitor_grace_minutes)
|
|
536
|
+
)
|
|
537
|
+
if janitor_grace < 0:
|
|
538
|
+
raise ValueError(
|
|
539
|
+
f"janitor_grace_minutes must be >= 0 (0 = use "
|
|
540
|
+
f"reap_grace_minutes), got {janitor_grace}"
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
# 0 means "every poll" here (not a fallback sentinel -- see
|
|
544
|
+
# janitor_interval_seconds), so again only negatives are rejected.
|
|
545
|
+
janitor_interval = int(
|
|
546
|
+
raw.get("janitor_interval_minutes", cls.janitor_interval_minutes)
|
|
547
|
+
)
|
|
548
|
+
if janitor_interval < 0:
|
|
549
|
+
raise ValueError(
|
|
550
|
+
f"janitor_interval_minutes must be >= 0 (0 = every poll), "
|
|
551
|
+
f"got {janitor_interval}"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
cache_cap = int(raw.get("cache_cap_mb", cls.cache_cap_mb))
|
|
555
|
+
if cache_cap < 0:
|
|
556
|
+
raise ValueError(
|
|
557
|
+
f"cache_cap_mb must be >= 0 (0 = no cache bound), got {cache_cap}"
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
warn_percent = int(raw.get("disk_warn_percent", cls.disk_warn_percent))
|
|
561
|
+
if not 0 <= warn_percent <= 100:
|
|
562
|
+
raise ValueError(
|
|
563
|
+
f"disk_warn_percent must be between 0 and 100 (0 = never "
|
|
564
|
+
f"warn), got {warn_percent}"
|
|
565
|
+
)
|
|
566
|
+
|
|
449
567
|
interval = int(raw.get("poll_interval", cls.poll_interval))
|
|
450
568
|
if interval < MIN_POLL_INTERVAL:
|
|
451
569
|
raise ValueError(
|
|
@@ -490,6 +608,14 @@ class Config:
|
|
|
490
608
|
activity_claims_enabled=bool(
|
|
491
609
|
raw.get("activity_claims_enabled", cls.activity_claims_enabled)
|
|
492
610
|
),
|
|
611
|
+
janitor_enabled=bool(raw.get("janitor_enabled", cls.janitor_enabled)),
|
|
612
|
+
janitor_grace_minutes=janitor_grace,
|
|
613
|
+
janitor_interval_minutes=janitor_interval,
|
|
614
|
+
cache_cap_mb=cache_cap,
|
|
615
|
+
cache_paths=_string_list(
|
|
616
|
+
raw.get("cache_paths", cls.cache_paths), "cache_paths"
|
|
617
|
+
),
|
|
618
|
+
disk_warn_percent=warn_percent,
|
|
493
619
|
dry_run=bool(raw.get("dry_run", cls.dry_run)),
|
|
494
620
|
)
|
|
495
621
|
|
|
@@ -128,6 +128,13 @@ class PullRequest:
|
|
|
128
128
|
# other three edges -- none of which flip drafts -- need not supply it;
|
|
129
129
|
# `mark_ready` refuses an empty one rather than POSTing a nonsense mutation.
|
|
130
130
|
node_id: str = ""
|
|
131
|
+
# When this PR was closed (merged or not), unix seconds; None while it is
|
|
132
|
+
# open OR when the payload carried no readable stamp. The workspace
|
|
133
|
+
# janitor's grace clock -- a merged PR's task workspace is reclaimable
|
|
134
|
+
# only once the anchor has been terminal for a while, and this is the
|
|
135
|
+
# instant that window runs from. Defaulted for the same reason as node_id:
|
|
136
|
+
# the three spawn edges never ask, and neither do their fakes.
|
|
137
|
+
closed_at: "int | None" = None
|
|
131
138
|
|
|
132
139
|
@property
|
|
133
140
|
def full_name(self) -> str:
|
|
@@ -141,6 +148,49 @@ class PullRequest:
|
|
|
141
148
|
return f"{self.owner}/{self.repo}#pr{self.number}"
|
|
142
149
|
|
|
143
150
|
|
|
151
|
+
def _pull_request_from(
|
|
152
|
+
data: "dict", owner: str, repo: str, *, number: "int | None" = None
|
|
153
|
+
) -> PullRequest:
|
|
154
|
+
"""One REST pull-request payload as a PullRequest.
|
|
155
|
+
|
|
156
|
+
Shared by the single-PR GET and the branch listing, which return the SAME
|
|
157
|
+
object shape -- so the two callers cannot drift on what `draft`, the head,
|
|
158
|
+
or the pending review requests mean. `number` is passed explicitly by the
|
|
159
|
+
single GET (it knows which PR it asked for even if the payload is empty);
|
|
160
|
+
the listing reads it from each entry.
|
|
161
|
+
"""
|
|
162
|
+
head = data.get("head") or {}
|
|
163
|
+
# Users carry `login`, teams `slug`; both hold the review-response edge
|
|
164
|
+
# closed -- a pending TEAM re-request still means the ball is in a
|
|
165
|
+
# reviewer's court, so it must not read as "awaiting the author".
|
|
166
|
+
requested = tuple(
|
|
167
|
+
name
|
|
168
|
+
for entry in (
|
|
169
|
+
list(data.get("requested_reviewers") or [])
|
|
170
|
+
+ list(data.get("requested_teams") or [])
|
|
171
|
+
)
|
|
172
|
+
if isinstance(entry, dict)
|
|
173
|
+
and (name := (entry.get("login") or entry.get("slug") or ""))
|
|
174
|
+
)
|
|
175
|
+
return PullRequest(
|
|
176
|
+
owner=owner,
|
|
177
|
+
repo=repo,
|
|
178
|
+
number=int(number if number is not None else data.get("number") or 0),
|
|
179
|
+
state=data.get("state", ""),
|
|
180
|
+
title=data.get("title", ""),
|
|
181
|
+
author=(data.get("user") or {}).get("login", ""),
|
|
182
|
+
head_ref=head.get("ref") or "",
|
|
183
|
+
head_sha=head.get("sha") or "",
|
|
184
|
+
draft=bool(data.get("draft")),
|
|
185
|
+
url=data.get("html_url", ""),
|
|
186
|
+
# `or ""`: GitHub sends null for an empty description.
|
|
187
|
+
body=data.get("body") or "",
|
|
188
|
+
requested_reviewers=requested,
|
|
189
|
+
node_id=data.get("node_id") or "",
|
|
190
|
+
closed_at=parse_github_timestamp(data.get("closed_at")),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
144
194
|
@dataclass(frozen=True)
|
|
145
195
|
class Review:
|
|
146
196
|
author: str
|
|
@@ -620,34 +670,47 @@ class GitHub:
|
|
|
620
670
|
issue edge's resume probe reads for provenance; it rides along on the
|
|
621
671
|
same GET rather than costing a call of its own.)"""
|
|
622
672
|
data = self._api(f"repos/{owner}/{repo}/pulls/{number}") or {}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
673
|
+
return _pull_request_from(data, owner, repo, number=number)
|
|
674
|
+
|
|
675
|
+
def branch_pulls(
|
|
676
|
+
self, owner: str, repo: str, head_ref: str
|
|
677
|
+
) -> "tuple[PullRequest, ...]":
|
|
678
|
+
"""Every pull request ever opened FROM `head_ref`, in any state --
|
|
679
|
+
the workspace janitor's anchor lookup.
|
|
680
|
+
|
|
681
|
+
A task workspace is a branch checkout, and the only thing that can say
|
|
682
|
+
whether that branch's work is over is the PR it was opened as. `state`
|
|
683
|
+
is deliberately `all`: an OPEN PR is precisely the case that must
|
|
684
|
+
spare the workspace, so a query that could not see it would be
|
|
685
|
+
unsafe, and a branch with no PR at all comes back empty (spared too --
|
|
686
|
+
unpublished work has no terminal anchor).
|
|
687
|
+
|
|
688
|
+
`head` is qualified with the owner (`owner:branch`), the form GitHub
|
|
689
|
+
requires to disambiguate forks. Malformed entries are skipped rather
|
|
690
|
+
than fatal, like `reviews()`; a single page (per_page=100) is a
|
|
691
|
+
documented window, and a branch with more than 100 pull requests is
|
|
692
|
+
far outside anything this daemon creates.
|
|
693
|
+
"""
|
|
694
|
+
data = (
|
|
695
|
+
self._api(
|
|
696
|
+
"-X",
|
|
697
|
+
"GET",
|
|
698
|
+
f"repos/{owner}/{repo}/pulls",
|
|
699
|
+
"-f",
|
|
700
|
+
f"head={owner}:{head_ref}",
|
|
701
|
+
"-f",
|
|
702
|
+
"state=all",
|
|
703
|
+
"-f",
|
|
704
|
+
"per_page=100",
|
|
632
705
|
)
|
|
633
|
-
|
|
634
|
-
and (name := (entry.get("login") or entry.get("slug") or ""))
|
|
706
|
+
or []
|
|
635
707
|
)
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
author=(data.get("user") or {}).get("login", ""),
|
|
643
|
-
head_ref=head.get("ref") or "",
|
|
644
|
-
head_sha=head.get("sha") or "",
|
|
645
|
-
draft=bool(data.get("draft")),
|
|
646
|
-
url=data.get("html_url", ""),
|
|
647
|
-
# `or ""`: GitHub sends null for an empty description.
|
|
648
|
-
body=data.get("body") or "",
|
|
649
|
-
requested_reviewers=requested,
|
|
650
|
-
node_id=data.get("node_id") or "",
|
|
708
|
+
if not isinstance(data, list):
|
|
709
|
+
return ()
|
|
710
|
+
return tuple(
|
|
711
|
+
_pull_request_from(entry, owner, repo)
|
|
712
|
+
for entry in data
|
|
713
|
+
if isinstance(entry, dict) and entry.get("number") is not None
|
|
651
714
|
)
|
|
652
715
|
|
|
653
716
|
def reviews(self, owner: str, repo: str, number: int) -> list[Review]:
|