alissa-tools-github-devloop 0.8.1__tar.gz → 0.8.3__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_devloop-0.8.1/src/main/alissa_tools_github_devloop.egg-info → alissa_tools_github_devloop-0.8.3}/PKG-INFO +1 -1
  2. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/__main__.py +86 -3
  3. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/config.py +160 -0
  4. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/ghclient.py +89 -26
  5. alissa_tools_github_devloop-0.8.3/src/main/alissa/tools/github/devloop/janitor.py +513 -0
  6. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/loop.py +1039 -89
  7. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/state.py +13 -0
  8. alissa_tools_github_devloop-0.8.3/src/main/alissa/tools/github/devloop/version +1 -0
  9. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/webui/sysinfo.py +9 -13
  10. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3/src/main/alissa_tools_github_devloop.egg-info}/PKG-INFO +1 -1
  11. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa_tools_github_devloop.egg-info/SOURCES.txt +1 -0
  12. alissa_tools_github_devloop-0.8.1/src/main/alissa/tools/github/devloop/version +0 -1
  13. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/LICENSE +0 -0
  14. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/MANIFEST.in +0 -0
  15. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/NOTICE +0 -0
  16. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/README.md +0 -0
  17. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/requirements.txt +0 -0
  18. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/setup.cfg +0 -0
  19. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/setup.py +0 -0
  20. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
  21. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
  22. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/proc.py +0 -0
  23. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/version.py +0 -0
  24. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/webui/__init__.py +0 -0
  25. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/webui/__main__.py +0 -0
  26. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/webui/auth.py +0 -0
  27. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/webui/page.py +0 -0
  28. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/webui/server.py +0 -0
  29. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/webui/sources.py +0 -0
  30. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa_tools_github_devloop.egg-info/dependency_links.txt +0 -0
  31. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa_tools_github_devloop.egg-info/entry_points.txt +0 -0
  32. {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/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.8.1
3
+ Version: 0.8.3
4
4
  Summary: ALISSA-TOOLS-GITHUB-DEVLOOP
5
5
  Home-page: https://alissa.app
6
6
  Author: Fahera
@@ -98,7 +98,15 @@ def build_parser() -> argparse.ArgumentParser:
98
98
  metavar="N",
99
99
  help="max concurrent worker sessions (develop-*/fix-*/maintain-*) "
100
100
  "before actionable items defer to the next poll; 0 = unlimited "
101
- "(the default)",
101
+ "(the default). Also the reaper's post-sweep alert threshold",
102
+ )
103
+ over.add_argument(
104
+ "--reap-grace-minutes",
105
+ type=int,
106
+ metavar="MINUTES",
107
+ help="how long a worker session must have been quiet before the "
108
+ "reaper may kill it on a terminal anchor; 0 = use stale_minutes "
109
+ "(default: 30)",
102
110
  )
103
111
  over.add_argument(
104
112
  "--on-missing-origin-task",
@@ -235,6 +243,62 @@ def build_parser() -> argparse.ArgumentParser:
235
243
  "(two overlapping instances can then both spawn on one item)",
236
244
  )
237
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
+
238
302
  dry = over.add_mutually_exclusive_group()
239
303
  dry.add_argument(
240
304
  "--dry-run",
@@ -267,6 +331,7 @@ def overrides_from(args: argparse.Namespace) -> dict:
267
331
  "attempt_cap": args.attempt_cap,
268
332
  "stale_minutes": args.stale_minutes,
269
333
  "max_sessions": args.max_sessions,
334
+ "reap_grace_minutes": args.reap_grace_minutes,
270
335
  "on_missing_origin_task": args.on_missing_origin_task,
271
336
  "on_missing_hub": args.on_missing_hub,
272
337
  "fix_rounds_enabled": args.fix_rounds_enabled,
@@ -278,6 +343,12 @@ def overrides_from(args: argparse.Namespace) -> dict:
278
343
  "orphan_sweep_enabled": args.orphan_sweep_enabled,
279
344
  "orphan_grace_minutes": args.orphan_grace_minutes,
280
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,
281
352
  "dry_run": args.dry_run,
282
353
  }
283
354
 
@@ -309,12 +380,15 @@ def log_effective_config(config: Config, login: str) -> None:
309
380
  log.info("reviewers: %s", ", ".join(config.reviewers) or "none")
310
381
  log.info(
311
382
  "poll every %ss; dry_run=%s; attempt_cap=%s; stale after %s min; "
312
- "max_sessions=%s; fix_rounds=%s; maintain=%s (label %r); resume=%s; "
383
+ "max_sessions=%s; reap_grace=%s min; fix_rounds=%s; maintain=%s "
384
+ "(label %r); resume=%s; "
313
385
  "rerequest=%s (grace %s min); orphan_sweep=%s (grace %s min); "
314
- "activity_claims=%s",
386
+ "activity_claims=%s; janitor=%s (grace %s min, every %s min, cache "
387
+ "cap %s MiB, warn at %s%%)",
315
388
  config.poll_interval, config.dry_run, config.attempt_cap,
316
389
  config.stale_minutes,
317
390
  config.max_sessions or "unlimited",
391
+ config.reap_grace_seconds // 60,
318
392
  config.fix_rounds_enabled,
319
393
  config.maintain_enabled, config.maintain_label,
320
394
  config.resume_enabled,
@@ -323,8 +397,17 @@ def log_effective_config(config: Config, login: str) -> None:
323
397
  config.orphan_sweep_enabled,
324
398
  config.orphan_grace_seconds // 60,
325
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,
326
405
  )
327
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
+ )
328
411
  log.debug("agent_profile: %s", config.agent_profile)
329
412
  log.debug("developer_login: %s", config.developer_login or f"{login} (auto)")
330
413
  log.debug("state_db: %s", config.state_db)
@@ -57,6 +57,7 @@ CONFIG_KEYS = (
57
57
  "attempt_cap",
58
58
  "stale_minutes",
59
59
  "max_sessions",
60
+ "reap_grace_minutes",
60
61
  "on_missing_origin_task",
61
62
  "on_missing_hub",
62
63
  "fix_rounds_enabled",
@@ -68,6 +69,12 @@ CONFIG_KEYS = (
68
69
  "orphan_sweep_enabled",
69
70
  "orphan_grace_minutes",
70
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",
71
78
  "dry_run",
72
79
  )
73
80
 
@@ -149,6 +156,18 @@ class Config:
149
156
  # listing, so enabling it costs no extra GitHub or CLI calls.
150
157
  max_sessions: int = 0
151
158
 
159
+ # The reaper's grace: how long a worker session must have been QUIET (no
160
+ # tmux activity) before a terminal anchor licenses killing it. The point
161
+ # is the window right after an anchor goes terminal -- a PR merges while
162
+ # its session is still attaching evidence, answering a triage thread,
163
+ # writing its delivery note -- where the anchor already says "done" and
164
+ # the session is not. Busy is spared regardless (see DevWatcher's sweep);
165
+ # this covers the session that is idle between two bursts of that work.
166
+ # 30 minutes by default: long enough to outlast a close-out, short enough
167
+ # that a corpse costs at most one extra poll window of memory. 0 falls
168
+ # back to stale_minutes, the sentinel every other grace knob here uses.
169
+ reap_grace_minutes: int = 30
170
+
152
171
  on_missing_origin_task: str = ON_MISSING_WARN
153
172
  on_missing_hub: str = HUB_SKIP
154
173
 
@@ -247,6 +266,56 @@ class Config:
247
266
  # that would rather not pay the extra comments fetch.
248
267
  activity_claims_enabled: bool = True
249
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
+
250
319
  dry_run: bool = False
251
320
 
252
321
  # Derived at build time: `repos` casefolded for matching. GitHub names are
@@ -280,6 +349,15 @@ class Config:
280
349
  the re-request instantly on a default config."""
281
350
  return (self.rerequest_grace_minutes or self.stale_minutes) * 60
282
351
 
352
+ @property
353
+ def reap_grace_seconds(self) -> int:
354
+ """How long a worker session must have been quiet before the reaper
355
+ may kill it, in seconds. Read this, never the raw field -- the
356
+ `rerequest_grace_seconds` contract verbatim: 0 is the sentinel for
357
+ "the stale window", and a caller reading the field directly would kill
358
+ a session that went quiet seconds ago on a config that set 0."""
359
+ return (self.reap_grace_minutes or self.stale_minutes) * 60
360
+
283
361
  @property
284
362
  def orphan_grace_seconds(self) -> int:
285
363
  """How long a draft's head must have been public before the orphan
@@ -289,6 +367,30 @@ class Config:
289
367
  ready-and-request a draft opened seconds ago on a default config."""
290
368
  return (self.orphan_grace_minutes or self.stale_minutes) * 60
291
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
+
292
394
  @property
293
395
  def manifest_path(self) -> Path:
294
396
  return self.workspace_root / "alissa-workspace.yaml"
@@ -391,6 +493,17 @@ class Config:
391
493
  f"max_sessions must be >= 0 (0 = unlimited), got {max_sessions}"
392
494
  )
393
495
 
496
+ # The reaper's grace, validated like the two below it -- same 0
497
+ # sentinel, and a negative value is the only invalid one (it would
498
+ # make every listed session instantly reapable, which is the one
499
+ # mistake here that kills live work).
500
+ reap_grace = int(raw.get("reap_grace_minutes", cls.reap_grace_minutes))
501
+ if reap_grace < 0:
502
+ raise ValueError(
503
+ f"reap_grace_minutes must be >= 0 (0 = use stale_minutes), "
504
+ f"got {reap_grace}"
505
+ )
506
+
394
507
  # 0 is the sentinel for "fall back to stale_minutes" (the documented
395
508
  # default), so the floor is 0 like max_sessions' -- a negative grace
396
509
  # would make the self-heal fire on a head pushed in the future.
@@ -413,6 +526,44 @@ class Config:
413
526
  f"got {orphan_grace}"
414
527
  )
415
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
+
416
567
  interval = int(raw.get("poll_interval", cls.poll_interval))
417
568
  if interval < MIN_POLL_INTERVAL:
418
569
  raise ValueError(
@@ -433,6 +584,7 @@ class Config:
433
584
  attempt_cap=cap,
434
585
  stale_minutes=stale,
435
586
  max_sessions=max_sessions,
587
+ reap_grace_minutes=reap_grace,
436
588
  on_missing_origin_task=mode,
437
589
  on_missing_hub=hub_mode,
438
590
  fix_rounds_enabled=bool(
@@ -456,6 +608,14 @@ class Config:
456
608
  activity_claims_enabled=bool(
457
609
  raw.get("activity_claims_enabled", cls.activity_claims_enabled)
458
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,
459
619
  dry_run=bool(raw.get("dry_run", cls.dry_run)),
460
620
  )
461
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
- head = data.get("head") or {}
624
- # Users carry `login`, teams `slug`; both hold the edge closed -- a
625
- # pending TEAM re-request still means the ball is in a reviewer's
626
- # court, so it must not read as "awaiting the author".
627
- requested = tuple(
628
- name
629
- for entry in (
630
- list(data.get("requested_reviewers") or [])
631
- + list(data.get("requested_teams") or [])
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
- if isinstance(entry, dict)
634
- and (name := (entry.get("login") or entry.get("slug") or ""))
706
+ or []
635
707
  )
636
- return PullRequest(
637
- owner=owner,
638
- repo=repo,
639
- number=number,
640
- state=data.get("state", ""),
641
- title=data.get("title", ""),
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]: