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
jj_stack/stack/status.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Stack status preparation and GitHub inspection helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from collections.abc import AsyncIterator, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Literal
|
|
10
|
+
|
|
11
|
+
import jj_stack.ui as ui
|
|
12
|
+
from jj_stack.bootstrap import CommandContext
|
|
13
|
+
from jj_stack.errors import CliError, ErrorMessage, error_message
|
|
14
|
+
from jj_stack.github.client import (
|
|
15
|
+
GithubClient,
|
|
16
|
+
GithubClientError,
|
|
17
|
+
build_github_client,
|
|
18
|
+
)
|
|
19
|
+
from jj_stack.github.error_messages import (
|
|
20
|
+
summarize_github_lookup_error,
|
|
21
|
+
)
|
|
22
|
+
from jj_stack.github.resolution import (
|
|
23
|
+
GithubRepoAddress,
|
|
24
|
+
GithubTarget,
|
|
25
|
+
UnresolvedGithubTarget,
|
|
26
|
+
resolve_github_target,
|
|
27
|
+
)
|
|
28
|
+
from jj_stack.identifiers import short_change_id
|
|
29
|
+
from jj_stack.jj.client import JjClient, UnsupportedStackError
|
|
30
|
+
from jj_stack.models.git import GitRemote
|
|
31
|
+
from jj_stack.models.github import GithubPR
|
|
32
|
+
from jj_stack.models.stack import LocalCommit, LocalStack
|
|
33
|
+
from jj_stack.models.tracking import PRIdentity, SubmittedBaseline, TrackingState
|
|
34
|
+
from jj_stack.stack.change_status import (
|
|
35
|
+
classify_stack_status_change,
|
|
36
|
+
submitted_state_disagreement,
|
|
37
|
+
)
|
|
38
|
+
from jj_stack.stack.selected import select_stack_path, select_stack_path_containing_change
|
|
39
|
+
from jj_stack.ui import Message
|
|
40
|
+
|
|
41
|
+
logger = logging.getLogger(__name__)
|
|
42
|
+
|
|
43
|
+
HELP = "Check one or more `jj` stacks and their pull requests"
|
|
44
|
+
|
|
45
|
+
PRLookupState = Literal["ambiguous", "closed", "error", "missing", "open"]
|
|
46
|
+
PRLookupSource = Literal["head", "remembered"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class PRLookup:
|
|
51
|
+
"""Best-effort GitHub pull request lookup for one branch."""
|
|
52
|
+
|
|
53
|
+
message: ErrorMessage | None
|
|
54
|
+
pr: GithubPR | None
|
|
55
|
+
state: PRLookupState
|
|
56
|
+
review_decision: str | None = None
|
|
57
|
+
review_decision_error: str | None = None
|
|
58
|
+
source: PRLookupSource = "head"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class StackStatusChange:
|
|
63
|
+
"""Rendered pull-request and branch state for one local change."""
|
|
64
|
+
|
|
65
|
+
branch: str | None
|
|
66
|
+
change_id: str
|
|
67
|
+
commit_id: str
|
|
68
|
+
local_divergent: bool
|
|
69
|
+
pr_identity: PRIdentity | None
|
|
70
|
+
submitted_baseline: SubmittedBaseline | None
|
|
71
|
+
pr_lookup: PRLookup | None
|
|
72
|
+
remote_target: str | None
|
|
73
|
+
subject: str
|
|
74
|
+
|
|
75
|
+
def pr(self) -> GithubPR | None:
|
|
76
|
+
lookup = self.pr_lookup
|
|
77
|
+
if lookup is None:
|
|
78
|
+
return None
|
|
79
|
+
return lookup.pr
|
|
80
|
+
|
|
81
|
+
def pr_number(self) -> int | None:
|
|
82
|
+
pr = self.pr()
|
|
83
|
+
if pr is None:
|
|
84
|
+
return None
|
|
85
|
+
return pr.number
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True, slots=True)
|
|
89
|
+
class StatusResult:
|
|
90
|
+
"""Status result for one selected local stack."""
|
|
91
|
+
|
|
92
|
+
github_error: ErrorMessage | None
|
|
93
|
+
github_repo: GithubRepoAddress | None
|
|
94
|
+
incomplete: bool
|
|
95
|
+
remote: GitRemote | None
|
|
96
|
+
remote_error: ErrorMessage | None
|
|
97
|
+
changes: tuple[StackStatusChange, ...]
|
|
98
|
+
selected_revset: str
|
|
99
|
+
submitted_state_disagreements: tuple[str, ...] = ()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True, slots=True)
|
|
103
|
+
class PreparedStatus:
|
|
104
|
+
"""Locally prepared status inputs before any GitHub inspection."""
|
|
105
|
+
|
|
106
|
+
github_target: GithubTarget | UnresolvedGithubTarget
|
|
107
|
+
prepared: PreparedStack
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def github_repo(self) -> GithubRepoAddress | None:
|
|
111
|
+
target = self.github_target
|
|
112
|
+
return target.repo if isinstance(target, GithubTarget) else None
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def github_repo_error(self) -> ErrorMessage | None:
|
|
116
|
+
return self.github_target.github_repo_error
|
|
117
|
+
|
|
118
|
+
def github_inspection_count(self) -> int:
|
|
119
|
+
"""Return how many selected changes need live GitHub inspection."""
|
|
120
|
+
|
|
121
|
+
if self.github_repo is None:
|
|
122
|
+
return 0
|
|
123
|
+
return sum(
|
|
124
|
+
1
|
|
125
|
+
for prepared_change in self.prepared.status_changes
|
|
126
|
+
if _needs_github_inspection(prepared_change)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True, slots=True)
|
|
131
|
+
class PreparedStack:
|
|
132
|
+
"""Prepared local stack inputs shared across inspection-driven commands."""
|
|
133
|
+
|
|
134
|
+
client: JjClient
|
|
135
|
+
remote: GitRemote | None
|
|
136
|
+
remote_error: ErrorMessage | None
|
|
137
|
+
remote_targets: dict[str, str]
|
|
138
|
+
stack: LocalStack
|
|
139
|
+
state: TrackingState
|
|
140
|
+
status_changes: tuple[PreparedChange, ...]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True, slots=True)
|
|
144
|
+
class PreparedChange:
|
|
145
|
+
"""Local stack change with its optional saved PR branch and cached state."""
|
|
146
|
+
|
|
147
|
+
branch: str | None
|
|
148
|
+
change: LocalCommit
|
|
149
|
+
pr_identity: PRIdentity | None
|
|
150
|
+
submitted_baseline: SubmittedBaseline | None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _required_branch(change: PreparedChange) -> str:
|
|
154
|
+
if change.branch is None:
|
|
155
|
+
raise AssertionError("GitHub inspection requires an exact saved PR branch.")
|
|
156
|
+
return change.branch
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def status_preparation_cli_error(error: UnsupportedStackError) -> CliError:
|
|
160
|
+
"""Translate stack-shape preparation failures into a user-facing CLI error."""
|
|
161
|
+
|
|
162
|
+
if error.reason == "trunk_resolved_to_root":
|
|
163
|
+
return CliError(
|
|
164
|
+
"No trunk bookmark is configured for this repo.",
|
|
165
|
+
hint=error.hint,
|
|
166
|
+
)
|
|
167
|
+
return CliError(t"Local history does not form a linear stack. {error}")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def prepare_status(
|
|
171
|
+
*,
|
|
172
|
+
context: CommandContext,
|
|
173
|
+
fetch_remote_state: bool = False,
|
|
174
|
+
observe_remote_targets: bool = True,
|
|
175
|
+
revset: str | None,
|
|
176
|
+
containing_change_id: str | None = None,
|
|
177
|
+
inspection_mode: bool = False,
|
|
178
|
+
) -> PreparedStatus:
|
|
179
|
+
"""Resolve local status inputs before any GitHub network inspection."""
|
|
180
|
+
|
|
181
|
+
jj_client = context.jj_client
|
|
182
|
+
state_store = context.state_store
|
|
183
|
+
state = state_store.load()
|
|
184
|
+
github_target = resolve_github_target(jj_client.list_git_remotes())
|
|
185
|
+
if fetch_remote_state and github_target.remote is not None:
|
|
186
|
+
jj_client.fetch_remote(remote=github_target.remote.name)
|
|
187
|
+
|
|
188
|
+
if containing_change_id is not None:
|
|
189
|
+
selected_path = select_stack_path_containing_change(
|
|
190
|
+
change_id=containing_change_id,
|
|
191
|
+
inspection_mode=inspection_mode,
|
|
192
|
+
jj_client=jj_client,
|
|
193
|
+
state=state,
|
|
194
|
+
)
|
|
195
|
+
else:
|
|
196
|
+
selected_path = select_stack_path(
|
|
197
|
+
inspection_mode=inspection_mode,
|
|
198
|
+
jj_client=jj_client,
|
|
199
|
+
revset=revset,
|
|
200
|
+
state=state,
|
|
201
|
+
)
|
|
202
|
+
prepared = prepare_stack_for_status(
|
|
203
|
+
context=context,
|
|
204
|
+
observed_remote_targets=None if observe_remote_targets else {},
|
|
205
|
+
remote=github_target.remote,
|
|
206
|
+
remote_error=github_target.remote_error,
|
|
207
|
+
stack=selected_path.stack,
|
|
208
|
+
state=state,
|
|
209
|
+
)
|
|
210
|
+
logger.debug(
|
|
211
|
+
"status prepared: selected_revset=%s changes=%d remote=%s",
|
|
212
|
+
prepared.stack.selected_revset,
|
|
213
|
+
len(prepared.status_changes),
|
|
214
|
+
prepared.remote.name if prepared.remote is not None else "unavailable",
|
|
215
|
+
)
|
|
216
|
+
return PreparedStatus(
|
|
217
|
+
github_target=github_target,
|
|
218
|
+
prepared=prepared,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def stream_status(
|
|
223
|
+
*,
|
|
224
|
+
prepared_status: PreparedStatus,
|
|
225
|
+
on_change: Callable[[StackStatusChange, bool], None] | None = None,
|
|
226
|
+
) -> StatusResult:
|
|
227
|
+
"""Inspect GitHub state for a prepared stack and optionally stream results out."""
|
|
228
|
+
|
|
229
|
+
return asyncio.run(
|
|
230
|
+
stream_status_async(
|
|
231
|
+
on_change=on_change,
|
|
232
|
+
prepared_status=prepared_status,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def stream_status_async(
|
|
238
|
+
*,
|
|
239
|
+
on_change: Callable[[StackStatusChange, bool], None] | None,
|
|
240
|
+
prepared_status: PreparedStatus,
|
|
241
|
+
) -> StatusResult:
|
|
242
|
+
prepared = prepared_status.prepared
|
|
243
|
+
selected_revset = prepared.stack.selected_revset
|
|
244
|
+
github_repo = prepared_status.github_repo
|
|
245
|
+
github_repo_error = prepared_status.github_repo_error
|
|
246
|
+
submitted_disagreements = submitted_state_disagreement(
|
|
247
|
+
prepared.state,
|
|
248
|
+
(prepared.stack,),
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if prepared.remote is None:
|
|
252
|
+
display_changes = tuple(reversed(build_status_changes_for_prepared_stack(prepared)))
|
|
253
|
+
for change in display_changes:
|
|
254
|
+
if on_change is not None:
|
|
255
|
+
on_change(change, False)
|
|
256
|
+
return StatusResult(
|
|
257
|
+
github_error=None,
|
|
258
|
+
github_repo=None,
|
|
259
|
+
incomplete=True,
|
|
260
|
+
remote=None,
|
|
261
|
+
remote_error=prepared.remote_error,
|
|
262
|
+
changes=display_changes,
|
|
263
|
+
selected_revset=selected_revset,
|
|
264
|
+
submitted_state_disagreements=submitted_disagreements,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
if github_repo is None:
|
|
268
|
+
logger.debug("status github target unavailable: %s", github_repo_error)
|
|
269
|
+
display_changes = tuple(reversed(build_status_changes_for_prepared_stack(prepared)))
|
|
270
|
+
for change in display_changes:
|
|
271
|
+
if on_change is not None:
|
|
272
|
+
on_change(change, False)
|
|
273
|
+
return StatusResult(
|
|
274
|
+
github_error=github_repo_error,
|
|
275
|
+
github_repo=None,
|
|
276
|
+
incomplete=True,
|
|
277
|
+
remote=prepared.remote,
|
|
278
|
+
remote_error=None,
|
|
279
|
+
changes=display_changes,
|
|
280
|
+
selected_revset=selected_revset,
|
|
281
|
+
submitted_state_disagreements=submitted_disagreements,
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
if not prepared.status_changes:
|
|
285
|
+
return StatusResult(
|
|
286
|
+
github_error=None,
|
|
287
|
+
github_repo=github_repo,
|
|
288
|
+
incomplete=False,
|
|
289
|
+
remote=prepared.remote,
|
|
290
|
+
remote_error=None,
|
|
291
|
+
changes=(),
|
|
292
|
+
selected_revset=selected_revset,
|
|
293
|
+
submitted_state_disagreements=submitted_disagreements,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
fallback_changes = tuple(reversed(build_status_changes_for_prepared_stack(prepared)))
|
|
297
|
+
prepared_changes_for_github = tuple(
|
|
298
|
+
prepared_change
|
|
299
|
+
for prepared_change in prepared.status_changes
|
|
300
|
+
if _needs_github_inspection(prepared_change)
|
|
301
|
+
)
|
|
302
|
+
if not prepared_changes_for_github:
|
|
303
|
+
return StatusResult(
|
|
304
|
+
github_error=None,
|
|
305
|
+
github_repo=github_repo,
|
|
306
|
+
incomplete=_status_is_incomplete(fallback_changes),
|
|
307
|
+
remote=prepared.remote,
|
|
308
|
+
remote_error=None,
|
|
309
|
+
changes=fallback_changes,
|
|
310
|
+
selected_revset=selected_revset,
|
|
311
|
+
submitted_state_disagreements=submitted_disagreements,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
changes: list[StackStatusChange] = []
|
|
315
|
+
try:
|
|
316
|
+
async for change in _iter_status_changes_with_github(
|
|
317
|
+
github_repo=github_repo,
|
|
318
|
+
prepared=prepared,
|
|
319
|
+
prepared_changes=prepared_changes_for_github,
|
|
320
|
+
):
|
|
321
|
+
changes.append(change)
|
|
322
|
+
if on_change is not None:
|
|
323
|
+
on_change(change, True)
|
|
324
|
+
except CliError as error:
|
|
325
|
+
github_error = error_message(error)
|
|
326
|
+
logger.debug("status github inspection failed: %s", github_error)
|
|
327
|
+
streamed_change_ids = {change.change_id for change in changes}
|
|
328
|
+
for change in fallback_changes:
|
|
329
|
+
if on_change is not None and change.change_id not in streamed_change_ids:
|
|
330
|
+
on_change(change, False)
|
|
331
|
+
return StatusResult(
|
|
332
|
+
github_error=github_error,
|
|
333
|
+
github_repo=github_repo,
|
|
334
|
+
incomplete=True,
|
|
335
|
+
remote=prepared.remote,
|
|
336
|
+
remote_error=None,
|
|
337
|
+
changes=fallback_changes,
|
|
338
|
+
selected_revset=selected_revset,
|
|
339
|
+
submitted_state_disagreements=submitted_disagreements,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
changes_by_change_id = {change.change_id: change for change in changes}
|
|
343
|
+
display_changes = tuple(
|
|
344
|
+
changes_by_change_id.get(change.change_id, change) for change in fallback_changes
|
|
345
|
+
)
|
|
346
|
+
return StatusResult(
|
|
347
|
+
github_error=None,
|
|
348
|
+
github_repo=github_repo,
|
|
349
|
+
incomplete=_status_is_incomplete(display_changes),
|
|
350
|
+
remote=prepared.remote,
|
|
351
|
+
remote_error=None,
|
|
352
|
+
changes=display_changes,
|
|
353
|
+
selected_revset=selected_revset,
|
|
354
|
+
submitted_state_disagreements=submitted_disagreements,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def prepare_stack_for_status(
|
|
359
|
+
*,
|
|
360
|
+
context: CommandContext,
|
|
361
|
+
observed_remote_targets: dict[str, str] | None = None,
|
|
362
|
+
remote: GitRemote | None,
|
|
363
|
+
remote_error: ErrorMessage | None,
|
|
364
|
+
stack: LocalStack,
|
|
365
|
+
state: TrackingState,
|
|
366
|
+
) -> PreparedStack:
|
|
367
|
+
"""Build prepared status inputs for one already-resolved local stack."""
|
|
368
|
+
|
|
369
|
+
jj_client = context.jj_client
|
|
370
|
+
status_changes = tuple(
|
|
371
|
+
PreparedChange(
|
|
372
|
+
branch=(identity.head_ref if identity is not None else None),
|
|
373
|
+
change=change,
|
|
374
|
+
pr_identity=identity,
|
|
375
|
+
submitted_baseline=state.submitted_baselines.get(change.change_id),
|
|
376
|
+
)
|
|
377
|
+
for change in stack.changes
|
|
378
|
+
for identity in (state.pr_identities.get(change.change_id),)
|
|
379
|
+
)
|
|
380
|
+
branches = tuple(change.branch for change in status_changes if change.branch is not None)
|
|
381
|
+
if observed_remote_targets is None:
|
|
382
|
+
observed_remote_targets = observe_remote_targets_for_status(
|
|
383
|
+
context=context,
|
|
384
|
+
remote=remote,
|
|
385
|
+
stacks=(stack,),
|
|
386
|
+
state=state,
|
|
387
|
+
)
|
|
388
|
+
remote_targets = {
|
|
389
|
+
branch: observed_remote_targets[branch]
|
|
390
|
+
for branch in branches
|
|
391
|
+
if branch in observed_remote_targets
|
|
392
|
+
}
|
|
393
|
+
return PreparedStack(
|
|
394
|
+
client=jj_client,
|
|
395
|
+
remote=remote,
|
|
396
|
+
remote_error=remote_error,
|
|
397
|
+
remote_targets=remote_targets,
|
|
398
|
+
stack=stack,
|
|
399
|
+
state=state,
|
|
400
|
+
status_changes=status_changes,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def observe_remote_targets_for_status(
|
|
405
|
+
*,
|
|
406
|
+
context: CommandContext,
|
|
407
|
+
excluded_branches: frozenset[str] = frozenset(),
|
|
408
|
+
remote: GitRemote | None,
|
|
409
|
+
stacks: tuple[LocalStack, ...],
|
|
410
|
+
state: TrackingState,
|
|
411
|
+
) -> dict[str, str]:
|
|
412
|
+
"""Observe the union of exact saved PR branch refs needed for status."""
|
|
413
|
+
|
|
414
|
+
if remote is None:
|
|
415
|
+
return {}
|
|
416
|
+
branches = tuple(
|
|
417
|
+
dict.fromkeys(
|
|
418
|
+
identity.head_ref
|
|
419
|
+
for stack in stacks
|
|
420
|
+
for change in stack.changes
|
|
421
|
+
for identity in (state.pr_identities.get(change.change_id),)
|
|
422
|
+
if identity is not None and identity.head_ref not in excluded_branches
|
|
423
|
+
)
|
|
424
|
+
)
|
|
425
|
+
if not branches:
|
|
426
|
+
return {}
|
|
427
|
+
return context.jj_client.list_remote_branches(
|
|
428
|
+
remote=remote.name,
|
|
429
|
+
patterns=tuple(f"refs/heads/{branch}" for branch in branches),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def build_status_changes_for_prepared_stack(
|
|
434
|
+
prepared: PreparedStack,
|
|
435
|
+
*,
|
|
436
|
+
pr_lookups: dict[str, PRLookup] | None = None,
|
|
437
|
+
) -> tuple[StackStatusChange, ...]:
|
|
438
|
+
return tuple(
|
|
439
|
+
StackStatusChange(
|
|
440
|
+
branch=change.branch,
|
|
441
|
+
change_id=change.change.change_id,
|
|
442
|
+
commit_id=change.change.commit_id,
|
|
443
|
+
local_divergent=change.change.divergent,
|
|
444
|
+
pr_lookup=(
|
|
445
|
+
pr_lookups.get(change.branch)
|
|
446
|
+
if pr_lookups is not None and change.branch is not None
|
|
447
|
+
else None
|
|
448
|
+
),
|
|
449
|
+
remote_target=(
|
|
450
|
+
prepared.remote_targets.get(change.branch) if change.branch is not None else None
|
|
451
|
+
),
|
|
452
|
+
pr_identity=change.pr_identity,
|
|
453
|
+
submitted_baseline=change.submitted_baseline,
|
|
454
|
+
subject=change.change.subject,
|
|
455
|
+
)
|
|
456
|
+
for change in prepared.status_changes
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _needs_github_inspection(prepared_change: PreparedChange) -> bool:
|
|
461
|
+
return prepared_change.pr_identity is not None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _status_is_incomplete(changes: tuple[StackStatusChange, ...]) -> bool:
|
|
465
|
+
return any(classify_stack_status_change(change).makes_report_incomplete for change in changes)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
async def _iter_status_changes_with_github(
|
|
469
|
+
*,
|
|
470
|
+
github_repo: GithubRepoAddress,
|
|
471
|
+
prepared: PreparedStack,
|
|
472
|
+
prepared_changes: tuple[PreparedChange, ...],
|
|
473
|
+
) -> AsyncIterator[StackStatusChange]:
|
|
474
|
+
ordered_prepared_changes = tuple(reversed(prepared_changes))
|
|
475
|
+
async with build_github_client(repo=github_repo) as github_client:
|
|
476
|
+
pr_lookups = await _resolve_pr_lookups(
|
|
477
|
+
github_client=github_client,
|
|
478
|
+
on_progress=None,
|
|
479
|
+
prepared_changes=ordered_prepared_changes,
|
|
480
|
+
)
|
|
481
|
+
for prepared_change in ordered_prepared_changes:
|
|
482
|
+
branch = _required_branch(prepared_change)
|
|
483
|
+
pr_lookup = pr_lookups[branch]
|
|
484
|
+
logger.debug(
|
|
485
|
+
"status change inspected: change_id=%s branch=%s pr_state=%s",
|
|
486
|
+
short_change_id(prepared_change.change.change_id),
|
|
487
|
+
branch,
|
|
488
|
+
pr_lookup.state,
|
|
489
|
+
)
|
|
490
|
+
yield StackStatusChange(
|
|
491
|
+
branch=branch,
|
|
492
|
+
change_id=prepared_change.change.change_id,
|
|
493
|
+
commit_id=prepared_change.change.commit_id,
|
|
494
|
+
local_divergent=prepared_change.change.divergent,
|
|
495
|
+
pr_lookup=pr_lookup,
|
|
496
|
+
remote_target=prepared.remote_targets.get(branch),
|
|
497
|
+
pr_identity=prepared_change.pr_identity,
|
|
498
|
+
submitted_baseline=prepared_change.submitted_baseline,
|
|
499
|
+
subject=prepared_change.change.subject,
|
|
500
|
+
)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def lookup_pr_lookups(
|
|
504
|
+
*,
|
|
505
|
+
github_repo: GithubRepoAddress,
|
|
506
|
+
on_progress: Callable[[int], None] | None = None,
|
|
507
|
+
prepared_changes: tuple[PreparedChange, ...],
|
|
508
|
+
) -> dict[str, PRLookup]:
|
|
509
|
+
"""Return batched pull-request lookups keyed by saved branch."""
|
|
510
|
+
|
|
511
|
+
return asyncio.run(
|
|
512
|
+
lookup_pr_lookups_async(
|
|
513
|
+
github_repo=github_repo,
|
|
514
|
+
on_progress=on_progress,
|
|
515
|
+
prepared_changes=prepared_changes,
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
async def lookup_pr_lookups_async(
|
|
521
|
+
*,
|
|
522
|
+
github_repo: GithubRepoAddress,
|
|
523
|
+
on_progress: Callable[[int], None] | None = None,
|
|
524
|
+
prepared_changes: tuple[PreparedChange, ...],
|
|
525
|
+
) -> dict[str, PRLookup]:
|
|
526
|
+
"""Return batched pull-request lookups keyed by saved branch."""
|
|
527
|
+
|
|
528
|
+
async with build_github_client(repo=github_repo) as github_client:
|
|
529
|
+
return await _resolve_pr_lookups(
|
|
530
|
+
github_client=github_client,
|
|
531
|
+
on_progress=on_progress,
|
|
532
|
+
prepared_changes=prepared_changes,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
async def _resolve_pr_lookups(
|
|
537
|
+
*,
|
|
538
|
+
github_client: GithubClient,
|
|
539
|
+
on_progress: Callable[[int], None] | None,
|
|
540
|
+
prepared_changes: tuple[PreparedChange, ...],
|
|
541
|
+
) -> dict[str, PRLookup]:
|
|
542
|
+
pr_lookups = await _discover_pr_lookups(
|
|
543
|
+
github_client=github_client,
|
|
544
|
+
prepared_changes=prepared_changes,
|
|
545
|
+
)
|
|
546
|
+
if on_progress is not None and pr_lookups:
|
|
547
|
+
on_progress(len(pr_lookups))
|
|
548
|
+
return pr_lookups
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
async def _discover_pr_lookups(
|
|
552
|
+
*,
|
|
553
|
+
github_client: GithubClient,
|
|
554
|
+
prepared_changes: tuple[PreparedChange, ...],
|
|
555
|
+
) -> dict[str, PRLookup]:
|
|
556
|
+
prepared_changes_by_branch = {
|
|
557
|
+
_required_branch(prepared_change): prepared_change for prepared_change in prepared_changes
|
|
558
|
+
}
|
|
559
|
+
branches = tuple(prepared_changes_by_branch)
|
|
560
|
+
if not branches:
|
|
561
|
+
return {}
|
|
562
|
+
|
|
563
|
+
try:
|
|
564
|
+
discovered_prs = await github_client.get_open_prs_by_head_refs(
|
|
565
|
+
head_refs=branches,
|
|
566
|
+
)
|
|
567
|
+
except GithubClientError as error:
|
|
568
|
+
# Auth failures, missing repos, server errors, and transport
|
|
569
|
+
# failures are repo-level: no per-branch lookup can succeed, so
|
|
570
|
+
# fail the whole inspection rather than reporting per-branch errors.
|
|
571
|
+
status_code = error.status_code
|
|
572
|
+
if status_code is None or status_code in {401, 403, 404} or status_code >= 500:
|
|
573
|
+
raise CliError(
|
|
574
|
+
summarize_github_lookup_error(action="pull request lookup", error=error),
|
|
575
|
+
hint=t"Run {ui.cmd('jj-stack doctor')} to check GitHub access.",
|
|
576
|
+
) from error
|
|
577
|
+
lookup_error = summarize_github_lookup_error(
|
|
578
|
+
action="pull request lookup",
|
|
579
|
+
error=error,
|
|
580
|
+
)
|
|
581
|
+
return {
|
|
582
|
+
branch: PRLookup(
|
|
583
|
+
message=lookup_error,
|
|
584
|
+
pr=None,
|
|
585
|
+
state="error",
|
|
586
|
+
)
|
|
587
|
+
for branch in branches
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
lookups = {
|
|
591
|
+
branch: _pr_lookup_from_discovered(
|
|
592
|
+
head_label=t"{github_client.repo.owner}:{ui.bookmark(branch)}",
|
|
593
|
+
prs=discovered_prs.get(branch, ()),
|
|
594
|
+
)
|
|
595
|
+
for branch in branches
|
|
596
|
+
}
|
|
597
|
+
remembered_numbers = tuple(
|
|
598
|
+
prepared_change.pr_identity.pr_number
|
|
599
|
+
for branch, prepared_change in prepared_changes_by_branch.items()
|
|
600
|
+
if lookups[branch].state == "missing" and prepared_change.pr_identity is not None
|
|
601
|
+
)
|
|
602
|
+
if not remembered_numbers:
|
|
603
|
+
return lookups
|
|
604
|
+
|
|
605
|
+
try:
|
|
606
|
+
remembered_prs = await github_client.get_prs_by_numbers(
|
|
607
|
+
pr_numbers=remembered_numbers,
|
|
608
|
+
)
|
|
609
|
+
except GithubClientError as error:
|
|
610
|
+
lookup_error = summarize_github_lookup_error(
|
|
611
|
+
action="remembered pull request lookup",
|
|
612
|
+
error=error,
|
|
613
|
+
)
|
|
614
|
+
failed_lookups: dict[str, PRLookup] = {}
|
|
615
|
+
for branch, lookup in lookups.items():
|
|
616
|
+
pr_identity = prepared_changes_by_branch[branch].pr_identity
|
|
617
|
+
if lookup.state == "missing" and pr_identity is not None:
|
|
618
|
+
failed_lookups[branch] = PRLookup(
|
|
619
|
+
message=lookup_error,
|
|
620
|
+
pr=None,
|
|
621
|
+
state="error",
|
|
622
|
+
)
|
|
623
|
+
else:
|
|
624
|
+
failed_lookups[branch] = lookup
|
|
625
|
+
return failed_lookups
|
|
626
|
+
|
|
627
|
+
for branch, lookup in tuple(lookups.items()):
|
|
628
|
+
if lookup.state != "missing":
|
|
629
|
+
continue
|
|
630
|
+
pr_identity = prepared_changes_by_branch[branch].pr_identity
|
|
631
|
+
if pr_identity is None:
|
|
632
|
+
continue
|
|
633
|
+
remembered_pr = remembered_prs.get(pr_identity.pr_number)
|
|
634
|
+
if remembered_pr is None:
|
|
635
|
+
continue
|
|
636
|
+
lookups[branch] = _pr_lookup_from_remembered(
|
|
637
|
+
branch=branch,
|
|
638
|
+
pr=remembered_pr,
|
|
639
|
+
)
|
|
640
|
+
return lookups
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _pr_lookup_from_discovered(
|
|
644
|
+
*,
|
|
645
|
+
head_label: Message,
|
|
646
|
+
prs: tuple[GithubPR, ...],
|
|
647
|
+
) -> PRLookup:
|
|
648
|
+
if not prs:
|
|
649
|
+
return PRLookup(
|
|
650
|
+
message=None,
|
|
651
|
+
pr=None,
|
|
652
|
+
state="missing",
|
|
653
|
+
)
|
|
654
|
+
if len(prs) > 1:
|
|
655
|
+
numbers = ", ".join(str(pr.number) for pr in prs)
|
|
656
|
+
return PRLookup(
|
|
657
|
+
message=(
|
|
658
|
+
t"GitHub reports multiple pull requests for head branch {head_label}: {numbers}."
|
|
659
|
+
),
|
|
660
|
+
pr=None,
|
|
661
|
+
state="ambiguous",
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
pr = prs[0]
|
|
665
|
+
effective_pr = pr.normalize_state()
|
|
666
|
+
message = None
|
|
667
|
+
if effective_pr.state != "open":
|
|
668
|
+
message = (
|
|
669
|
+
t"GitHub reports pull request #{effective_pr.number} "
|
|
670
|
+
t"for head branch {head_label} in state {effective_pr.state}."
|
|
671
|
+
)
|
|
672
|
+
return _single_pr_lookup(
|
|
673
|
+
message=message,
|
|
674
|
+
pr=effective_pr,
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _pr_lookup_from_remembered(
|
|
679
|
+
*,
|
|
680
|
+
branch: str,
|
|
681
|
+
pr: GithubPR,
|
|
682
|
+
) -> PRLookup:
|
|
683
|
+
effective_pr = pr.normalize_state()
|
|
684
|
+
message: ErrorMessage | None = None
|
|
685
|
+
if effective_pr.head.ref != branch:
|
|
686
|
+
message = (
|
|
687
|
+
t"Remembered PR #{effective_pr.number} now uses head branch "
|
|
688
|
+
t"{ui.bookmark(effective_pr.head.ref)}, not "
|
|
689
|
+
t"{ui.bookmark(branch)}."
|
|
690
|
+
)
|
|
691
|
+
return _single_pr_lookup(
|
|
692
|
+
message=message,
|
|
693
|
+
pr=effective_pr,
|
|
694
|
+
source="remembered",
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def _single_pr_lookup(
|
|
699
|
+
*,
|
|
700
|
+
message: ErrorMessage | None,
|
|
701
|
+
pr: GithubPR,
|
|
702
|
+
source: PRLookupSource = "head",
|
|
703
|
+
) -> PRLookup:
|
|
704
|
+
open_ = pr.state == "open"
|
|
705
|
+
return PRLookup(
|
|
706
|
+
message=message,
|
|
707
|
+
pr=pr,
|
|
708
|
+
review_decision=(pr.review_decision if open_ and not pr.is_draft else None),
|
|
709
|
+
source=source,
|
|
710
|
+
state="open" if open_ else "closed",
|
|
711
|
+
)
|