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,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
+ )