jj-stack 0.1.0__py3-none-any.whl

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 (88) hide show
  1. jj_stack/__init__.py +11 -0
  2. jj_stack/__main__.py +8 -0
  3. jj_stack/bootstrap.py +213 -0
  4. jj_stack/cli.py +1297 -0
  5. jj_stack/cli_help.py +680 -0
  6. jj_stack/commands/__init__.py +1 -0
  7. jj_stack/commands/_cleanup_actions.py +389 -0
  8. jj_stack/commands/_json_status.py +84 -0
  9. jj_stack/commands/checkout.py +668 -0
  10. jj_stack/commands/cleanup/command.py +702 -0
  11. jj_stack/commands/cleanup/shared.py +62 -0
  12. jj_stack/commands/cleanup/stale.py +79 -0
  13. jj_stack/commands/doctor.py +358 -0
  14. jj_stack/commands/in_use.py +31 -0
  15. jj_stack/commands/list_.py +667 -0
  16. jj_stack/commands/merge/__init__.py +1 -0
  17. jj_stack/commands/merge/command.py +385 -0
  18. jj_stack/commands/merge/github_stack.py +333 -0
  19. jj_stack/commands/merge/models.py +97 -0
  20. jj_stack/commands/merge/plan.py +109 -0
  21. jj_stack/commands/merge/preconditions.py +162 -0
  22. jj_stack/commands/merge/render.py +52 -0
  23. jj_stack/commands/relink.py +210 -0
  24. jj_stack/commands/submit/__init__.py +1 -0
  25. jj_stack/commands/submit/auto_close.py +108 -0
  26. jj_stack/commands/submit/changes.py +90 -0
  27. jj_stack/commands/submit/command.py +780 -0
  28. jj_stack/commands/submit/descriptions.py +570 -0
  29. jj_stack/commands/submit/github_stack.py +139 -0
  30. jj_stack/commands/submit/inputs.py +168 -0
  31. jj_stack/commands/submit/models.py +198 -0
  32. jj_stack/commands/submit/overview_comments.py +178 -0
  33. jj_stack/commands/submit/prs.py +461 -0
  34. jj_stack/commands/submit/render.py +129 -0
  35. jj_stack/commands/sync.py +510 -0
  36. jj_stack/commands/sync_apply.py +474 -0
  37. jj_stack/commands/unstack.py +321 -0
  38. jj_stack/commands/view.py +1151 -0
  39. jj_stack/completion.py +484 -0
  40. jj_stack/concurrency.py +93 -0
  41. jj_stack/config.py +170 -0
  42. jj_stack/console.py +708 -0
  43. jj_stack/errors.py +152 -0
  44. jj_stack/formatting.py +95 -0
  45. jj_stack/github/__init__.py +1 -0
  46. jj_stack/github/auth.py +34 -0
  47. jj_stack/github/client.py +1098 -0
  48. jj_stack/github/error_messages.py +89 -0
  49. jj_stack/github/overview_comments.py +35 -0
  50. jj_stack/github/pr_refs.py +70 -0
  51. jj_stack/github/resolution.py +208 -0
  52. jj_stack/github/stack_availability.py +30 -0
  53. jj_stack/identifiers.py +7 -0
  54. jj_stack/jj/__init__.py +1 -0
  55. jj_stack/jj/cli_args.py +23 -0
  56. jj_stack/jj/client.py +1459 -0
  57. jj_stack/jj/colors.py +199 -0
  58. jj_stack/models/__init__.py +1 -0
  59. jj_stack/models/git.py +15 -0
  60. jj_stack/models/github.py +243 -0
  61. jj_stack/models/stack.py +70 -0
  62. jj_stack/models/tracking.py +115 -0
  63. jj_stack/pr_branch_namespace.py +78 -0
  64. jj_stack/stack/__init__.py +1 -0
  65. jj_stack/stack/change_status.py +216 -0
  66. jj_stack/stack/convergence.py +501 -0
  67. jj_stack/stack/convergence_models.py +69 -0
  68. jj_stack/stack/convergence_observation.py +162 -0
  69. jj_stack/stack/github_stack_safety.py +132 -0
  70. jj_stack/stack/global_convergence.py +270 -0
  71. jj_stack/stack/path.py +235 -0
  72. jj_stack/stack/pr_branches.py +126 -0
  73. jj_stack/stack/pr_facts.py +165 -0
  74. jj_stack/stack/repo.py +95 -0
  75. jj_stack/stack/selected.py +358 -0
  76. jj_stack/stack/selection.py +124 -0
  77. jj_stack/stack/status.py +711 -0
  78. jj_stack/stack/trunk_evidence.py +174 -0
  79. jj_stack/state/__init__.py +1 -0
  80. jj_stack/state/operation_lock.py +220 -0
  81. jj_stack/state/store.py +260 -0
  82. jj_stack/ui.py +230 -0
  83. jj_stack-0.1.0.dist-info/METADATA +170 -0
  84. jj_stack-0.1.0.dist-info/RECORD +88 -0
  85. jj_stack-0.1.0.dist-info/WHEEL +4 -0
  86. jj_stack-0.1.0.dist-info/entry_points.txt +2 -0
  87. jj_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
  88. jj_stack-0.1.0.dist-info/licenses/NOTICE +2 -0
@@ -0,0 +1,702 @@
1
+ """Remove PR branches, comments, and saved links no active pull request needs.
2
+
3
+ With no selector, it checks the whole repo. A revset limits cleanup to one local stack;
4
+ `--pull-request` selects one tracked pull request, and `--pull-request orphans` selects every
5
+ tracked pull request whose local change is gone. Add `--close` to close selected open pull
6
+ requests before cleanup.
7
+
8
+ Without `--close`, open pull requests are left alone. Already closed or merged pull requests do
9
+ not need the flag and are cleaned up normally.
10
+
11
+ If another open pull request still uses a PR branch as its base, that branch stays. Close or
12
+ retarget the pull request named in the message, then rerun the same cleanup command.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ from collections.abc import Callable
19
+ from dataclasses import replace
20
+ from pathlib import Path
21
+
22
+ import jj_stack.console as console
23
+ import jj_stack.ui as ui
24
+ from jj_stack.bootstrap import CommandContext, bootstrap_context
25
+ from jj_stack.commands._cleanup_actions import (
26
+ OverviewCommentLookup,
27
+ apply_overview_comment_cleanup,
28
+ apply_remote_branch_cleanup,
29
+ check_tracked_pr,
30
+ emit_action_row,
31
+ find_overview_comment,
32
+ github_stack_cleanup_blocker,
33
+ plan_pr_cleanup,
34
+ )
35
+ from jj_stack.concurrency import DEFAULT_BOUNDED_CONCURRENCY, run_bounded_tasks
36
+ from jj_stack.errors import AmbiguousSelectionError, UsageError
37
+ from jj_stack.github.client import GithubClient, GithubClientError, build_github_client
38
+ from jj_stack.github.error_messages import github_target_unavailable_messages
39
+ from jj_stack.github.overview_comments import STACK_OVERVIEW_COMMENT_LABEL
40
+ from jj_stack.github.resolution import (
41
+ GithubTarget,
42
+ resolve_github_target,
43
+ )
44
+ from jj_stack.jj.cli_args import JjCliArgs
45
+ from jj_stack.jj.client import PRRefUpdate
46
+ from jj_stack.models.tracking import PRIdentity, TrackingState
47
+ from jj_stack.stack.change_status import enumerate_orphaned_records
48
+ from jj_stack.stack.pr_facts import (
49
+ RepoFacts,
50
+ observe_prs,
51
+ )
52
+ from jj_stack.stack.repo import observe_repo_paths
53
+ from jj_stack.stack.selected import select_stack_path
54
+ from jj_stack.stack.selection import resolve_pr_number
55
+ from jj_stack.state.operation_lock import (
56
+ acquire_operation_lock,
57
+ )
58
+ from jj_stack.ui import plain_text
59
+
60
+ from .shared import (
61
+ CleanupAction,
62
+ CleanupResult,
63
+ PreparedCleanup,
64
+ PreparedCleanupChange,
65
+ )
66
+ from .stale import (
67
+ LocalCleanupObservation,
68
+ local_cleanup_observations,
69
+ )
70
+
71
+ HELP = "Remove PR data that no active pull request needs"
72
+
73
+
74
+ def _build_action_streamer(*, header: str) -> Callable[[CleanupAction], None]:
75
+ """Print the action header once, then stream actions as they arrive."""
76
+
77
+ header_printed = False
78
+
79
+ def emit_action(action: CleanupAction) -> None:
80
+ nonlocal header_printed
81
+ if not header_printed:
82
+ console.output(header)
83
+ header_printed = True
84
+ emit_action_row(kind=action.kind, status=action.status, body=action.body)
85
+
86
+ return emit_action
87
+
88
+
89
+ def cleanup(
90
+ *,
91
+ cli_args: JjCliArgs,
92
+ close: bool,
93
+ debug: bool,
94
+ dry_run: bool,
95
+ pr: str | None,
96
+ repo: Path | None,
97
+ revset: str | None,
98
+ ) -> int:
99
+ """CLI entrypoint for `cleanup`."""
100
+
101
+ if pr is not None and revset is not None:
102
+ raise UsageError("cleanup --pull-request cannot be combined with a revset.")
103
+ if close and pr is None:
104
+ raise UsageError("cleanup --close requires --pull-request.")
105
+
106
+ context = bootstrap_context(
107
+ repo=repo,
108
+ cli_args=cli_args,
109
+ debug=debug,
110
+ )
111
+ with acquire_operation_lock(
112
+ context.state_store.require_writable(),
113
+ command="cleanup",
114
+ ):
115
+ return _run_cleanup_command(
116
+ close=close,
117
+ context=context,
118
+ dry_run=dry_run,
119
+ pr=pr,
120
+ revset=revset,
121
+ )
122
+
123
+
124
+ def _run_cleanup_command(
125
+ *,
126
+ close: bool,
127
+ context: CommandContext,
128
+ dry_run: bool,
129
+ pr: str | None,
130
+ revset: str | None,
131
+ ) -> int:
132
+ """Render and run the stale cleanup command path."""
133
+
134
+ with console.spinner(description="Loading PR state"):
135
+ prepared_cleanup = _prepare_cleanup(
136
+ close=close,
137
+ context=context,
138
+ dry_run=dry_run,
139
+ pr=pr,
140
+ revset=revset,
141
+ )
142
+ selected_change_ids = prepared_cleanup.selected_change_ids
143
+ observed_change_ids = (
144
+ tuple(prepared_cleanup.state.pr_identities)
145
+ if selected_change_ids is None
146
+ else selected_change_ids
147
+ )
148
+ local_observations = local_cleanup_observations(
149
+ change_ids=observed_change_ids,
150
+ context=prepared_cleanup.context,
151
+ )
152
+ if _cleanup_needs_remote_context(prepared_cleanup=prepared_cleanup):
153
+ prepared_cleanup = _load_cleanup_remote_context(prepared_cleanup=prepared_cleanup)
154
+ for message in github_target_unavailable_messages(prepared_cleanup.github_target):
155
+ console.warning(plain_text(message))
156
+
157
+ result = asyncio.run(
158
+ _run_cleanup_async(
159
+ on_action=_build_action_streamer(
160
+ header=(
161
+ "Planned cleanup actions:"
162
+ if prepared_cleanup.dry_run
163
+ else "Applied cleanup actions:"
164
+ ),
165
+ ),
166
+ prepared_cleanup=prepared_cleanup,
167
+ local_observations=local_observations,
168
+ )
169
+ )
170
+ if not result.actions:
171
+ console.output("No cleanup actions needed.")
172
+ return 1 if any(action.status == "blocked" for action in result.actions) else 0
173
+
174
+
175
+ async def cleanup_tracked_prs(
176
+ *,
177
+ change_ids: tuple[str, ...],
178
+ context: CommandContext,
179
+ dry_run: bool,
180
+ github_client: GithubClient,
181
+ github_target: GithubTarget,
182
+ planned_detached_dependents: frozenset[int] = frozenset(),
183
+ planned_local_removals: frozenset[str] = frozenset(),
184
+ ) -> CleanupResult:
185
+ """Run cleanup for PRs reconciled by another command."""
186
+
187
+ state = context.state_store.load()
188
+ if not dry_run:
189
+ context.state_store.require_writable()
190
+ prepared_cleanup = PreparedCleanup(
191
+ close_open_prs=False,
192
+ context=context,
193
+ github_target=github_target,
194
+ dry_run=dry_run,
195
+ selected_change_ids=change_ids,
196
+ state=state,
197
+ )
198
+ local_observations = local_cleanup_observations(
199
+ change_ids=change_ids,
200
+ context=context,
201
+ )
202
+ if dry_run:
203
+ local_observations.update(
204
+ {
205
+ change_id: LocalCleanupObservation(
206
+ has_mutable_copy=False,
207
+ stale_reason=None,
208
+ )
209
+ for change_id in planned_local_removals
210
+ }
211
+ )
212
+ return await _run_cleanup_async(
213
+ github_client=github_client,
214
+ on_action=_build_action_streamer(
215
+ header="Planned cleanup actions:" if dry_run else "Applied cleanup actions:",
216
+ ),
217
+ prepared_cleanup=prepared_cleanup,
218
+ preview_detached_dependents=(planned_detached_dependents if dry_run else frozenset()),
219
+ local_observations=local_observations,
220
+ )
221
+
222
+
223
+ def _prepare_cleanup(
224
+ *,
225
+ close: bool,
226
+ context: CommandContext,
227
+ dry_run: bool,
228
+ pr: str | None,
229
+ revset: str | None,
230
+ ) -> PreparedCleanup:
231
+ """Resolve local cleanup inputs before any GitHub network inspection."""
232
+
233
+ state_store = context.state_store
234
+ state = state_store.load()
235
+ if not dry_run:
236
+ state_store.require_writable()
237
+ selected_change_ids = _resolve_cleanup_change_ids(
238
+ context=context,
239
+ pr=pr,
240
+ revset=revset,
241
+ state=state,
242
+ )
243
+
244
+ return PreparedCleanup(
245
+ close_open_prs=close,
246
+ context=context,
247
+ github_target=None,
248
+ dry_run=dry_run,
249
+ selected_change_ids=selected_change_ids,
250
+ state=state,
251
+ )
252
+
253
+
254
+ def _resolve_cleanup_change_ids(
255
+ *,
256
+ context: CommandContext,
257
+ pr: str | None,
258
+ revset: str | None,
259
+ state: TrackingState,
260
+ ) -> tuple[str, ...] | None:
261
+ """Resolve an optional cleanup selector to saved change IDs."""
262
+
263
+ if pr == "orphans":
264
+ repo_paths = observe_repo_paths(
265
+ jj_client=context.jj_client,
266
+ state=state,
267
+ )
268
+ tracked_stacks = tuple(path.stack for path in repo_paths.paths if path.tracked_change_ids)
269
+ return tuple(
270
+ orphan.change_id for orphan in enumerate_orphaned_records(state, tracked_stacks)
271
+ )
272
+ if pr is not None:
273
+ pr_number = resolve_pr_number(
274
+ jj_client=context.jj_client,
275
+ pr_reference=pr,
276
+ )
277
+ matches = tuple(
278
+ change_id
279
+ for change_id, identity in state.pr_identities.items()
280
+ if identity.pr_number == pr_number
281
+ )
282
+ if len(matches) > 1:
283
+ raise AmbiguousSelectionError(
284
+ t"Multiple saved links claim PR #{pr_number}.",
285
+ hint=t"Run {ui.cmd('list')} to inspect them and repair the incorrect link.",
286
+ )
287
+ return matches
288
+ if revset is None:
289
+ return None
290
+ stack = select_stack_path(
291
+ jj_client=context.jj_client,
292
+ revset=revset,
293
+ state=state,
294
+ ).stack
295
+ return tuple(
296
+ change.change_id for change in stack.changes if change.change_id in state.pr_identities
297
+ )
298
+
299
+
300
+ async def _run_cleanup_async(
301
+ *,
302
+ github_client: GithubClient | None = None,
303
+ on_action: Callable[[CleanupAction], None] | None,
304
+ prepared_cleanup: PreparedCleanup,
305
+ preview_detached_dependents: frozenset[int] = frozenset(),
306
+ local_observations: dict[str, LocalCleanupObservation],
307
+ ) -> CleanupResult:
308
+ actions: list[CleanupAction] = []
309
+
310
+ def record_action(action: CleanupAction) -> None:
311
+ actions.append(action)
312
+ if on_action is not None:
313
+ on_action(action)
314
+
315
+ prepared_changes = _run_local_cleanup_pass(
316
+ prepared_cleanup=prepared_cleanup,
317
+ local_observations=local_observations,
318
+ )
319
+ github_target = prepared_cleanup.github_target
320
+ if isinstance(github_target, GithubTarget) and prepared_changes:
321
+ if github_client is not None:
322
+ await _run_tracked_pr_cleanup_pass(
323
+ github_client=github_client,
324
+ prepared_changes=prepared_changes,
325
+ prepared_cleanup=prepared_cleanup,
326
+ preview_detached_dependents=preview_detached_dependents,
327
+ record_action=record_action,
328
+ )
329
+ else:
330
+ async with build_github_client(repo=github_target.repo) as client:
331
+ await _run_tracked_pr_cleanup_pass(
332
+ github_client=client,
333
+ prepared_changes=prepared_changes,
334
+ prepared_cleanup=prepared_cleanup,
335
+ preview_detached_dependents=preview_detached_dependents,
336
+ record_action=record_action,
337
+ )
338
+ elif prepared_changes:
339
+ for prepared_change in prepared_changes:
340
+ candidate = prepared_change.candidate
341
+ record_action(
342
+ CleanupAction(
343
+ kind="tracking",
344
+ status="blocked",
345
+ body=t"cannot inspect PR #{candidate.pr_identity.pr_number} for "
346
+ t"{ui.change_id(candidate.change_id)} because the GitHub repo "
347
+ t"cannot be resolved",
348
+ )
349
+ )
350
+ return CleanupResult(actions=tuple(actions))
351
+
352
+
353
+ def _run_local_cleanup_pass(
354
+ *,
355
+ prepared_cleanup: PreparedCleanup,
356
+ local_observations: dict[str, LocalCleanupObservation],
357
+ ) -> tuple[PreparedCleanupChange, ...]:
358
+ prepared_changes: list[PreparedCleanupChange] = []
359
+ selected_change_ids = prepared_cleanup.selected_change_ids
360
+ change_ids = (
361
+ tuple(prepared_cleanup.state.pr_identities)
362
+ if selected_change_ids is None
363
+ else selected_change_ids
364
+ )
365
+ for change_id in change_ids:
366
+ candidate = prepared_cleanup.state.tracked_pr(change_id)
367
+ if candidate is None:
368
+ continue
369
+ local_observation = local_observations.get(
370
+ change_id,
371
+ LocalCleanupObservation(
372
+ has_mutable_copy=False,
373
+ stale_reason="local change was not inspected",
374
+ ),
375
+ )
376
+ prepared_changes.append(
377
+ PreparedCleanupChange(
378
+ candidate=candidate,
379
+ has_mutable_copy=local_observation.has_mutable_copy,
380
+ stale_reason=local_observation.stale_reason,
381
+ )
382
+ )
383
+ return tuple(prepared_changes)
384
+
385
+
386
+ async def _run_tracked_pr_cleanup_pass(
387
+ *,
388
+ github_client: GithubClient,
389
+ prepared_changes: tuple[PreparedCleanupChange, ...],
390
+ prepared_cleanup: PreparedCleanup,
391
+ preview_detached_dependents: frozenset[int] = frozenset(),
392
+ record_action: Callable[[CleanupAction], None],
393
+ ) -> None:
394
+ """Clean exact closed-PR records while preserving open or ambiguous ones."""
395
+
396
+ if not prepared_changes:
397
+ return
398
+ remote = prepared_cleanup.remote
399
+ if remote is None:
400
+ raise AssertionError("Tracked PR cleanup requires a configured remote.")
401
+ remote_name = remote.name
402
+ observations = await _observe_cleanup_prs(
403
+ change_ids=tuple(change.candidate.change_id for change in prepared_changes),
404
+ context=prepared_cleanup.context,
405
+ github_client=github_client,
406
+ remote_name=remote_name,
407
+ )
408
+ for prepared_change in prepared_changes:
409
+ candidate = prepared_change.candidate
410
+ observation = observations[candidate.change_id]
411
+ if isinstance(observation, GithubClientError):
412
+ record_action(
413
+ CleanupAction(
414
+ kind="tracking",
415
+ status="blocked",
416
+ body=t"cannot inspect saved PR "
417
+ t"#{candidate.pr_identity.pr_number} for "
418
+ t"{ui.change_id(candidate.change_id)}; fix GitHub access, then rerun "
419
+ t"{ui.cmd('cleanup')}",
420
+ )
421
+ )
422
+ continue
423
+ stop_after_failure = await _cleanup_tracked_pr(
424
+ github_client=github_client,
425
+ initial_observation=observation,
426
+ prepared_change=prepared_change,
427
+ prepared_cleanup=prepared_cleanup,
428
+ preview_detached_dependents=preview_detached_dependents,
429
+ record_action=record_action,
430
+ remote_name=remote_name,
431
+ )
432
+ if stop_after_failure:
433
+ break
434
+
435
+
436
+ async def _observe_cleanup_prs(
437
+ *,
438
+ change_ids: tuple[str, ...],
439
+ context: CommandContext,
440
+ github_client: GithubClient,
441
+ remote_name: str,
442
+ ) -> dict[str, RepoFacts | GithubClientError]:
443
+ """Batch first, then isolate only records that GitHub cannot decode."""
444
+
445
+ async def observe_one(change_id: str) -> RepoFacts | GithubClientError:
446
+ try:
447
+ return await observe_prs(
448
+ change_ids=(change_id,),
449
+ context=context,
450
+ github_client=github_client,
451
+ include_open_dependents=True,
452
+ include_open_head_prs=True,
453
+ remote_name=remote_name,
454
+ )
455
+ except GithubClientError as error:
456
+ return error
457
+
458
+ try:
459
+ observation = await observe_prs(
460
+ change_ids=change_ids,
461
+ context=context,
462
+ github_client=github_client,
463
+ include_open_dependents=True,
464
+ include_open_head_prs=True,
465
+ remote_name=remote_name,
466
+ )
467
+ except GithubClientError:
468
+ values = await run_bounded_tasks(
469
+ concurrency=DEFAULT_BOUNDED_CONCURRENCY,
470
+ items=change_ids,
471
+ run_item=observe_one,
472
+ )
473
+ else:
474
+ values = (observation,) * len(change_ids)
475
+ return dict(zip(change_ids, values, strict=True))
476
+
477
+
478
+ async def _cleanup_tracked_pr(
479
+ *,
480
+ github_client: GithubClient,
481
+ initial_observation: RepoFacts,
482
+ prepared_change: PreparedCleanupChange,
483
+ prepared_cleanup: PreparedCleanup,
484
+ preview_detached_dependents: frozenset[int],
485
+ record_action: Callable[[CleanupAction], None],
486
+ remote_name: str,
487
+ ) -> bool:
488
+ """Plan and apply one cleanup, returning whether a partial failure must stop the pass."""
489
+
490
+ candidate = prepared_change.candidate
491
+ identity = candidate.pr_identity
492
+ pr_state, update, blocker_action = _pr_cleanup_update(
493
+ close_open_prs=prepared_cleanup.close_open_prs,
494
+ observation=initial_observation,
495
+ prepared_change=prepared_change,
496
+ preview_detached_dependents=preview_detached_dependents,
497
+ )
498
+ if blocker_action is not None:
499
+ record_action(blocker_action)
500
+ return False
501
+ if pr_state == "open" and not prepared_cleanup.close_open_prs:
502
+ if prepared_change.stale_reason is not None:
503
+ record_action(
504
+ CleanupAction(
505
+ kind="tracking",
506
+ status="skipped",
507
+ body=t"preserve open orphan PR #{identity.pr_number}",
508
+ )
509
+ )
510
+ return False
511
+ if pr_state == "merged" and prepared_change.has_mutable_copy:
512
+ record_action(
513
+ CleanupAction(
514
+ kind="tracking",
515
+ status="skipped",
516
+ body=t"preserve merged PR #{identity.pr_number} for "
517
+ t"{ui.change_id(candidate.change_id)}; run "
518
+ t"{ui.cmd(f'sync {candidate.change_id}')} before cleanup",
519
+ )
520
+ )
521
+ return False
522
+ stack_blocker = await github_stack_cleanup_blocker(
523
+ github_client=github_client,
524
+ pr_number=identity.pr_number,
525
+ )
526
+ if stack_blocker is not None:
527
+ record_action(stack_blocker)
528
+ return False
529
+ overview_lookup = await _preflight_cleanup_overview_comment(
530
+ github_client=github_client,
531
+ identity=identity,
532
+ record_action=record_action,
533
+ )
534
+ if overview_lookup is None:
535
+ return False
536
+ if pr_state == "open":
537
+ close_action = CleanupAction(
538
+ kind="pull request",
539
+ status="planned" if prepared_cleanup.dry_run else "applied",
540
+ body=t"close PR #{identity.pr_number}",
541
+ )
542
+ if not prepared_cleanup.dry_run:
543
+ try:
544
+ await github_client.close_pr(pr_number=identity.pr_number)
545
+ except GithubClientError as error:
546
+ record_action(
547
+ CleanupAction(
548
+ kind="pull request",
549
+ status="blocked",
550
+ body=t"cannot close PR #{identity.pr_number}: {error}",
551
+ )
552
+ )
553
+ return True
554
+ record_action(close_action)
555
+ return await _apply_tracked_pr_cleanup(
556
+ branch_update=update,
557
+ overview_lookup=overview_lookup,
558
+ github_client=github_client,
559
+ prepared_change=prepared_change,
560
+ prepared_cleanup=prepared_cleanup,
561
+ record_action=record_action,
562
+ remote_name=remote_name,
563
+ )
564
+
565
+
566
+ def _pr_cleanup_update(
567
+ *,
568
+ close_open_prs: bool,
569
+ observation: RepoFacts,
570
+ prepared_change: PreparedCleanupChange,
571
+ preview_detached_dependents: frozenset[int] = frozenset(),
572
+ ) -> tuple[str, PRRefUpdate | None, CleanupAction | None]:
573
+ """Check the exact PR and derive its remote branch deletion."""
574
+
575
+ candidate = prepared_change.candidate
576
+ pr, blocker = check_tracked_pr(
577
+ allowed_states=frozenset({"open", "closed", "merged"}),
578
+ candidate=candidate,
579
+ observation=observation,
580
+ )
581
+ if blocker is not None:
582
+ return "blocked", None, blocker
583
+ if pr is None:
584
+ raise AssertionError("Exact cleanup lookup must return a pull request.")
585
+ if pr.state == "open" and not close_open_prs:
586
+ return "open", None, None
587
+ _pr, update, blocker = plan_pr_cleanup(
588
+ allowed_states=(
589
+ frozenset({"open", "closed", "merged"})
590
+ if close_open_prs
591
+ else frozenset({"closed", "merged"})
592
+ ),
593
+ candidate=candidate,
594
+ observation=observation,
595
+ preview_detached_dependents=preview_detached_dependents,
596
+ )
597
+ return pr.state, update, blocker
598
+
599
+
600
+ async def _preflight_cleanup_overview_comment(
601
+ *,
602
+ github_client: GithubClient,
603
+ identity: PRIdentity,
604
+ record_action: Callable[[CleanupAction], None],
605
+ ) -> OverviewCommentLookup | None:
606
+ """Resolve the overview comment, recording and stopping on an ambiguous lookup."""
607
+
608
+ lookup = await find_overview_comment(
609
+ github_client=github_client,
610
+ pr_number=identity.pr_number,
611
+ )
612
+ if lookup.blocked_reason is not None:
613
+ record_action(
614
+ CleanupAction(
615
+ kind=STACK_OVERVIEW_COMMENT_LABEL,
616
+ status="blocked",
617
+ body=lookup.blocked_reason,
618
+ )
619
+ )
620
+ return None
621
+ return lookup
622
+
623
+
624
+ async def _apply_tracked_pr_cleanup(
625
+ *,
626
+ branch_update: PRRefUpdate | None,
627
+ overview_lookup: OverviewCommentLookup,
628
+ github_client: GithubClient,
629
+ prepared_change: PreparedCleanupChange,
630
+ prepared_cleanup: PreparedCleanup,
631
+ record_action: Callable[[CleanupAction], None],
632
+ remote_name: str,
633
+ ) -> bool:
634
+ """Apply checked cleanup, returning whether a partial failure must stop the pass."""
635
+
636
+ candidate = prepared_change.candidate
637
+ mutation_started = not prepared_cleanup.dry_run and (
638
+ branch_update is not None or overview_lookup.comment is not None
639
+ )
640
+ apply_remote_branch_cleanup(
641
+ dry_run=prepared_cleanup.dry_run,
642
+ jj_client=prepared_cleanup.context.jj_client,
643
+ record_action=record_action,
644
+ remote_name=remote_name,
645
+ update=branch_update,
646
+ )
647
+ comment_actions, comments_current = await apply_overview_comment_cleanup(
648
+ dry_run=prepared_cleanup.dry_run,
649
+ github_client=github_client,
650
+ lookup=overview_lookup,
651
+ pr_number=candidate.pr_identity.pr_number,
652
+ )
653
+ for action in comment_actions:
654
+ record_action(action)
655
+ if not comments_current:
656
+ return mutation_started
657
+ reason = (
658
+ f" ({prepared_change.stale_reason})" if prepared_change.stale_reason is not None else ""
659
+ )
660
+ action = CleanupAction(
661
+ kind="tracking",
662
+ status="planned" if prepared_cleanup.dry_run else "applied",
663
+ body=t"forget PR #{candidate.pr_identity.pr_number} for "
664
+ t"{ui.change_id(candidate.change_id)}{reason}",
665
+ )
666
+ if prepared_cleanup.dry_run:
667
+ record_action(action)
668
+ else:
669
+ prepared_cleanup.context.state_store.retire_pr(
670
+ candidate.change_id,
671
+ )
672
+ record_action(action)
673
+ return False
674
+
675
+
676
+ def _load_cleanup_remote_context(*, prepared_cleanup: PreparedCleanup) -> PreparedCleanup:
677
+ """Resolve remote and GitHub target details once plain cleanup actually needs them."""
678
+
679
+ if prepared_cleanup.github_target is not None:
680
+ return prepared_cleanup
681
+ return replace(
682
+ prepared_cleanup,
683
+ github_target=resolve_github_target(
684
+ prepared_cleanup.context.jj_client.list_git_remotes()
685
+ ),
686
+ )
687
+
688
+
689
+ def _cleanup_needs_remote_context(
690
+ *,
691
+ prepared_cleanup: PreparedCleanup,
692
+ ) -> bool:
693
+ """Whether plain cleanup might need remote or GitHub state beyond local checks."""
694
+
695
+ return any(
696
+ change_id in prepared_cleanup.state.submitted_baselines
697
+ for change_id in (
698
+ tuple(prepared_cleanup.state.pr_identities)
699
+ if prepared_cleanup.selected_change_ids is None
700
+ else prepared_cleanup.selected_change_ids
701
+ )
702
+ )