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.
- {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
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/__main__.py +86 -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
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/ghclient.py +89 -26
- alissa_tools_github_devloop-0.8.3/src/main/alissa/tools/github/devloop/janitor.py +513 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/loop.py +1039 -89
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/state.py +13 -0
- alissa_tools_github_devloop-0.8.3/src/main/alissa/tools/github/devloop/version +1 -0
- {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
- {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
- {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
- alissa_tools_github_devloop-0.8.1/src/main/alissa/tools/github/devloop/version +0 -1
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/LICENSE +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/MANIFEST.in +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/NOTICE +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/README.md +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/requirements.txt +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/setup.cfg +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/setup.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/__init__.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/alissa.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/proc.py +0 -0
- {alissa_tools_github_devloop-0.8.1 → alissa_tools_github_devloop-0.8.3}/src/main/alissa/tools/github/devloop/version.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
|
@@ -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;
|
|
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
|
-
|
|
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]:
|