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,78 @@
1
+ """Configured ownership policy for jj-stack PR branches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+ from jj_stack.identifiers import short_change_id
9
+ from jj_stack.models.stack import LocalCommit
10
+
11
+ _DEFAULT_SLUG = "change"
12
+ _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
13
+ _current_namespace: PRBranchNamespace | None = None
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class PRBranchNamespace:
18
+ """The configured namespace jj-stack may use for PR branches."""
19
+
20
+ prefix: str
21
+
22
+ @property
23
+ def branch_prefix(self) -> str:
24
+ """Return the namespace as users see it, such as ``jj-stack/``."""
25
+
26
+ return f"{self.prefix}/"
27
+
28
+ @property
29
+ def branch_glob(self) -> str:
30
+ """Return the bookmark pattern matching every branch in the namespace."""
31
+
32
+ return f"{self.branch_prefix}*"
33
+
34
+ @property
35
+ def fetch_refspec(self) -> str:
36
+ """Return the negative Git refspec that excludes the namespace from fetch."""
37
+
38
+ return f"^refs/heads/{self.branch_glob}"
39
+
40
+ def generate_branch(self, change: LocalCommit) -> str:
41
+ """Generate the initial readable branch name for a change."""
42
+
43
+ first_line = change.description.splitlines()[0] if change.description else ""
44
+ slug = _NON_ALNUM_RE.sub("-", first_line.lower()).strip("-") or _DEFAULT_SLUG
45
+ return f"{self.branch_prefix}{slug}-{short_change_id(change.change_id)}"
46
+
47
+ def contains(self, branch: str) -> bool:
48
+ """Return whether a branch belongs to this namespace."""
49
+
50
+ return branch.startswith(self.branch_prefix)
51
+
52
+ def branch_ref(self, branch: str) -> str:
53
+ """Return the full Git ref for one branch, refusing anything outside."""
54
+
55
+ if not self.contains(branch):
56
+ raise ValueError(f"not a branch in the {self.branch_prefix} namespace: {branch!r}")
57
+ return f"refs/heads/{branch}"
58
+
59
+
60
+ def install_pr_branch_namespace(prefix: str) -> None:
61
+ """Install the configured namespace for the current CLI invocation."""
62
+
63
+ global _current_namespace
64
+ _current_namespace = PRBranchNamespace(prefix)
65
+
66
+
67
+ def current_pr_branch_namespace() -> PRBranchNamespace:
68
+ """Return the namespace installed during command bootstrap."""
69
+
70
+ if _current_namespace is None:
71
+ raise RuntimeError("PR branch namespace has not been installed")
72
+ return _current_namespace
73
+
74
+
75
+ def pr_branch_matches_change(branch: str, change_id: str) -> bool:
76
+ """Whether a branch carries the change's short-ID suffix."""
77
+
78
+ return branch.endswith(f"-{short_change_id(change_id)}")
@@ -0,0 +1 @@
1
+ """Stack workflow helpers and orchestration."""
@@ -0,0 +1,216 @@
1
+ """Derived per-change pull request lifecycle classification.
2
+
3
+ This module centralizes the observational state that commands derive from the local `jj` stack,
4
+ saved tracking data, remote-ref observations, and GitHub PR lookups. It deliberately does not
5
+ mutate tracking state or decide command policy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Sequence
11
+ from dataclasses import dataclass
12
+ from typing import TYPE_CHECKING, Literal
13
+
14
+ from jj_stack.models.stack import LocalStack
15
+ from jj_stack.models.tracking import PRIdentity, TrackingState
16
+
17
+ if TYPE_CHECKING:
18
+ from jj_stack.stack.status import PRLookup, StackStatusChange
19
+
20
+ LocalTrackingState = Literal["present", "divergent", "orphaned", "missing"]
21
+ PRLifecycle = Literal[
22
+ "none",
23
+ "open",
24
+ "closed",
25
+ "merged",
26
+ "missing",
27
+ "ambiguous",
28
+ ]
29
+ PRReviewDecision = Literal[
30
+ "none",
31
+ "approved",
32
+ "changes_requested",
33
+ "commented",
34
+ "unknown",
35
+ ]
36
+
37
+
38
+ @dataclass(frozen=True, slots=True)
39
+ class ChangeStatus:
40
+ """Orthogonal local and pull request status axes for one logical change."""
41
+
42
+ local: LocalTrackingState
43
+ pr_lifecycle: PRLifecycle
44
+ pr_draft: bool | None
45
+ pr_queued: bool | None
46
+ pr_review_decision: PRReviewDecision
47
+ pr_lookup_error: bool = False
48
+ pr_review_decision_error: str | None = None
49
+ saved_pr_identity: bool = False
50
+
51
+ @property
52
+ def has_pr_lookup_failure(self) -> bool:
53
+ """Whether GitHub PR inspection failed for this change."""
54
+
55
+ return self.pr_lookup_error or self.pr_review_decision_error is not None
56
+
57
+ @property
58
+ def has_stale_pr_link(self) -> bool:
59
+ """Whether saved PR identity exists but live branch lookup is missing."""
60
+
61
+ return self.pr_lifecycle == "missing" and self.saved_pr_identity
62
+
63
+ @property
64
+ def makes_report_incomplete(self) -> bool:
65
+ """Whether this change stops `view` and `list` from reporting it completely.
66
+
67
+ Both report commands share this rule so the same repo cannot yield a complete
68
+ report from one and an incomplete report from the other.
69
+ """
70
+
71
+ return (
72
+ (self.local == "divergent" and self.pr_lifecycle != "merged")
73
+ or self.pr_lifecycle == "ambiguous"
74
+ or self.has_pr_lookup_failure
75
+ or self.has_stale_pr_link
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class OrphanedRecord:
81
+ """A saved tracking record whose change has left every live stack."""
82
+
83
+ change_id: str
84
+ pr_identity: PRIdentity
85
+
86
+
87
+ def classify_stack_status_change(
88
+ change: StackStatusChange,
89
+ ) -> ChangeStatus:
90
+ """Classify a rendered status change without performing I/O."""
91
+
92
+ local: LocalTrackingState = "divergent" if change.local_divergent else "present"
93
+ return classify_change_status(
94
+ local=local,
95
+ pr_lookup=change.pr_lookup,
96
+ pr_identity=change.pr_identity,
97
+ )
98
+
99
+
100
+ def classify_change_status(
101
+ *,
102
+ local: LocalTrackingState,
103
+ pr_lookup: PRLookup | None,
104
+ pr_identity: PRIdentity | None = None,
105
+ ) -> ChangeStatus:
106
+ """Derive change status axes from already-loaded facts."""
107
+
108
+ lifecycle, pr_lookup_error = _pr_lifecycle(pr_lookup)
109
+ return ChangeStatus(
110
+ local=local,
111
+ pr_lifecycle=lifecycle,
112
+ pr_draft=_pr_draft(
113
+ lifecycle=lifecycle,
114
+ pr_lookup=pr_lookup,
115
+ ),
116
+ pr_queued=(
117
+ pr_lookup.pr.is_queued
118
+ if lifecycle == "open" and pr_lookup is not None and pr_lookup.pr is not None
119
+ else None
120
+ ),
121
+ pr_review_decision=_pr_review_decision(
122
+ lifecycle=lifecycle,
123
+ pr_lookup=pr_lookup,
124
+ ),
125
+ pr_lookup_error=pr_lookup_error,
126
+ pr_review_decision_error=(None if pr_lookup is None else pr_lookup.review_decision_error),
127
+ saved_pr_identity=pr_identity is not None,
128
+ )
129
+
130
+
131
+ def enumerate_orphaned_records(
132
+ state: TrackingState,
133
+ local_stacks: Sequence[LocalStack],
134
+ ) -> tuple[OrphanedRecord, ...]:
135
+ """Return saved PR records whose change is no longer in any live stack."""
136
+
137
+ live_change_ids: set[str] = set()
138
+ for stack in local_stacks:
139
+ for change in stack.changes:
140
+ live_change_ids.add(change.change_id)
141
+
142
+ orphans: list[OrphanedRecord] = []
143
+ for change_id, pr_identity in state.pr_identities.items():
144
+ if change_id in live_change_ids:
145
+ continue
146
+ orphans.append(OrphanedRecord(change_id=change_id, pr_identity=pr_identity))
147
+ return tuple(orphans)
148
+
149
+
150
+ def submitted_state_disagreement(
151
+ state: TrackingState,
152
+ local_stacks: Sequence[LocalStack],
153
+ ) -> tuple[str, ...]:
154
+ """Return change_ids whose saved submit baseline no longer matches the DAG."""
155
+
156
+ disagreements: list[str] = []
157
+ for stack in local_stacks:
158
+ for change in stack.changes:
159
+ pr_identity = state.pr_identities.get(change.change_id)
160
+ if pr_identity is None:
161
+ continue
162
+ if state.submitted_baselines[change.change_id].commit_id != change.commit_id:
163
+ disagreements.append(change.change_id)
164
+ return tuple(disagreements)
165
+
166
+
167
+ def _pr_lifecycle(
168
+ pr_lookup: PRLookup | None,
169
+ ) -> tuple[PRLifecycle, bool]:
170
+ if pr_lookup is None:
171
+ return "none", False
172
+ lookup_state = pr_lookup.state
173
+ if lookup_state == "open":
174
+ return "open", False
175
+ if lookup_state == "closed":
176
+ pr = pr_lookup.pr
177
+ if pr is not None and pr.state == "merged":
178
+ return "merged", False
179
+ return "closed", False
180
+ if lookup_state == "missing":
181
+ return "missing", False
182
+ if lookup_state == "ambiguous":
183
+ return "ambiguous", False
184
+ if lookup_state == "error":
185
+ return "none", True
186
+ return "none", True
187
+
188
+
189
+ def _pr_draft(
190
+ *,
191
+ lifecycle: PRLifecycle,
192
+ pr_lookup: PRLookup | None,
193
+ ) -> bool | None:
194
+ if lifecycle != "open" or pr_lookup is None:
195
+ return None
196
+ pr = pr_lookup.pr
197
+ if pr is None:
198
+ return None
199
+ return pr.is_draft
200
+
201
+
202
+ def _pr_review_decision(
203
+ *,
204
+ lifecycle: PRLifecycle,
205
+ pr_lookup: PRLookup | None,
206
+ ) -> PRReviewDecision:
207
+ if lifecycle != "open" or pr_lookup is None:
208
+ return "none"
209
+ if pr_lookup.review_decision_error is not None:
210
+ return "unknown"
211
+ decision = pr_lookup.review_decision
212
+ if decision is None:
213
+ return "none"
214
+ if decision in {"approved", "changes_requested", "commented"}:
215
+ return decision
216
+ return "unknown"