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,389 @@
1
+ """Shared PR checks and cleanup helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass
7
+ from typing import Literal
8
+
9
+ import jj_stack.console as console
10
+ import jj_stack.ui as ui
11
+ from jj_stack.commands.cleanup.shared import CleanupAction
12
+ from jj_stack.errors import CliError
13
+ from jj_stack.github.client import GithubClient, GithubClientError
14
+ from jj_stack.github.overview_comments import (
15
+ STACK_OVERVIEW_COMMENT_LABEL,
16
+ delete_stack_overview_comment,
17
+ is_overview_comment,
18
+ )
19
+ from jj_stack.jj.client import JjClient, PRRefUpdate
20
+ from jj_stack.models.github import GithubIssueComment, GithubPR
21
+ from jj_stack.models.tracking import TrackedPR
22
+ from jj_stack.pr_branch_namespace import pr_branch_matches_change
23
+ from jj_stack.stack.github_stack_safety import GithubStackSelection
24
+ from jj_stack.stack.pr_facts import RepoFacts, has_competing_open_pr
25
+ from jj_stack.ui import Message
26
+
27
+ ActionPresentationStatus = Literal["applied", "blocked", "planned", "skipped"]
28
+
29
+
30
+ def check_tracked_pr(
31
+ *,
32
+ allowed_states: frozenset[str],
33
+ candidate: TrackedPR,
34
+ observation: RepoFacts,
35
+ preview_detached_dependents: frozenset[int] = frozenset(),
36
+ require_no_open_dependents: bool = False,
37
+ ) -> tuple[GithubPR | None, CleanupAction | None]:
38
+ """Check one exact unchanged PR against shared facts."""
39
+
40
+ change_id = candidate.change_id
41
+ pr_identity = candidate.pr_identity
42
+ submitted_baseline = candidate.submitted_baseline
43
+ observed = observation.prs[change_id]
44
+ pr_number = pr_identity.pr_number
45
+ repo_key = observation.repo.repo_key
46
+ pr = observed.pr
47
+ kind = "pull request"
48
+ reason: Message | None = None
49
+ if (observed.identity, observed.baseline) != (pr_identity, submitted_baseline):
50
+ kind = "tracking"
51
+ reason = (
52
+ t"tracking for {ui.change_id(change_id)} changed while this command ran; "
53
+ t"rerun the same command"
54
+ )
55
+ elif pr_identity.repo_key != repo_key:
56
+ reason = (
57
+ t"cannot inspect saved PR #{pr_number} because it belongs to a "
58
+ t"different GitHub repo; point the remote back at it, or reattach the "
59
+ t"change with {ui.cmd('jj-stack relink')}"
60
+ )
61
+ elif pr is None:
62
+ reason = (
63
+ t"PR #{pr_number} is no longer on GitHub; attach a replacement with "
64
+ t"{ui.cmd('jj-stack relink')}, or drop the tracking with "
65
+ t"{ui.cmd('jj-stack unstack --local')}"
66
+ )
67
+ else:
68
+ pr = pr.normalize_state()
69
+ if reason is None:
70
+ assert pr is not None
71
+ if not pr_identity.matches_pr(pr):
72
+ reason = (
73
+ t"cannot inspect saved PR #{pr_number} because its live PR no longer "
74
+ t"matches {ui.bookmark(pr_identity.head_ref)}"
75
+ )
76
+ elif not candidate.matches_snapshot(pr, repo_key=repo_key):
77
+ reason = (
78
+ t"cannot mutate saved PR #{pr_number} because its head no longer "
79
+ t"matches the saved submitted commit"
80
+ )
81
+ elif pr.state not in allowed_states:
82
+ reason = (
83
+ t"cannot mutate saved PR #{pr_number} because GitHub now reports "
84
+ t"state {pr.state!r}"
85
+ )
86
+ check_dependents = (reason is None, require_no_open_dependents) == (True, True)
87
+ if check_dependents:
88
+ open_prs_by_base = observation.open_prs_by_base
89
+ assert open_prs_by_base is not None
90
+ observed_dependents = open_prs_by_base.get(pr_identity.head_ref, ())
91
+ dependents = tuple(
92
+ filter(
93
+ lambda item: item.number not in preview_detached_dependents,
94
+ observed_dependents,
95
+ )
96
+ )
97
+ # A full 100-result page may hide another dependent, so it also fails closed.
98
+ blockers = dependents[:1] or observed_dependents[99:100]
99
+ if blockers:
100
+ kind = "remote branch"
101
+ dependent = blockers[0]
102
+ reason = (
103
+ t"preserve PR #{pr_number}'s branch and tracking because open "
104
+ t"PR #{dependent.number} still uses {ui.bookmark(pr_identity.head_ref)} "
105
+ t"as its base; close or retarget PR #{dependent.number}, then rerun "
106
+ t"{ui.cmd('cleanup')}"
107
+ )
108
+ return (
109
+ pr,
110
+ None if reason is None else CleanupAction(kind=kind, body=reason, status="blocked"),
111
+ )
112
+
113
+
114
+ @dataclass(frozen=True, slots=True)
115
+ class OverviewCommentLookup:
116
+ """Resolution of the managed overview comment on one pull request.
117
+
118
+ At most one of ``comment`` or ``blocked_reason`` is set. Both are ``None`` when the marker is
119
+ absent.
120
+ """
121
+
122
+ comment: GithubIssueComment | None = None
123
+ blocked_reason: str | None = None
124
+
125
+
126
+ async def find_overview_comment(
127
+ *,
128
+ github_client: GithubClient,
129
+ pr_number: int,
130
+ ) -> OverviewCommentLookup:
131
+ """Discover the managed overview comment for one PR via a single list call."""
132
+
133
+ try:
134
+ comments = await github_client.list_issue_comments(
135
+ issue_number=pr_number,
136
+ )
137
+ except GithubClientError as error:
138
+ if error.status_code == 404:
139
+ return OverviewCommentLookup()
140
+ reason = error.user_facing_reason()
141
+ return OverviewCommentLookup(
142
+ blocked_reason=(
143
+ f"cannot inspect the {STACK_OVERVIEW_COMMENT_LABEL} for PR #{pr_number}: {reason}"
144
+ ),
145
+ )
146
+
147
+ return _resolve_overview_comment_from_listed(
148
+ comments=comments,
149
+ pr_number=pr_number,
150
+ )
151
+
152
+
153
+ async def apply_overview_comment_cleanup(
154
+ *,
155
+ dry_run: bool,
156
+ github_client: GithubClient,
157
+ lookup: OverviewCommentLookup,
158
+ pr_number: int,
159
+ ) -> tuple[tuple[CleanupAction, ...], bool]:
160
+ """Delete one overview comment identified during cleanup planning."""
161
+
162
+ comment = lookup.comment
163
+ if comment is None:
164
+ return (), True
165
+ deleted = True
166
+ if not dry_run:
167
+ try:
168
+ deleted = await delete_stack_overview_comment(
169
+ comment_id=comment.id,
170
+ github_client=github_client,
171
+ )
172
+ except CliError as error:
173
+ return (
174
+ CleanupAction(
175
+ kind=STACK_OVERVIEW_COMMENT_LABEL,
176
+ body=str(error),
177
+ status="blocked",
178
+ ),
179
+ ), False
180
+ action_body = f"delete {STACK_OVERVIEW_COMMENT_LABEL} #{comment.id} from PR #{pr_number}"
181
+ if not dry_run and not deleted:
182
+ action_body = (
183
+ f"{STACK_OVERVIEW_COMMENT_LABEL} #{comment.id} already absent from PR #{pr_number}"
184
+ )
185
+ return (
186
+ CleanupAction(
187
+ kind=STACK_OVERVIEW_COMMENT_LABEL,
188
+ body=action_body,
189
+ status="planned" if dry_run else "applied",
190
+ ),
191
+ ), True
192
+
193
+
194
+ def _resolve_overview_comment_from_listed(
195
+ *,
196
+ comments: tuple[GithubIssueComment, ...],
197
+ pr_number: int,
198
+ ) -> OverviewCommentLookup:
199
+ matching_comments = [comment for comment in comments if is_overview_comment(comment.body)]
200
+ if len(matching_comments) > 1:
201
+ return OverviewCommentLookup(
202
+ blocked_reason=(
203
+ f"cannot delete {STACK_OVERVIEW_COMMENT_LABEL}s because GitHub reports "
204
+ f"multiple candidates on PR #{pr_number}"
205
+ ),
206
+ )
207
+ if not matching_comments:
208
+ return OverviewCommentLookup()
209
+ return OverviewCommentLookup(comment=matching_comments[0])
210
+
211
+
212
+ def emit_action_row(
213
+ *,
214
+ kind: str,
215
+ status: ActionPresentationStatus,
216
+ body: Message,
217
+ ) -> None:
218
+ prefix, prefix_style, body_style = _action_presentation(status)
219
+ message = body
220
+ if kind != "tracking":
221
+ message = (ui.semantic_text(kind, "prefix"), ": ", body)
222
+ console.output(
223
+ ui.prefixed_line(
224
+ f"{prefix} ",
225
+ message,
226
+ prefix_labels=prefix_style,
227
+ message_labels=body_style,
228
+ )
229
+ )
230
+
231
+
232
+ def _action_presentation(
233
+ status: ActionPresentationStatus,
234
+ ) -> tuple[str, tuple[str, ...] | None, tuple[str, ...] | None]:
235
+ if status == "applied":
236
+ return (
237
+ " ✓",
238
+ ("signature status good",),
239
+ None,
240
+ )
241
+ if status == "planned":
242
+ return (
243
+ " ~",
244
+ ("hint heading",),
245
+ None,
246
+ )
247
+ if status == "blocked":
248
+ return (
249
+ " ✗",
250
+ ("error heading",),
251
+ ("warning heading",),
252
+ )
253
+ if status == "skipped":
254
+ return (
255
+ " -",
256
+ ("hint heading",),
257
+ None,
258
+ )
259
+ return (" ?", None, None)
260
+
261
+
262
+ def plan_pr_cleanup(
263
+ *,
264
+ allowed_states: frozenset[str],
265
+ candidate: TrackedPR,
266
+ observation: RepoFacts,
267
+ preview_detached_dependents: frozenset[int] = frozenset(),
268
+ ) -> tuple[GithubPR | None, PRRefUpdate | None, CleanupAction | None]:
269
+ """Check exact cleanup facts and derive at most one leased ref deletion."""
270
+
271
+ pr, blocker = check_tracked_pr(
272
+ allowed_states=allowed_states,
273
+ candidate=candidate,
274
+ observation=observation,
275
+ preview_detached_dependents=preview_detached_dependents,
276
+ require_no_open_dependents=True,
277
+ )
278
+ if blocker is not None or pr is None:
279
+ return pr, None, blocker
280
+ change_id = candidate.change_id
281
+ pr_identity = candidate.pr_identity
282
+ submitted_baseline = candidate.submitted_baseline
283
+ observed = observation.prs[change_id]
284
+ if has_competing_open_pr(
285
+ open_head_prs=observed.open_head_prs,
286
+ pr_number=pr_identity.pr_number,
287
+ ):
288
+ return (
289
+ pr,
290
+ None,
291
+ CleanupAction(
292
+ kind="remote branch",
293
+ body=t"cannot delete {ui.bookmark(pr_identity.head_ref)} because another open "
294
+ t"pull request uses it as its head branch",
295
+ status="blocked",
296
+ ),
297
+ )
298
+ configured_repo = observation.configured_repo
299
+ if (
300
+ observation.remote is None
301
+ or configured_repo is None
302
+ or configured_repo.repo_key != pr_identity.repo_key
303
+ ):
304
+ return (
305
+ pr,
306
+ None,
307
+ CleanupAction(
308
+ kind="remote branch",
309
+ body=t"cannot resolve the configured remote for saved PR "
310
+ t"#{pr_identity.pr_number}",
311
+ status="blocked",
312
+ ),
313
+ )
314
+ branch = pr_identity.head_ref
315
+ if not pr_branch_matches_change(branch, change_id):
316
+ return (
317
+ pr,
318
+ None,
319
+ CleanupAction(
320
+ kind="tracking",
321
+ body=t"cannot clean up {ui.bookmark(branch)} because it does not match "
322
+ t"change {ui.change_id(change_id)}",
323
+ status="blocked",
324
+ ),
325
+ )
326
+ remote_target = observation.prs[change_id].remote_pr_branch_target
327
+ if remote_target is not None and remote_target != submitted_baseline.commit_id:
328
+ return (
329
+ pr,
330
+ None,
331
+ CleanupAction(
332
+ kind="remote branch",
333
+ body=t"cannot delete {ui.bookmark(branch)} because it "
334
+ t"already points to a different commit",
335
+ status="blocked",
336
+ ),
337
+ )
338
+ update = (
339
+ None
340
+ if remote_target is None
341
+ else PRRefUpdate(
342
+ branch=branch,
343
+ expected_target=submitted_baseline.commit_id,
344
+ desired_target=None,
345
+ )
346
+ )
347
+ return pr, update, None
348
+
349
+
350
+ async def github_stack_cleanup_blocker(
351
+ *,
352
+ github_client: GithubClient,
353
+ pr_number: int,
354
+ ) -> CleanupAction | None:
355
+ """Fail closed when current stack membership still needs a PR branch."""
356
+
357
+ try:
358
+ await GithubStackSelection(github_client, (pr_number,)).require_unstacked()
359
+ except CliError as error:
360
+ return CleanupAction(kind="remote branch", body=str(error), status="blocked")
361
+ return None
362
+
363
+
364
+ def apply_remote_branch_cleanup(
365
+ *,
366
+ dry_run: bool,
367
+ jj_client: JjClient,
368
+ record_action: Callable[[CleanupAction], None],
369
+ remote_name: str,
370
+ update: PRRefUpdate | None,
371
+ ) -> None:
372
+ """Execute one prechecked remote branch deletion with an exact lease.
373
+
374
+ A rejected lease raises, so there is no failure for callers to branch on.
375
+ """
376
+
377
+ if update is not None:
378
+ if not dry_run:
379
+ jj_client.mutate_remote_pr_branch_refs(
380
+ remote=remote_name,
381
+ updates=(update,),
382
+ )
383
+ record_action(
384
+ CleanupAction(
385
+ kind="remote branch",
386
+ body=t"delete {ui.bookmark(f'{update.branch}@{remote_name}')}",
387
+ status="planned" if dry_run else "applied",
388
+ )
389
+ )
@@ -0,0 +1,84 @@
1
+ """JSON projections for user-facing stack status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from jj_stack.models.tracking import PRIdentity
6
+ from jj_stack.stack.change_status import (
7
+ ChangeStatus,
8
+ classify_stack_status_change,
9
+ )
10
+ from jj_stack.stack.status import StackStatusChange
11
+
12
+
13
+ def stack_change_json(
14
+ change: StackStatusChange,
15
+ *,
16
+ current: bool = False,
17
+ ) -> dict[str, object]:
18
+ """Return the public JSON shape for one stack change."""
19
+
20
+ payload: dict[str, object] = {
21
+ "change_id": change.change_id,
22
+ "status": _change_status(classify_stack_status_change(change)),
23
+ "subject": change.subject,
24
+ }
25
+ if change.branch is not None:
26
+ payload["branch"] = change.branch
27
+ if current:
28
+ payload["current"] = True
29
+ pr = pr_json(change)
30
+ if pr is not None:
31
+ payload["pr"] = pr
32
+ return payload
33
+
34
+
35
+ def pr_json(
36
+ change: StackStatusChange,
37
+ ) -> dict[str, object] | None:
38
+ lookup = change.pr_lookup
39
+ if lookup is not None and lookup.pr is not None:
40
+ pr = lookup.pr
41
+ return _json_object(
42
+ {
43
+ "number": pr.number,
44
+ "url": pr.html_url,
45
+ }
46
+ )
47
+ return saved_pr_json(change.pr_identity)
48
+
49
+
50
+ def saved_pr_json(
51
+ pr_identity: PRIdentity | None,
52
+ ) -> dict[str, object] | None:
53
+ if pr_identity is None:
54
+ return None
55
+ payload = _json_object({"number": pr_identity.pr_number})
56
+ return payload or None
57
+
58
+
59
+ def _change_status(status: ChangeStatus) -> str:
60
+ if status.local == "divergent":
61
+ return "divergent"
62
+ if status.pr_lifecycle in {"ambiguous", "closed", "merged", "missing"}:
63
+ return status.pr_lifecycle
64
+ if status.has_pr_lookup_failure:
65
+ return "unknown"
66
+ if status.pr_lifecycle == "open":
67
+ if status.pr_queued is True:
68
+ return "queued"
69
+ if status.pr_draft is True:
70
+ return "draft"
71
+ if status.pr_review_decision == "approved":
72
+ return "approved"
73
+ if status.pr_review_decision == "changes_requested":
74
+ return "changes_requested"
75
+ if status.pr_review_decision == "commented":
76
+ return "commented"
77
+ return "open"
78
+ if status.saved_pr_identity:
79
+ return "submitted"
80
+ return "unsubmitted"
81
+
82
+
83
+ def _json_object(values: dict[str, object | None]) -> dict[str, object]:
84
+ return {key: value for key, value in values.items() if value is not None}