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,162 @@
|
|
|
1
|
+
"""Observe the additional repo facts needed to plan selected convergence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import replace
|
|
6
|
+
|
|
7
|
+
from jj_stack.bootstrap import CommandContext
|
|
8
|
+
from jj_stack.github.client import GithubClient
|
|
9
|
+
from jj_stack.github.resolution import GithubRepoAddress
|
|
10
|
+
from jj_stack.models.github import GithubStack
|
|
11
|
+
from jj_stack.models.stack import LocalCommit
|
|
12
|
+
from jj_stack.stack.pr_facts import (
|
|
13
|
+
PRFacts,
|
|
14
|
+
RepoFacts,
|
|
15
|
+
observe_prs,
|
|
16
|
+
)
|
|
17
|
+
from jj_stack.stack.repo import observe_repo_paths
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def complete_sync_observation(
|
|
21
|
+
*,
|
|
22
|
+
context: CommandContext,
|
|
23
|
+
github: GithubClient,
|
|
24
|
+
initial: RepoFacts,
|
|
25
|
+
remote_name: str,
|
|
26
|
+
repo: GithubRepoAddress,
|
|
27
|
+
selected: tuple[LocalCommit, ...],
|
|
28
|
+
stacks: tuple[GithubStack, ...],
|
|
29
|
+
) -> tuple[RepoFacts, tuple[GithubStack, ...], bool]:
|
|
30
|
+
state = context.state_store.load()
|
|
31
|
+
selected_prs = {
|
|
32
|
+
identity.pr_number
|
|
33
|
+
for change in selected
|
|
34
|
+
if (identity := state.pr_identities.get(change.change_id)) is not None
|
|
35
|
+
}
|
|
36
|
+
affected = tuple(stack for stack in stacks if not selected_prs.isdisjoint(stack.pr_numbers))
|
|
37
|
+
resource_prs = {number for stack in affected for number in stack.pr_numbers}
|
|
38
|
+
tracked_prs = {
|
|
39
|
+
identity.pr_number
|
|
40
|
+
for identity in state.pr_identities.values()
|
|
41
|
+
if identity.repo_key == repo.repo_key
|
|
42
|
+
}
|
|
43
|
+
if not any(
|
|
44
|
+
_pr_changed(observed, include_remote_target=False)
|
|
45
|
+
for change in selected
|
|
46
|
+
if (observed := initial.prs.get(change.change_id)) is not None
|
|
47
|
+
) and (resource_prs & tracked_prs).issubset(selected_prs):
|
|
48
|
+
return initial, (), False
|
|
49
|
+
change_ids = tuple(
|
|
50
|
+
change_id
|
|
51
|
+
for change_id, identity in state.pr_identities.items()
|
|
52
|
+
if identity.repo_key == repo.repo_key
|
|
53
|
+
and identity.pr_number in resource_prs
|
|
54
|
+
and change_id in state.submitted_baselines
|
|
55
|
+
)
|
|
56
|
+
missing_ids = tuple(change_id for change_id in change_ids if change_id not in initial.prs)
|
|
57
|
+
missing = (
|
|
58
|
+
await observe_prs(
|
|
59
|
+
change_ids=missing_ids,
|
|
60
|
+
context=context,
|
|
61
|
+
github_client=github,
|
|
62
|
+
github_repo_snapshot=initial.github_repo,
|
|
63
|
+
include_remote_targets=False,
|
|
64
|
+
remote_name=remote_name,
|
|
65
|
+
)
|
|
66
|
+
if missing_ids
|
|
67
|
+
else None
|
|
68
|
+
)
|
|
69
|
+
prs = {
|
|
70
|
+
**initial.prs,
|
|
71
|
+
**(missing.prs if missing is not None else {}),
|
|
72
|
+
}
|
|
73
|
+
identities = tuple(item.identity for item in prs.values() if item.identity is not None)
|
|
74
|
+
remote = initial.remote or (missing.remote if missing is not None else None)
|
|
75
|
+
targets = (
|
|
76
|
+
context.jj_client.list_remote_branches(
|
|
77
|
+
remote=remote.name,
|
|
78
|
+
patterns=tuple(f"refs/heads/{item.head_ref}" for item in identities),
|
|
79
|
+
)
|
|
80
|
+
if remote is not None and identities
|
|
81
|
+
else {}
|
|
82
|
+
)
|
|
83
|
+
observation = replace(
|
|
84
|
+
initial,
|
|
85
|
+
prs={
|
|
86
|
+
change_id: replace(
|
|
87
|
+
item,
|
|
88
|
+
remote_pr_branch_target=(
|
|
89
|
+
targets.get(item.identity.head_ref) if item.identity is not None else None
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
for change_id, item in prs.items()
|
|
93
|
+
},
|
|
94
|
+
)
|
|
95
|
+
changed = any(_pr_changed(item) for item in observation.prs.values())
|
|
96
|
+
return observation, stacks if changed else (), changed
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def queued_pr_numbers(
|
|
100
|
+
observation: RepoFacts,
|
|
101
|
+
selected: tuple[LocalCommit, ...],
|
|
102
|
+
) -> tuple[int, ...]:
|
|
103
|
+
return tuple(
|
|
104
|
+
pr.number
|
|
105
|
+
for change in selected
|
|
106
|
+
if (observed := observation.prs.get(change.change_id)) is not None
|
|
107
|
+
and (pr := observed.pr) is not None
|
|
108
|
+
and pr.normalize_state().state == "open"
|
|
109
|
+
and pr.is_queued
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def dependent_path_heads(
|
|
114
|
+
*,
|
|
115
|
+
ancestor_commit_ids: tuple[str, ...],
|
|
116
|
+
context: CommandContext,
|
|
117
|
+
excluded_change_ids: frozenset[str],
|
|
118
|
+
) -> dict[str, tuple[LocalCommit, ...]]:
|
|
119
|
+
if not ancestor_commit_ids:
|
|
120
|
+
return {}
|
|
121
|
+
paths = observe_repo_paths(
|
|
122
|
+
jj_client=context.jj_client,
|
|
123
|
+
descendant_of=ancestor_commit_ids,
|
|
124
|
+
include_working_copies=True,
|
|
125
|
+
state=context.state_store.load(),
|
|
126
|
+
).paths
|
|
127
|
+
result: dict[str, tuple[LocalCommit, ...]] = {}
|
|
128
|
+
for ancestor in ancestor_commit_ids:
|
|
129
|
+
heads: dict[str, LocalCommit] = {}
|
|
130
|
+
for path in paths:
|
|
131
|
+
if not any(item.commit_id == ancestor for item in path.stack.changes):
|
|
132
|
+
continue
|
|
133
|
+
head = next(
|
|
134
|
+
(
|
|
135
|
+
change
|
|
136
|
+
for change in reversed(path.stack.changes)
|
|
137
|
+
if change.change_id not in excluded_change_ids
|
|
138
|
+
),
|
|
139
|
+
None,
|
|
140
|
+
)
|
|
141
|
+
if head is not None:
|
|
142
|
+
heads[head.commit_id] = head
|
|
143
|
+
result[ancestor] = tuple(heads.values())
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _pr_changed(
|
|
148
|
+
observed: PRFacts,
|
|
149
|
+
*,
|
|
150
|
+
include_remote_target: bool = True,
|
|
151
|
+
) -> bool:
|
|
152
|
+
pr = observed.pr
|
|
153
|
+
if (baseline := observed.baseline) is None:
|
|
154
|
+
return False
|
|
155
|
+
if pr is None:
|
|
156
|
+
return True
|
|
157
|
+
return (
|
|
158
|
+
pr.normalize_state().state == "merged"
|
|
159
|
+
or pr.head.sha != baseline.commit_id
|
|
160
|
+
or (include_remote_target and observed.remote_pr_branch_target != baseline.commit_id)
|
|
161
|
+
or any(commit.immutable for commit in observed.local_commits)
|
|
162
|
+
)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Mutation guards for GitHub stack resources."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Collection, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import jj_stack.ui as ui
|
|
9
|
+
from jj_stack.errors import CliError
|
|
10
|
+
from jj_stack.github.client import GithubClient, GithubClientError
|
|
11
|
+
from jj_stack.models.github import GithubStack
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def selected_github_stack(
|
|
15
|
+
*,
|
|
16
|
+
selected_pr_numbers: Collection[int],
|
|
17
|
+
stacks: Sequence[GithubStack],
|
|
18
|
+
) -> GithubStack | None:
|
|
19
|
+
"""Return the one GitHub stack the selected pull requests belong to.
|
|
20
|
+
|
|
21
|
+
The selection may overlap at most one resource, and every active member of that resource
|
|
22
|
+
must be selected. A merged prefix GitHub retains is always valid, so a resource the selection
|
|
23
|
+
touches only through merged members is still returned for callers that reconcile them.
|
|
24
|
+
Callers that mutate a resource separately require a selected pull request to remain active.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
selected = set(selected_pr_numbers)
|
|
28
|
+
overlapping = tuple(stack for stack in stacks if not selected.isdisjoint(stack.pr_numbers))
|
|
29
|
+
if not overlapping:
|
|
30
|
+
return None
|
|
31
|
+
# Only a resource the selection still has an active member in can be dissolved. GitHub keeps
|
|
32
|
+
# merged members forever, so unstacking a fully merged resource changes nothing, and naming
|
|
33
|
+
# one in a hint would leave the user retrying a command that cannot make progress.
|
|
34
|
+
dissolvable = tuple(
|
|
35
|
+
stack for stack in overlapping if not selected.isdisjoint(stack.active_pr_numbers)
|
|
36
|
+
)
|
|
37
|
+
if len(dissolvable) > 1:
|
|
38
|
+
numbers = tuple(sorted(stack.number for stack in dissolvable))
|
|
39
|
+
raise CliError(
|
|
40
|
+
t"The selected pull requests belong to GitHub stacks "
|
|
41
|
+
t"{ui.join(lambda number: f'#{number}', numbers)}.",
|
|
42
|
+
hint=t"Run "
|
|
43
|
+
t"{ui.join(lambda number: ui.cmd(f'jj-stack unstack --stack {number}'), numbers)}, "
|
|
44
|
+
t"then retry.",
|
|
45
|
+
)
|
|
46
|
+
if not dissolvable and len(overlapping) > 1:
|
|
47
|
+
numbers = tuple(sorted(stack.number for stack in overlapping))
|
|
48
|
+
raise CliError(
|
|
49
|
+
t"The selected pull requests are merged members of GitHub stacks "
|
|
50
|
+
t"{ui.join(lambda number: f'#{number}', numbers)}.",
|
|
51
|
+
hint="Select changes belonging to one of those stacks, then retry.",
|
|
52
|
+
)
|
|
53
|
+
stack = dissolvable[0] if dissolvable else overlapping[0]
|
|
54
|
+
unselected = tuple(number for number in stack.active_pr_numbers if number not in selected)
|
|
55
|
+
if unselected:
|
|
56
|
+
raise CliError(
|
|
57
|
+
t"GitHub stack #{stack.number} keeps "
|
|
58
|
+
t"{ui.join(lambda number: f'#{number}', unselected)} active outside the selected "
|
|
59
|
+
t"stack.",
|
|
60
|
+
hint=t"Select the complete stack, or run "
|
|
61
|
+
t"{ui.cmd(f'jj-stack unstack --stack {stack.number}')}, then retry.",
|
|
62
|
+
)
|
|
63
|
+
return stack
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def dissolve_github_stack(
|
|
67
|
+
*,
|
|
68
|
+
github_client: GithubClient,
|
|
69
|
+
stack: GithubStack,
|
|
70
|
+
) -> None:
|
|
71
|
+
"""Dissolve an observed stack and reject an incomplete mutation result."""
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
remaining = await github_client.unstack(stack_number=stack.number)
|
|
75
|
+
except GithubClientError as error:
|
|
76
|
+
if error.status_code == 422:
|
|
77
|
+
raise CliError(
|
|
78
|
+
t"GitHub could not remove any pull requests from stack #{stack.number}.",
|
|
79
|
+
hint=t"Resolve its locked pull requests, then retry "
|
|
80
|
+
t"{ui.cmd(f'jj-stack unstack --stack {stack.number}')}",
|
|
81
|
+
) from None
|
|
82
|
+
raise CliError(t"Could not remove GitHub stack grouping #{stack.number}.") from error
|
|
83
|
+
if remaining is not None and remaining.active_pr_numbers:
|
|
84
|
+
members = ", ".join(f"#{number}" for number in remaining.active_pr_numbers)
|
|
85
|
+
raise CliError(
|
|
86
|
+
t"GitHub stack #{stack.number} still contains {members}.",
|
|
87
|
+
hint=t"Resolve its locked pull requests, then retry "
|
|
88
|
+
t"{ui.cmd(f'jj-stack unstack --stack {stack.number}')}.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(frozen=True, slots=True)
|
|
93
|
+
class GithubStackSelection:
|
|
94
|
+
"""Live stack membership for one exact ordered PR selection."""
|
|
95
|
+
|
|
96
|
+
github_client: GithubClient
|
|
97
|
+
pr_numbers: tuple[int, ...]
|
|
98
|
+
|
|
99
|
+
async def observe(self) -> tuple[GithubStack, ...]:
|
|
100
|
+
"""Return the current complete GitHub stack resources."""
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
return await self.github_client.list_stacks()
|
|
104
|
+
except GithubClientError as error:
|
|
105
|
+
raise CliError("Could not inspect GitHub stack membership.") from error
|
|
106
|
+
|
|
107
|
+
async def active_stacks(self) -> tuple[GithubStack, ...]:
|
|
108
|
+
"""Return resources in which a selected pull request is still an active member.
|
|
109
|
+
|
|
110
|
+
Only these resources can be dissolved or blocked on: GitHub keeps merged members
|
|
111
|
+
forever, so a resource the selection only touches through them needs no mutation.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
if not self.pr_numbers:
|
|
115
|
+
return ()
|
|
116
|
+
stacks = await self.observe()
|
|
117
|
+
selected = set(self.pr_numbers)
|
|
118
|
+
return tuple(
|
|
119
|
+
stack for stack in stacks if not selected.isdisjoint(stack.active_pr_numbers)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
async def require_unstacked(self) -> None:
|
|
123
|
+
"""Reject a mutation while a selected pull request is still an active member."""
|
|
124
|
+
|
|
125
|
+
blocking = await self.active_stacks()
|
|
126
|
+
if not blocking:
|
|
127
|
+
return
|
|
128
|
+
stack_number = blocking[0].number
|
|
129
|
+
raise CliError(
|
|
130
|
+
t"GitHub stack #{stack_number} blocks this jj-stack operation.",
|
|
131
|
+
hint=t"Run {ui.cmd(f'jj-stack unstack --stack {stack_number}')} and retry.",
|
|
132
|
+
)
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Observe and classify repo-wide convergence without mutating it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import jj_stack.github.resolution as github_resolution
|
|
10
|
+
from jj_stack.bootstrap import CommandContext
|
|
11
|
+
from jj_stack.github.client import GithubClient
|
|
12
|
+
from jj_stack.models.github import GithubPR, GithubStack
|
|
13
|
+
from jj_stack.models.stack import LocalCommit
|
|
14
|
+
from jj_stack.models.tracking import TrackedPR, TrackingState
|
|
15
|
+
from jj_stack.stack.convergence_models import (
|
|
16
|
+
FinishPR,
|
|
17
|
+
PRFinishPlan,
|
|
18
|
+
SkipPRFinish,
|
|
19
|
+
)
|
|
20
|
+
from jj_stack.stack.path import RepoStackPath
|
|
21
|
+
from jj_stack.stack.pr_branches import prepare_visible_pr_snapshots
|
|
22
|
+
from jj_stack.stack.pr_facts import (
|
|
23
|
+
RepoFacts,
|
|
24
|
+
observe_github_stacks,
|
|
25
|
+
observe_prs,
|
|
26
|
+
)
|
|
27
|
+
from jj_stack.stack.repo import observe_repo_paths
|
|
28
|
+
from jj_stack.stack.trunk_evidence import (
|
|
29
|
+
CommitAncestry,
|
|
30
|
+
TrunkEvidenceKind,
|
|
31
|
+
classify_commit_ancestries,
|
|
32
|
+
classify_proven_kind,
|
|
33
|
+
)
|
|
34
|
+
from jj_stack.ui import Message
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class GlobalConvergencePlan:
|
|
39
|
+
blocked: tuple[tuple[TrackedPR, Message], ...]
|
|
40
|
+
finishes: tuple[PRFinishPlan, ...]
|
|
41
|
+
sync_change_ids: tuple[str, ...]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class GlobalSyncFacts:
|
|
46
|
+
"""One repo-wide observation for global classification."""
|
|
47
|
+
|
|
48
|
+
ancestries: Mapping[str, CommitAncestry]
|
|
49
|
+
local_copies: Mapping[str, tuple[LocalCommit, ...]]
|
|
50
|
+
paths: tuple[RepoStackPath, ...]
|
|
51
|
+
pr_facts: RepoFacts
|
|
52
|
+
stacks: tuple[GithubStack, ...]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def observe_global_sync(
|
|
56
|
+
*,
|
|
57
|
+
context: CommandContext,
|
|
58
|
+
github: GithubClient,
|
|
59
|
+
remote_name: str,
|
|
60
|
+
trunk_commit_id: str,
|
|
61
|
+
) -> GlobalSyncFacts:
|
|
62
|
+
"""Observe tracked pull requests from tracking toward affected local paths."""
|
|
63
|
+
|
|
64
|
+
state = context.state_store.load()
|
|
65
|
+
candidates = state.tracked_prs()
|
|
66
|
+
change_ids = tuple(candidate.change_id for candidate in candidates)
|
|
67
|
+
prepare_visible_pr_snapshots(
|
|
68
|
+
jj_client=context.jj_client,
|
|
69
|
+
state=state,
|
|
70
|
+
)
|
|
71
|
+
all_copies, local_copies = context.jj_client.query_commits_by_change_ids_with_off_trunk(
|
|
72
|
+
change_ids
|
|
73
|
+
)
|
|
74
|
+
anchors = tuple(commit.commit_id for commits in local_copies.values() for commit in commits)
|
|
75
|
+
paths = (
|
|
76
|
+
observe_repo_paths(
|
|
77
|
+
jj_client=context.jj_client,
|
|
78
|
+
descendant_of=anchors,
|
|
79
|
+
exclude_trunk_descendants=True,
|
|
80
|
+
include_working_copies=True,
|
|
81
|
+
state=state,
|
|
82
|
+
).paths
|
|
83
|
+
if anchors
|
|
84
|
+
else ()
|
|
85
|
+
)
|
|
86
|
+
pr_observations, stacks = await asyncio.gather(
|
|
87
|
+
observe_prs(
|
|
88
|
+
change_ids=change_ids,
|
|
89
|
+
context=context,
|
|
90
|
+
github_client=github,
|
|
91
|
+
include_remote_targets=False,
|
|
92
|
+
local_commits_snapshot=all_copies,
|
|
93
|
+
remote_name=remote_name,
|
|
94
|
+
),
|
|
95
|
+
observe_github_stacks(github=github),
|
|
96
|
+
)
|
|
97
|
+
commit_ids = _observation_commit_ids(pr_observations)
|
|
98
|
+
return GlobalSyncFacts(
|
|
99
|
+
ancestries=classify_commit_ancestries(
|
|
100
|
+
commit_ids=commit_ids,
|
|
101
|
+
context=context,
|
|
102
|
+
trunk_commit_id=trunk_commit_id,
|
|
103
|
+
),
|
|
104
|
+
local_copies=local_copies,
|
|
105
|
+
paths=paths,
|
|
106
|
+
pr_facts=pr_observations,
|
|
107
|
+
stacks=stacks,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def build_global_convergence_plan(
|
|
112
|
+
*,
|
|
113
|
+
facts: GlobalSyncFacts,
|
|
114
|
+
repo: github_resolution.GithubRepoAddress,
|
|
115
|
+
state: TrackingState,
|
|
116
|
+
) -> GlobalConvergencePlan:
|
|
117
|
+
blocked: list[tuple[TrackedPR, Message]] = []
|
|
118
|
+
finishes: list[PRFinishPlan] = []
|
|
119
|
+
heads: list[str] = []
|
|
120
|
+
tracked_prs = frozenset(
|
|
121
|
+
identity.pr_number
|
|
122
|
+
for identity in state.pr_identities.values()
|
|
123
|
+
if identity.repo_key == repo.repo_key
|
|
124
|
+
)
|
|
125
|
+
for candidate in state.tracked_prs():
|
|
126
|
+
reason, finish, candidate_heads = _classify_global_candidate(
|
|
127
|
+
candidate=candidate,
|
|
128
|
+
facts=facts,
|
|
129
|
+
repo=repo,
|
|
130
|
+
tracked_pr_numbers=tracked_prs,
|
|
131
|
+
)
|
|
132
|
+
heads.extend(candidate_heads)
|
|
133
|
+
if reason is not None:
|
|
134
|
+
blocked.append((candidate, reason))
|
|
135
|
+
if finish is not None:
|
|
136
|
+
finishes.append(finish)
|
|
137
|
+
return GlobalConvergencePlan(
|
|
138
|
+
blocked=tuple(blocked),
|
|
139
|
+
finishes=tuple(finishes),
|
|
140
|
+
sync_change_ids=tuple(dict.fromkeys(heads)),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _classify_global_candidate(
|
|
145
|
+
*,
|
|
146
|
+
candidate: TrackedPR,
|
|
147
|
+
facts: GlobalSyncFacts,
|
|
148
|
+
repo: github_resolution.GithubRepoAddress,
|
|
149
|
+
tracked_pr_numbers: frozenset[int],
|
|
150
|
+
) -> tuple[Message | None, PRFinishPlan | None, tuple[str, ...]]:
|
|
151
|
+
ancestry = facts.ancestries[candidate.submitted_baseline.commit_id]
|
|
152
|
+
pr = facts.pr_facts.prs[candidate.change_id].pr
|
|
153
|
+
evidence: TrunkEvidenceKind | None = None
|
|
154
|
+
reason: Message = ""
|
|
155
|
+
if pr is not None:
|
|
156
|
+
evidence, reason = classify_proven_kind(
|
|
157
|
+
ancestries=facts.ancestries,
|
|
158
|
+
candidate=candidate,
|
|
159
|
+
pr=pr,
|
|
160
|
+
repo=repo,
|
|
161
|
+
)
|
|
162
|
+
heads = _candidate_path_heads(candidate, facts=facts)
|
|
163
|
+
affected = ancestry == "on_trunk" or evidence == "rewritten"
|
|
164
|
+
if affected:
|
|
165
|
+
return _affected_candidate_plan(
|
|
166
|
+
candidate=candidate,
|
|
167
|
+
evidence=evidence,
|
|
168
|
+
facts=facts,
|
|
169
|
+
heads=heads,
|
|
170
|
+
pr=pr,
|
|
171
|
+
reason=reason,
|
|
172
|
+
tracked_prs=tracked_pr_numbers,
|
|
173
|
+
)
|
|
174
|
+
if pr is None:
|
|
175
|
+
return t"GitHub no longer reports PR #{candidate.pr_identity.pr_number}", None, ()
|
|
176
|
+
if ancestry == "unresolved":
|
|
177
|
+
return "the submitted commit is unavailable locally", None, ()
|
|
178
|
+
if reason and (
|
|
179
|
+
pr.normalize_state().state != "open" or not candidate.pr_identity.matches_pr(pr)
|
|
180
|
+
):
|
|
181
|
+
return reason, None, ()
|
|
182
|
+
return None, None, ()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _affected_candidate_plan(
|
|
186
|
+
*,
|
|
187
|
+
candidate: TrackedPR,
|
|
188
|
+
evidence: TrunkEvidenceKind | None,
|
|
189
|
+
facts: GlobalSyncFacts,
|
|
190
|
+
heads: tuple[str, ...] | None,
|
|
191
|
+
pr: GithubPR | None,
|
|
192
|
+
reason: Message,
|
|
193
|
+
tracked_prs: frozenset[int],
|
|
194
|
+
) -> tuple[Message | None, PRFinishPlan | None, tuple[str, ...]]:
|
|
195
|
+
if heads is None:
|
|
196
|
+
return "local history is not a supported stack", None, ()
|
|
197
|
+
if heads:
|
|
198
|
+
return None, None, heads
|
|
199
|
+
if pr is None:
|
|
200
|
+
return t"GitHub no longer reports PR #{candidate.pr_identity.pr_number}", None, ()
|
|
201
|
+
if evidence is None:
|
|
202
|
+
return reason, None, ()
|
|
203
|
+
stack_reason, historical = _detached_stack_blocker(
|
|
204
|
+
candidate=candidate,
|
|
205
|
+
stacks=facts.stacks,
|
|
206
|
+
tracked_pr_numbers=tracked_prs,
|
|
207
|
+
)
|
|
208
|
+
if stack_reason is not None:
|
|
209
|
+
return stack_reason, None, ()
|
|
210
|
+
finish = (
|
|
211
|
+
SkipPRFinish(candidate)
|
|
212
|
+
if evidence == "rewritten" or historical or pr.normalize_state().state != "open"
|
|
213
|
+
else FinishPR(candidate, pr)
|
|
214
|
+
)
|
|
215
|
+
return None, finish, ()
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _candidate_path_heads(
|
|
219
|
+
candidate: TrackedPR, *, facts: GlobalSyncFacts
|
|
220
|
+
) -> tuple[str, ...] | None:
|
|
221
|
+
copies = {commit.commit_id for commit in facts.local_copies[candidate.change_id]}
|
|
222
|
+
if not copies:
|
|
223
|
+
return ()
|
|
224
|
+
heads = tuple(
|
|
225
|
+
path.stack.head.change_id
|
|
226
|
+
for path in facts.paths
|
|
227
|
+
if any(change.commit_id in copies for change in path.stack.changes)
|
|
228
|
+
)
|
|
229
|
+
return heads or None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _detached_stack_blocker(
|
|
233
|
+
*,
|
|
234
|
+
candidate: TrackedPR,
|
|
235
|
+
stacks: tuple[GithubStack, ...],
|
|
236
|
+
tracked_pr_numbers: frozenset[int],
|
|
237
|
+
) -> tuple[Message | None, bool]:
|
|
238
|
+
number = candidate.pr_identity.pr_number
|
|
239
|
+
matching = tuple(
|
|
240
|
+
member for stack in stacks for member in stack.prs if member.number == number
|
|
241
|
+
)
|
|
242
|
+
if not matching:
|
|
243
|
+
return None, False
|
|
244
|
+
if not matching[0].is_historical:
|
|
245
|
+
return t"GitHub still lists PR #{number} as an active member of its stack", False
|
|
246
|
+
blocked = any(
|
|
247
|
+
number in stack.pr_numbers
|
|
248
|
+
and not set(stack.active_pr_numbers).isdisjoint(tracked_pr_numbers)
|
|
249
|
+
for stack in stacks
|
|
250
|
+
)
|
|
251
|
+
return (
|
|
252
|
+
(
|
|
253
|
+
t"PR #{number} is in a GitHub stack that still has active members tracked here"
|
|
254
|
+
if blocked
|
|
255
|
+
else None
|
|
256
|
+
),
|
|
257
|
+
True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _observation_commit_ids(observation: RepoFacts) -> tuple[str, ...]:
|
|
262
|
+
return tuple(
|
|
263
|
+
commit_id
|
|
264
|
+
for item in observation.prs.values()
|
|
265
|
+
for commit_id in (
|
|
266
|
+
item.baseline.commit_id if item.baseline is not None else None,
|
|
267
|
+
item.pr.merge_commit_sha if item.pr is not None else None,
|
|
268
|
+
)
|
|
269
|
+
if commit_id is not None
|
|
270
|
+
)
|