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