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,667 @@
|
|
|
1
|
+
"""List the stacks `jj-stack` is tracking in this local repo.
|
|
2
|
+
|
|
3
|
+
It shows one row per locally known stack, including the head change ID, stack size, PR state,
|
|
4
|
+
and description of the head change. It does not discover stacks that exist only on GitHub.
|
|
5
|
+
|
|
6
|
+
It also shows orphaned PRs: tracked PRs whose local change is no longer part of any current
|
|
7
|
+
stack. Close them and remove their branches, comments, and saved links with
|
|
8
|
+
`jj-stack cleanup --pull-request orphans --close`.
|
|
9
|
+
|
|
10
|
+
It reads pull request state from GitHub, but discovers stack membership from the local DAG using
|
|
11
|
+
local `trunk()` as the lower boundary. If your local copy of trunk is behind, `jj-stack`'s picture
|
|
12
|
+
of membership can be stale even though the pull request state is current. Run `jj git fetch`
|
|
13
|
+
first when the list needs to reflect the latest trunk.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import sys
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import jj_stack.console as console
|
|
24
|
+
import jj_stack.ui as ui
|
|
25
|
+
from jj_stack.bootstrap import CommandContext, bootstrap_context
|
|
26
|
+
from jj_stack.commands._json_status import (
|
|
27
|
+
saved_pr_json,
|
|
28
|
+
stack_change_json,
|
|
29
|
+
)
|
|
30
|
+
from jj_stack.console import requested_color_mode
|
|
31
|
+
from jj_stack.errors import EXIT_INCOMPLETE, CliError, ErrorMessage, error_message
|
|
32
|
+
from jj_stack.github.resolution import (
|
|
33
|
+
GithubTarget,
|
|
34
|
+
UnresolvedGithubTarget,
|
|
35
|
+
resolve_github_target,
|
|
36
|
+
)
|
|
37
|
+
from jj_stack.jj.cli_args import JjCliArgs
|
|
38
|
+
from jj_stack.models.stack import LocalStack
|
|
39
|
+
from jj_stack.models.tracking import TrackingState
|
|
40
|
+
from jj_stack.stack.change_status import (
|
|
41
|
+
ChangeStatus,
|
|
42
|
+
OrphanedRecord,
|
|
43
|
+
classify_stack_status_change,
|
|
44
|
+
enumerate_orphaned_records,
|
|
45
|
+
submitted_state_disagreement,
|
|
46
|
+
)
|
|
47
|
+
from jj_stack.stack.pr_branches import duplicate_pr_branch_claims
|
|
48
|
+
from jj_stack.stack.repo import observe_repo_paths
|
|
49
|
+
from jj_stack.stack.status import (
|
|
50
|
+
PreparedStack,
|
|
51
|
+
PRLookup,
|
|
52
|
+
StackStatusChange,
|
|
53
|
+
build_status_changes_for_prepared_stack,
|
|
54
|
+
lookup_pr_lookups,
|
|
55
|
+
observe_remote_targets_for_status,
|
|
56
|
+
prepare_stack_for_status,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
HELP = "List the stacks `jj-stack` is tracking in this repo"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class StackRow:
|
|
64
|
+
changes: tuple[StackStatusChange, ...]
|
|
65
|
+
current: bool
|
|
66
|
+
current_change_ids: frozenset[str]
|
|
67
|
+
head_change_id: str
|
|
68
|
+
incomplete: bool
|
|
69
|
+
prs: str
|
|
70
|
+
size: int
|
|
71
|
+
state: ui.Message
|
|
72
|
+
subject: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class OrphanRow:
|
|
77
|
+
"""One orphaned PR — its local change has left every current stack."""
|
|
78
|
+
|
|
79
|
+
branch: str
|
|
80
|
+
change_id: str
|
|
81
|
+
pr: dict[str, object] | None
|
|
82
|
+
pr_label: str
|
|
83
|
+
state: ui.Message
|
|
84
|
+
subject: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class _PreparedDiscoveredStack:
|
|
89
|
+
current: bool
|
|
90
|
+
prepared: PreparedStack
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def list_(
|
|
94
|
+
*,
|
|
95
|
+
as_json: bool,
|
|
96
|
+
cli_args: JjCliArgs,
|
|
97
|
+
debug: bool,
|
|
98
|
+
repo: Path | None,
|
|
99
|
+
) -> int:
|
|
100
|
+
"""CLI entrypoint for `list`."""
|
|
101
|
+
|
|
102
|
+
context = bootstrap_context(
|
|
103
|
+
repo=repo,
|
|
104
|
+
cli_args=cli_args,
|
|
105
|
+
debug=debug,
|
|
106
|
+
)
|
|
107
|
+
return _run_list(
|
|
108
|
+
as_json=as_json,
|
|
109
|
+
context=context,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _run_list(
|
|
114
|
+
*,
|
|
115
|
+
as_json: bool,
|
|
116
|
+
context: CommandContext,
|
|
117
|
+
) -> int:
|
|
118
|
+
state = context.state_store.load()
|
|
119
|
+
if state.pr_identities:
|
|
120
|
+
with console.spinner(description="Inspecting local stacks"):
|
|
121
|
+
repo_paths = observe_repo_paths(
|
|
122
|
+
jj_client=context.jj_client,
|
|
123
|
+
state=state,
|
|
124
|
+
)
|
|
125
|
+
discovered = tuple(path.stack for path in repo_paths.paths if path.tracked_change_ids)
|
|
126
|
+
current_tracked_commit_id = repo_paths.current_tracked_commit_id
|
|
127
|
+
else:
|
|
128
|
+
discovered = ()
|
|
129
|
+
current_tracked_commit_id = None
|
|
130
|
+
|
|
131
|
+
ordered = _order_discovered_stacks(
|
|
132
|
+
discovered,
|
|
133
|
+
current_tracked_commit_id=current_tracked_commit_id,
|
|
134
|
+
)
|
|
135
|
+
duplicate_branches = duplicate_pr_branch_claims(
|
|
136
|
+
(identity.head_ref, change.change_id)
|
|
137
|
+
for stack in ordered
|
|
138
|
+
for change in stack.changes
|
|
139
|
+
if (identity := state.pr_identities.get(change.change_id)) is not None
|
|
140
|
+
)
|
|
141
|
+
duplicate_branch_names = frozenset(duplicate_branches)
|
|
142
|
+
orphan_rows = tuple(
|
|
143
|
+
_build_orphan_row(orphan) for orphan in enumerate_orphaned_records(state, ordered)
|
|
144
|
+
)
|
|
145
|
+
if not ordered:
|
|
146
|
+
if as_json:
|
|
147
|
+
console.machine_output(
|
|
148
|
+
json.dumps(
|
|
149
|
+
_json_list_payload(orphan_rows=orphan_rows, rows=()),
|
|
150
|
+
indent=2,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return 0
|
|
154
|
+
if not orphan_rows:
|
|
155
|
+
console.output("No stacks.")
|
|
156
|
+
return 0
|
|
157
|
+
color_when = context.jj_client.resolve_color_when(
|
|
158
|
+
cli_color=requested_color_mode(),
|
|
159
|
+
stdout_is_tty=sys.stdout.isatty(),
|
|
160
|
+
)
|
|
161
|
+
with console.spinner(description="Rendering jj change IDs"):
|
|
162
|
+
rendered_change_ids = context.jj_client.render_short_change_ids(
|
|
163
|
+
tuple(row.change_id for row in orphan_rows),
|
|
164
|
+
color_when=color_when,
|
|
165
|
+
)
|
|
166
|
+
console.output(
|
|
167
|
+
_stack_table(
|
|
168
|
+
orphan_rows=orphan_rows,
|
|
169
|
+
rendered_change_ids=rendered_change_ids,
|
|
170
|
+
rows=(),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
_emit_orphan_hint(orphan_rows)
|
|
174
|
+
return 0
|
|
175
|
+
github_target = resolve_github_target(context.jj_client.list_git_remotes())
|
|
176
|
+
with console.spinner(description="Inspecting PR branches"):
|
|
177
|
+
observed_remote_targets = observe_remote_targets_for_status(
|
|
178
|
+
context=context,
|
|
179
|
+
excluded_branches=duplicate_branch_names,
|
|
180
|
+
remote=github_target.remote,
|
|
181
|
+
stacks=ordered,
|
|
182
|
+
state=state,
|
|
183
|
+
)
|
|
184
|
+
prepared_discovered = tuple(
|
|
185
|
+
_PreparedDiscoveredStack(
|
|
186
|
+
current=_stack_contains_commit_id(
|
|
187
|
+
stack,
|
|
188
|
+
commit_id=current_tracked_commit_id,
|
|
189
|
+
),
|
|
190
|
+
prepared=prepare_stack_for_status(
|
|
191
|
+
context=context,
|
|
192
|
+
observed_remote_targets=observed_remote_targets,
|
|
193
|
+
remote=github_target.remote,
|
|
194
|
+
remote_error=github_target.remote_error,
|
|
195
|
+
stack=stack,
|
|
196
|
+
state=state,
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
for stack in ordered
|
|
200
|
+
)
|
|
201
|
+
for branch, change_ids in sorted(duplicate_branches.items()):
|
|
202
|
+
console.warning(
|
|
203
|
+
t"PR branch {ui.bookmark(branch)} is saved for changes "
|
|
204
|
+
t"{ui.join(ui.change_id, change_ids)}. Live GitHub details for those changes "
|
|
205
|
+
t"were not inspected."
|
|
206
|
+
)
|
|
207
|
+
pr_lookups, github_error = _load_pr_lookups(
|
|
208
|
+
excluded_branches=duplicate_branch_names,
|
|
209
|
+
github_target=github_target,
|
|
210
|
+
prepared_discovered=prepared_discovered,
|
|
211
|
+
)
|
|
212
|
+
rows = tuple(
|
|
213
|
+
_build_row(
|
|
214
|
+
github_error=github_target.github_repo_error or github_error,
|
|
215
|
+
is_current=item.current,
|
|
216
|
+
prepared_stack=item.prepared,
|
|
217
|
+
pr_lookups=pr_lookups,
|
|
218
|
+
)
|
|
219
|
+
for item in prepared_discovered
|
|
220
|
+
)
|
|
221
|
+
incomplete = bool(duplicate_branches) or any(row.incomplete for row in rows)
|
|
222
|
+
if as_json:
|
|
223
|
+
console.machine_output(
|
|
224
|
+
json.dumps(
|
|
225
|
+
_json_list_payload(orphan_rows=orphan_rows, rows=rows),
|
|
226
|
+
indent=2,
|
|
227
|
+
)
|
|
228
|
+
)
|
|
229
|
+
return EXIT_INCOMPLETE if incomplete else 0
|
|
230
|
+
color_when = context.jj_client.resolve_color_when(
|
|
231
|
+
cli_color=requested_color_mode(),
|
|
232
|
+
stdout_is_tty=sys.stdout.isatty(),
|
|
233
|
+
)
|
|
234
|
+
head_change_ids_to_render = tuple(row.head_change_id for row in rows) + tuple(
|
|
235
|
+
row.change_id for row in orphan_rows
|
|
236
|
+
)
|
|
237
|
+
with console.spinner(description="Rendering jj change IDs"):
|
|
238
|
+
rendered_change_ids = context.jj_client.render_short_change_ids(
|
|
239
|
+
head_change_ids_to_render,
|
|
240
|
+
color_when=color_when,
|
|
241
|
+
)
|
|
242
|
+
console.output(
|
|
243
|
+
_stack_table(
|
|
244
|
+
orphan_rows=orphan_rows,
|
|
245
|
+
rendered_change_ids=rendered_change_ids,
|
|
246
|
+
rows=rows,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
_emit_orphan_hint(orphan_rows)
|
|
250
|
+
_emit_stale_stacks_advisory(discovered=ordered, state=state)
|
|
251
|
+
return EXIT_INCOMPLETE if incomplete else 0
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _build_orphan_row(orphan: OrphanedRecord) -> OrphanRow:
|
|
255
|
+
pr_number = orphan.pr_identity.pr_number
|
|
256
|
+
return OrphanRow(
|
|
257
|
+
branch=orphan.pr_identity.head_ref,
|
|
258
|
+
change_id=orphan.change_id,
|
|
259
|
+
pr=saved_pr_json(orphan.pr_identity),
|
|
260
|
+
pr_label=f"PR #{pr_number}",
|
|
261
|
+
state=ui.semantic_text("orphan", "warning", "heading"),
|
|
262
|
+
subject="local change missing",
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _json_list_payload(
|
|
267
|
+
*,
|
|
268
|
+
orphan_rows: tuple[OrphanRow, ...],
|
|
269
|
+
rows: tuple[StackRow, ...],
|
|
270
|
+
) -> dict[str, object]:
|
|
271
|
+
return {
|
|
272
|
+
"rows": [
|
|
273
|
+
*(_json_stack_row(row) for row in rows),
|
|
274
|
+
*(_json_orphan_row(row) for row in orphan_rows),
|
|
275
|
+
],
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _json_stack_row(row: StackRow) -> dict[str, object]:
|
|
280
|
+
payload: dict[str, object] = {
|
|
281
|
+
"changes": [
|
|
282
|
+
stack_change_json(
|
|
283
|
+
change,
|
|
284
|
+
current=change.change_id in row.current_change_ids,
|
|
285
|
+
)
|
|
286
|
+
for change in row.changes
|
|
287
|
+
],
|
|
288
|
+
"status": ui.plain_text(row.state),
|
|
289
|
+
"subject": row.subject,
|
|
290
|
+
"type": "stack",
|
|
291
|
+
}
|
|
292
|
+
if row.current:
|
|
293
|
+
payload["current"] = True
|
|
294
|
+
return payload
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _json_orphan_row(row: OrphanRow) -> dict[str, object]:
|
|
298
|
+
payload: dict[str, object] = {
|
|
299
|
+
"branch": row.branch,
|
|
300
|
+
"change_id": row.change_id,
|
|
301
|
+
"status": ui.plain_text(row.state),
|
|
302
|
+
"subject": row.subject,
|
|
303
|
+
"type": "orphan",
|
|
304
|
+
}
|
|
305
|
+
if row.pr is not None:
|
|
306
|
+
payload["pr"] = row.pr
|
|
307
|
+
return payload
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _emit_orphan_hint(orphan_rows: tuple[OrphanRow, ...]) -> None:
|
|
311
|
+
if not orphan_rows:
|
|
312
|
+
return
|
|
313
|
+
command = ui.cmd("cleanup --pull-request orphans --close")
|
|
314
|
+
console.note(t"Orphan cleanup: preview and run {command}.")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _emit_stale_stacks_advisory(
|
|
318
|
+
*,
|
|
319
|
+
discovered: tuple[LocalStack, ...],
|
|
320
|
+
state: TrackingState,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Hint that tracked stacks have changed since their last successful submit.
|
|
323
|
+
|
|
324
|
+
Submitted-state disagreement means the saved baseline from the last successful
|
|
325
|
+
submit no longer matches the live DAG. The right follow-up can depend on the specific
|
|
326
|
+
stack state, so this advisory directs the user to inspect each stack rather
|
|
327
|
+
than naming one mutation.
|
|
328
|
+
"""
|
|
329
|
+
|
|
330
|
+
stale_heads = tuple(
|
|
331
|
+
stack.head.change_id
|
|
332
|
+
for stack in discovered
|
|
333
|
+
if submitted_state_disagreement(state, (stack,))
|
|
334
|
+
)
|
|
335
|
+
if not stale_heads:
|
|
336
|
+
return
|
|
337
|
+
if len(stale_heads) == 1:
|
|
338
|
+
head = stale_heads[0][:8]
|
|
339
|
+
console.warning(
|
|
340
|
+
(
|
|
341
|
+
"Tracked stack has changed since its last submit; ",
|
|
342
|
+
t"inspect with {ui.cmd(f'jj-stack view {head}')} or refresh with "
|
|
343
|
+
t"{ui.cmd(f'jj-stack submit {head}')}.",
|
|
344
|
+
)
|
|
345
|
+
)
|
|
346
|
+
return
|
|
347
|
+
heads_fragments = ui.join(ui.change_id, stale_heads)
|
|
348
|
+
console.warning(
|
|
349
|
+
(
|
|
350
|
+
"Tracked stacks have changed since their last submit; ",
|
|
351
|
+
t"inspect with {ui.cmd('jj-stack view <head>')} or refresh with "
|
|
352
|
+
t"{ui.cmd('jj-stack submit <head>')}: ",
|
|
353
|
+
*heads_fragments,
|
|
354
|
+
)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _order_discovered_stacks(
|
|
359
|
+
discovered: tuple[LocalStack, ...],
|
|
360
|
+
*,
|
|
361
|
+
current_tracked_commit_id: str | None,
|
|
362
|
+
) -> tuple[LocalStack, ...]:
|
|
363
|
+
return tuple(
|
|
364
|
+
sorted(
|
|
365
|
+
discovered,
|
|
366
|
+
key=lambda stack: (
|
|
367
|
+
0
|
|
368
|
+
if _stack_contains_commit_id(
|
|
369
|
+
stack,
|
|
370
|
+
commit_id=current_tracked_commit_id,
|
|
371
|
+
)
|
|
372
|
+
else 1,
|
|
373
|
+
stack.head.change_id,
|
|
374
|
+
),
|
|
375
|
+
)
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _stack_contains_commit_id(
|
|
380
|
+
stack: LocalStack,
|
|
381
|
+
*,
|
|
382
|
+
commit_id: str | None,
|
|
383
|
+
) -> bool:
|
|
384
|
+
if commit_id is None:
|
|
385
|
+
return False
|
|
386
|
+
return any(change.commit_id == commit_id for change in stack.changes)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _build_row(
|
|
390
|
+
*,
|
|
391
|
+
github_error: ErrorMessage | None,
|
|
392
|
+
is_current: bool,
|
|
393
|
+
prepared_stack: PreparedStack,
|
|
394
|
+
pr_lookups: dict[str, PRLookup],
|
|
395
|
+
) -> StackRow:
|
|
396
|
+
stack = prepared_stack.stack
|
|
397
|
+
changes = build_status_changes_for_prepared_stack(
|
|
398
|
+
prepared_stack,
|
|
399
|
+
pr_lookups=pr_lookups,
|
|
400
|
+
)
|
|
401
|
+
statuses = tuple(classify_stack_status_change(change) for change in changes)
|
|
402
|
+
pr_numbers = _pr_numbers_from_changes(changes)
|
|
403
|
+
prs = _format_pr_summary(pr_numbers)
|
|
404
|
+
local_fragments: list[ui.Message] = []
|
|
405
|
+
if any(change.divergent for change in stack.changes):
|
|
406
|
+
local_fragments.append(ui.semantic_text("divergent", "error", "heading"))
|
|
407
|
+
if any(change.conflict for change in stack.changes):
|
|
408
|
+
local_fragments.append(ui.semantic_text("conflicted", "error", "heading"))
|
|
409
|
+
state = _state_from_status(
|
|
410
|
+
github_error=github_error,
|
|
411
|
+
local_fragments=tuple(local_fragments),
|
|
412
|
+
remote_error=prepared_stack.remote_error,
|
|
413
|
+
changes=changes,
|
|
414
|
+
statuses=statuses,
|
|
415
|
+
)
|
|
416
|
+
return StackRow(
|
|
417
|
+
changes=changes,
|
|
418
|
+
current=is_current,
|
|
419
|
+
current_change_ids=frozenset(
|
|
420
|
+
change.change_id for change in stack.changes if change.current_working_copy
|
|
421
|
+
),
|
|
422
|
+
head_change_id=stack.head.change_id,
|
|
423
|
+
incomplete=_status_is_incomplete(
|
|
424
|
+
github_error=github_error,
|
|
425
|
+
remote_error=prepared_stack.remote_error,
|
|
426
|
+
statuses=statuses,
|
|
427
|
+
),
|
|
428
|
+
prs=prs,
|
|
429
|
+
size=len(stack.changes),
|
|
430
|
+
state=state,
|
|
431
|
+
subject=stack.head.subject,
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _state_from_status(
|
|
436
|
+
*,
|
|
437
|
+
github_error: ErrorMessage | None,
|
|
438
|
+
local_fragments: tuple[ui.Message, ...],
|
|
439
|
+
remote_error: ErrorMessage | None,
|
|
440
|
+
changes: tuple[StackStatusChange, ...],
|
|
441
|
+
statuses: tuple[ChangeStatus, ...] | None = None,
|
|
442
|
+
) -> ui.Message:
|
|
443
|
+
if statuses is None:
|
|
444
|
+
statuses = tuple(classify_stack_status_change(change) for change in changes)
|
|
445
|
+
fragments = [
|
|
446
|
+
*local_fragments,
|
|
447
|
+
*_status_fragments(
|
|
448
|
+
github_error=github_error,
|
|
449
|
+
remote_error=remote_error,
|
|
450
|
+
statuses=statuses,
|
|
451
|
+
),
|
|
452
|
+
]
|
|
453
|
+
if fragments:
|
|
454
|
+
joined: list[ui.Message] = []
|
|
455
|
+
for index, fragment in enumerate(fragments):
|
|
456
|
+
if index:
|
|
457
|
+
joined.append(", ")
|
|
458
|
+
joined.append(fragment)
|
|
459
|
+
return tuple(joined)
|
|
460
|
+
if any(status.saved_pr_identity for status in statuses):
|
|
461
|
+
return "tracked"
|
|
462
|
+
return "not submitted"
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _status_fragments(
|
|
466
|
+
*,
|
|
467
|
+
github_error: ErrorMessage | None,
|
|
468
|
+
remote_error: ErrorMessage | None,
|
|
469
|
+
statuses: tuple[ChangeStatus, ...],
|
|
470
|
+
) -> tuple[ui.Message, ...]:
|
|
471
|
+
fragments: list[ui.Message] = []
|
|
472
|
+
if github_error is not None or remote_error is not None:
|
|
473
|
+
fragments.append(ui.semantic_text("GitHub unavailable", "warning", "heading"))
|
|
474
|
+
|
|
475
|
+
merged_ancestors = sum(1 for status in statuses if status.pr_lifecycle == "merged")
|
|
476
|
+
if merged_ancestors:
|
|
477
|
+
label = (
|
|
478
|
+
"cleanup needed"
|
|
479
|
+
if merged_ancestors == 1
|
|
480
|
+
else f"{merged_ancestors} merged, cleanup needed"
|
|
481
|
+
)
|
|
482
|
+
fragments.append(ui.semantic_text(label, "warning", "heading"))
|
|
483
|
+
|
|
484
|
+
closed = sum(1 for status in statuses if status.pr_lifecycle == "closed")
|
|
485
|
+
if closed:
|
|
486
|
+
label = "closed" if closed == 1 else f"{closed} closed"
|
|
487
|
+
fragments.append(ui.semantic_text(label, "warning", "heading"))
|
|
488
|
+
|
|
489
|
+
stale_links = sum(1 for status in statuses if status.has_stale_pr_link)
|
|
490
|
+
if stale_links:
|
|
491
|
+
label = "stale link" if stale_links == 1 else f"{stale_links} stale links"
|
|
492
|
+
fragments.append(ui.semantic_text(label, "warning", "heading"))
|
|
493
|
+
|
|
494
|
+
ambiguous = sum(1 for status in statuses if status.pr_lifecycle == "ambiguous")
|
|
495
|
+
if ambiguous:
|
|
496
|
+
label = "ambiguous PR" if ambiguous == 1 else f"{ambiguous} ambiguous PRs"
|
|
497
|
+
fragments.append(ui.semantic_text(label, "warning", "heading"))
|
|
498
|
+
|
|
499
|
+
lookup_failures = sum(1 for status in statuses if status.has_pr_lookup_failure)
|
|
500
|
+
if lookup_failures:
|
|
501
|
+
label = (
|
|
502
|
+
"GitHub lookup failed"
|
|
503
|
+
if lookup_failures == 1
|
|
504
|
+
else f"{lookup_failures} GitHub lookups failed"
|
|
505
|
+
)
|
|
506
|
+
fragments.append(ui.semantic_text(label, "warning", "heading"))
|
|
507
|
+
|
|
508
|
+
queued = sum(1 for status in statuses if status.pr_queued is True)
|
|
509
|
+
if queued:
|
|
510
|
+
label = "queued" if queued == 1 else f"{queued} queued"
|
|
511
|
+
fragments.append(ui.semantic_text(label, "hint", "heading"))
|
|
512
|
+
|
|
513
|
+
drafts = sum(
|
|
514
|
+
1 for status in statuses if status.pr_draft is True and status.pr_queued is not True
|
|
515
|
+
)
|
|
516
|
+
if drafts:
|
|
517
|
+
label = "draft" if drafts == 1 else f"{drafts} drafts"
|
|
518
|
+
fragments.append(ui.semantic_text(label, "hint", "heading"))
|
|
519
|
+
|
|
520
|
+
open_non_draft_decisions = tuple(
|
|
521
|
+
status.pr_review_decision
|
|
522
|
+
for status in statuses
|
|
523
|
+
if status.pr_lifecycle == "open"
|
|
524
|
+
and status.pr_draft is False
|
|
525
|
+
and status.pr_queued is not True
|
|
526
|
+
)
|
|
527
|
+
changes_requested = sum(
|
|
528
|
+
1 for decision in open_non_draft_decisions if decision == "changes_requested"
|
|
529
|
+
)
|
|
530
|
+
if changes_requested:
|
|
531
|
+
label = (
|
|
532
|
+
"changes requested"
|
|
533
|
+
if changes_requested == 1
|
|
534
|
+
else f"{changes_requested} changes requested"
|
|
535
|
+
)
|
|
536
|
+
fragments.append(ui.semantic_text(label, "warning", "heading"))
|
|
537
|
+
|
|
538
|
+
approved = sum(1 for decision in open_non_draft_decisions if decision == "approved")
|
|
539
|
+
open_neutral = sum(
|
|
540
|
+
1
|
|
541
|
+
for decision in open_non_draft_decisions
|
|
542
|
+
if decision not in {"approved", "changes_requested"}
|
|
543
|
+
)
|
|
544
|
+
total_open = approved + changes_requested + drafts + open_neutral
|
|
545
|
+
if approved:
|
|
546
|
+
label = "approved" if approved == total_open else f"{approved} approved"
|
|
547
|
+
fragments.append(ui.semantic_text(label, "hint", "heading"))
|
|
548
|
+
if open_neutral:
|
|
549
|
+
label = "open" if open_neutral == 1 else f"{open_neutral} open"
|
|
550
|
+
fragments.append(label)
|
|
551
|
+
return tuple(fragments)
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def _status_is_incomplete(
|
|
555
|
+
*,
|
|
556
|
+
github_error: ErrorMessage | None,
|
|
557
|
+
remote_error: ErrorMessage | None,
|
|
558
|
+
statuses: tuple[ChangeStatus, ...],
|
|
559
|
+
) -> bool:
|
|
560
|
+
if github_error is not None or remote_error is not None:
|
|
561
|
+
return True
|
|
562
|
+
return any(status.makes_report_incomplete for status in statuses)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _pr_numbers_from_changes(
|
|
566
|
+
changes: tuple[StackStatusChange, ...],
|
|
567
|
+
) -> tuple[int, ...]:
|
|
568
|
+
numbers: list[int] = []
|
|
569
|
+
for change in changes:
|
|
570
|
+
lookup = change.pr_lookup
|
|
571
|
+
if lookup is not None and lookup.pr is not None:
|
|
572
|
+
numbers.append(lookup.pr.number)
|
|
573
|
+
continue
|
|
574
|
+
pr_identity = change.pr_identity
|
|
575
|
+
if pr_identity is not None:
|
|
576
|
+
numbers.append(pr_identity.pr_number)
|
|
577
|
+
return tuple(sorted(dict.fromkeys(numbers)))
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _load_pr_lookups(
|
|
581
|
+
*,
|
|
582
|
+
excluded_branches: frozenset[str],
|
|
583
|
+
github_target: GithubTarget | UnresolvedGithubTarget,
|
|
584
|
+
prepared_discovered: tuple[_PreparedDiscoveredStack, ...],
|
|
585
|
+
) -> tuple[dict[str, PRLookup], ErrorMessage | None]:
|
|
586
|
+
if not isinstance(github_target, GithubTarget):
|
|
587
|
+
return {}, None
|
|
588
|
+
|
|
589
|
+
prepared_changes_by_branch = {
|
|
590
|
+
branch: change
|
|
591
|
+
for item in prepared_discovered
|
|
592
|
+
for change in item.prepared.status_changes
|
|
593
|
+
if change.pr_identity is not None
|
|
594
|
+
and (branch := change.branch) is not None
|
|
595
|
+
and branch not in excluded_branches
|
|
596
|
+
}
|
|
597
|
+
if not prepared_changes_by_branch:
|
|
598
|
+
return {}, None
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
with console.progress(
|
|
602
|
+
description="Inspecting GitHub",
|
|
603
|
+
total=len(prepared_changes_by_branch),
|
|
604
|
+
) as progress:
|
|
605
|
+
return (
|
|
606
|
+
lookup_pr_lookups(
|
|
607
|
+
github_repo=github_target.repo,
|
|
608
|
+
on_progress=progress.advance,
|
|
609
|
+
prepared_changes=tuple(prepared_changes_by_branch.values()),
|
|
610
|
+
),
|
|
611
|
+
None,
|
|
612
|
+
)
|
|
613
|
+
except CliError as error:
|
|
614
|
+
return {}, error_message(error)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _format_pr_summary(numbers: tuple[int, ...]) -> str:
|
|
618
|
+
if not numbers:
|
|
619
|
+
return ""
|
|
620
|
+
if len(numbers) == 1:
|
|
621
|
+
return f"PR {numbers[0]}"
|
|
622
|
+
return f"{len(numbers)} PRs"
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _stack_table(
|
|
626
|
+
*,
|
|
627
|
+
orphan_rows: tuple[OrphanRow, ...],
|
|
628
|
+
rendered_change_ids: dict[str, str],
|
|
629
|
+
rows: tuple[StackRow, ...],
|
|
630
|
+
) -> ui.DataTable:
|
|
631
|
+
stack_table_rows = [
|
|
632
|
+
(
|
|
633
|
+
(
|
|
634
|
+
f"@ {rendered_change_ids.get(row.head_change_id, row.head_change_id[:8])}"
|
|
635
|
+
if row.current
|
|
636
|
+
else rendered_change_ids.get(row.head_change_id, row.head_change_id[:8])
|
|
637
|
+
),
|
|
638
|
+
f"{row.size} {'change' if row.size == 1 else 'changes'}",
|
|
639
|
+
row.prs,
|
|
640
|
+
row.state,
|
|
641
|
+
row.subject,
|
|
642
|
+
)
|
|
643
|
+
for row in rows
|
|
644
|
+
]
|
|
645
|
+
for orphan in orphan_rows:
|
|
646
|
+
stack_table_rows.append(
|
|
647
|
+
(
|
|
648
|
+
rendered_change_ids.get(orphan.change_id, orphan.change_id[:8]),
|
|
649
|
+
"orphan",
|
|
650
|
+
orphan.pr_label,
|
|
651
|
+
orphan.state,
|
|
652
|
+
orphan.subject,
|
|
653
|
+
)
|
|
654
|
+
)
|
|
655
|
+
return ui.DataTable(
|
|
656
|
+
columns=(
|
|
657
|
+
ui.TableColumn("head", no_wrap=True),
|
|
658
|
+
ui.TableColumn("size", no_wrap=True),
|
|
659
|
+
ui.TableColumn("PRs", no_wrap=True),
|
|
660
|
+
ui.TableColumn("state"),
|
|
661
|
+
ui.TableColumn("description"),
|
|
662
|
+
),
|
|
663
|
+
pad_edge=False,
|
|
664
|
+
padding=(0, 0),
|
|
665
|
+
show_edge=False,
|
|
666
|
+
rows=tuple(stack_table_rows),
|
|
667
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Merge command implementation package."""
|