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.
- jj_stack/__init__.py +11 -0
- jj_stack/__main__.py +8 -0
- jj_stack/bootstrap.py +213 -0
- jj_stack/cli.py +1297 -0
- jj_stack/cli_help.py +680 -0
- jj_stack/commands/__init__.py +1 -0
- jj_stack/commands/_cleanup_actions.py +389 -0
- jj_stack/commands/_json_status.py +84 -0
- jj_stack/commands/checkout.py +668 -0
- jj_stack/commands/cleanup/command.py +702 -0
- jj_stack/commands/cleanup/shared.py +62 -0
- jj_stack/commands/cleanup/stale.py +79 -0
- jj_stack/commands/doctor.py +358 -0
- jj_stack/commands/in_use.py +31 -0
- jj_stack/commands/list_.py +667 -0
- jj_stack/commands/merge/__init__.py +1 -0
- jj_stack/commands/merge/command.py +385 -0
- jj_stack/commands/merge/github_stack.py +333 -0
- jj_stack/commands/merge/models.py +97 -0
- jj_stack/commands/merge/plan.py +109 -0
- jj_stack/commands/merge/preconditions.py +162 -0
- jj_stack/commands/merge/render.py +52 -0
- jj_stack/commands/relink.py +210 -0
- jj_stack/commands/submit/__init__.py +1 -0
- jj_stack/commands/submit/auto_close.py +108 -0
- jj_stack/commands/submit/changes.py +90 -0
- jj_stack/commands/submit/command.py +780 -0
- jj_stack/commands/submit/descriptions.py +570 -0
- jj_stack/commands/submit/github_stack.py +139 -0
- jj_stack/commands/submit/inputs.py +168 -0
- jj_stack/commands/submit/models.py +198 -0
- jj_stack/commands/submit/overview_comments.py +178 -0
- jj_stack/commands/submit/prs.py +461 -0
- jj_stack/commands/submit/render.py +129 -0
- jj_stack/commands/sync.py +510 -0
- jj_stack/commands/sync_apply.py +474 -0
- jj_stack/commands/unstack.py +321 -0
- jj_stack/commands/view.py +1151 -0
- jj_stack/completion.py +484 -0
- jj_stack/concurrency.py +93 -0
- jj_stack/config.py +170 -0
- jj_stack/console.py +708 -0
- jj_stack/errors.py +152 -0
- jj_stack/formatting.py +95 -0
- jj_stack/github/__init__.py +1 -0
- jj_stack/github/auth.py +34 -0
- jj_stack/github/client.py +1098 -0
- jj_stack/github/error_messages.py +89 -0
- jj_stack/github/overview_comments.py +35 -0
- jj_stack/github/pr_refs.py +70 -0
- jj_stack/github/resolution.py +208 -0
- jj_stack/github/stack_availability.py +30 -0
- jj_stack/identifiers.py +7 -0
- jj_stack/jj/__init__.py +1 -0
- jj_stack/jj/cli_args.py +23 -0
- jj_stack/jj/client.py +1459 -0
- jj_stack/jj/colors.py +199 -0
- jj_stack/models/__init__.py +1 -0
- jj_stack/models/git.py +15 -0
- jj_stack/models/github.py +243 -0
- jj_stack/models/stack.py +70 -0
- jj_stack/models/tracking.py +115 -0
- jj_stack/pr_branch_namespace.py +78 -0
- jj_stack/stack/__init__.py +1 -0
- jj_stack/stack/change_status.py +216 -0
- jj_stack/stack/convergence.py +501 -0
- jj_stack/stack/convergence_models.py +69 -0
- jj_stack/stack/convergence_observation.py +162 -0
- jj_stack/stack/github_stack_safety.py +132 -0
- jj_stack/stack/global_convergence.py +270 -0
- jj_stack/stack/path.py +235 -0
- jj_stack/stack/pr_branches.py +126 -0
- jj_stack/stack/pr_facts.py +165 -0
- jj_stack/stack/repo.py +95 -0
- jj_stack/stack/selected.py +358 -0
- jj_stack/stack/selection.py +124 -0
- jj_stack/stack/status.py +711 -0
- jj_stack/stack/trunk_evidence.py +174 -0
- jj_stack/state/__init__.py +1 -0
- jj_stack/state/operation_lock.py +220 -0
- jj_stack/state/store.py +260 -0
- jj_stack/ui.py +230 -0
- jj_stack-0.1.0.dist-info/METADATA +170 -0
- jj_stack-0.1.0.dist-info/RECORD +88 -0
- jj_stack-0.1.0.dist-info/WHEEL +4 -0
- jj_stack-0.1.0.dist-info/entry_points.txt +2 -0
- jj_stack-0.1.0.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|