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,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}
|