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,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."""