git-paoding 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.
- git_paoding/__init__.py +32 -0
- git_paoding/_agent_plugins/__init__.py +1 -0
- git_paoding/_agent_plugins/git-paoding/.claude-plugin/plugin.json +12 -0
- git_paoding/_agent_plugins/git-paoding/.codex-plugin/plugin.json +22 -0
- git_paoding/_agent_plugins/git-paoding/skills/git-paoding/SKILL.md +206 -0
- git_paoding/agent_install.py +113 -0
- git_paoding/api.py +383 -0
- git_paoding/cli/__init__.py +1 -0
- git_paoding/cli/facade.py +133 -0
- git_paoding/cli/main.py +277 -0
- git_paoding/cli/render.py +205 -0
- git_paoding/core/__init__.py +1 -0
- git_paoding/core/diffatoms.py +208 -0
- git_paoding/core/model.py +292 -0
- git_paoding/core/projection.py +376 -0
- git_paoding/core/publish.py +644 -0
- git_paoding/core/reconcile.py +220 -0
- git_paoding/core/selectors.py +279 -0
- git_paoding/github/__init__.py +1 -0
- git_paoding/github/backend.py +53 -0
- git_paoding/github/gh_cli.py +339 -0
- git_paoding/github/lifecycle.py +123 -0
- git_paoding/github/prbody.py +281 -0
- git_paoding/gitio/__init__.py +49 -0
- git_paoding/gitio/diffparse.py +273 -0
- git_paoding/gitio/plumbing.py +170 -0
- git_paoding/gitio/refs.py +145 -0
- git_paoding/gitio/runner.py +133 -0
- git_paoding/py.typed +1 -0
- git_paoding/store/__init__.py +6 -0
- git_paoding/store/jsonstore.py +142 -0
- git_paoding/store/lock.py +165 -0
- git_paoding-0.1.0.dist-info/METADATA +351 -0
- git_paoding-0.1.0.dist-info/RECORD +36 -0
- git_paoding-0.1.0.dist-info/WHEEL +4 -0
- git_paoding-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
"""Remote synchronization for semantic review sessions.
|
|
2
|
+
|
|
3
|
+
The functions here own generated ref updates and GitHub pull-request mutations.
|
|
4
|
+
All other core modules operate without remote side effects.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, replace
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from git_paoding.core.diffatoms import ReplayAtom, atomize_hunks
|
|
13
|
+
from git_paoding.core.model import (
|
|
14
|
+
AtomState,
|
|
15
|
+
DiffStat,
|
|
16
|
+
PaodingError,
|
|
17
|
+
PRRecord,
|
|
18
|
+
PRState,
|
|
19
|
+
PublishOutcome,
|
|
20
|
+
PublishResult,
|
|
21
|
+
PublishSliceResult,
|
|
22
|
+
Session,
|
|
23
|
+
SessionSummary,
|
|
24
|
+
SliceStatus,
|
|
25
|
+
SliceSummary,
|
|
26
|
+
StatusResult,
|
|
27
|
+
)
|
|
28
|
+
from git_paoding.core.projection import build_projection
|
|
29
|
+
from git_paoding.core.reconcile import reconcile
|
|
30
|
+
from git_paoding.github.backend import (
|
|
31
|
+
DuplicatePullRequestMarkerError,
|
|
32
|
+
GitHubBackend,
|
|
33
|
+
PullRequestNotFoundError,
|
|
34
|
+
)
|
|
35
|
+
from git_paoding.github.lifecycle import (
|
|
36
|
+
MergedSlicePullRequestError,
|
|
37
|
+
archive_slice_pr,
|
|
38
|
+
remove_slice_pr,
|
|
39
|
+
rename_slice_pr,
|
|
40
|
+
)
|
|
41
|
+
from git_paoding.github.prbody import (
|
|
42
|
+
IntegrationSliceLink,
|
|
43
|
+
RelatedSliceLink,
|
|
44
|
+
rewrite_integration_body,
|
|
45
|
+
rewrite_slice_body,
|
|
46
|
+
slice_marker,
|
|
47
|
+
)
|
|
48
|
+
from git_paoding.gitio.diffparse import diff_trees
|
|
49
|
+
from git_paoding.gitio.plumbing import rev_parse
|
|
50
|
+
from git_paoding.gitio.refs import (
|
|
51
|
+
RefSyncResult,
|
|
52
|
+
delete_projection_refs,
|
|
53
|
+
generated_refs,
|
|
54
|
+
sync_projection_refs,
|
|
55
|
+
)
|
|
56
|
+
from git_paoding.store.jsonstore import JsonSessionStore, branch_key
|
|
57
|
+
from git_paoding.store.lock import SessionLock
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class PublishError(PaodingError):
|
|
61
|
+
"""Raised when publication state is inconsistent or unsafe to guess."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class _PreparedSlice:
|
|
66
|
+
"""Projection refs prepared before any pull request is mutated."""
|
|
67
|
+
|
|
68
|
+
base_ref: str
|
|
69
|
+
head_ref: str
|
|
70
|
+
ref_sync: RefSyncResult
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def reconcile_and_status(
|
|
74
|
+
repo: Path,
|
|
75
|
+
session: Session,
|
|
76
|
+
*,
|
|
77
|
+
full: bool = False,
|
|
78
|
+
) -> tuple[Session, tuple[ReplayAtom, ...], StatusResult]:
|
|
79
|
+
"""Reconcile a session against its live canonical tip and build status."""
|
|
80
|
+
|
|
81
|
+
final_oid = rev_parse(repo, f"refs/heads/{session.canonical_branch}")
|
|
82
|
+
replay_atoms = atomize_hunks(diff_trees(repo, session.base_oid, final_oid))
|
|
83
|
+
reconciled_atoms = reconcile(
|
|
84
|
+
session.atoms,
|
|
85
|
+
tuple(item.atom for item in replay_atoms),
|
|
86
|
+
focus_slice=session.focus_slice,
|
|
87
|
+
)
|
|
88
|
+
atoms = list(reconciled_atoms)
|
|
89
|
+
if full:
|
|
90
|
+
atoms = [
|
|
91
|
+
atom.model_copy(update={"preview": _full_preview(replay_atom)})
|
|
92
|
+
for atom, replay_atom in zip(atoms, replay_atoms, strict=True)
|
|
93
|
+
]
|
|
94
|
+
session = session.model_copy(update={"atoms": atoms, "last_final_oid": final_oid})
|
|
95
|
+
status = status_from_session(
|
|
96
|
+
session,
|
|
97
|
+
defaulted_atom_ids=reconciled_atoms.defaulted_atom_ids,
|
|
98
|
+
)
|
|
99
|
+
return session, replay_atoms, status
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _full_preview(replay_atom: ReplayAtom) -> str:
|
|
103
|
+
"""Render every changed line from the authoritative current Git diff."""
|
|
104
|
+
|
|
105
|
+
if not replay_atom.removed_lines and not replay_atom.added_lines:
|
|
106
|
+
return replay_atom.atom.preview
|
|
107
|
+
|
|
108
|
+
rendered: list[str] = []
|
|
109
|
+
for prefix, lines in (("-", replay_atom.removed_lines), ("+", replay_atom.added_lines)):
|
|
110
|
+
for line in lines:
|
|
111
|
+
text = line.decode("utf-8", errors="replace")
|
|
112
|
+
rendered.append(prefix + text)
|
|
113
|
+
if text and not text.endswith("\n"):
|
|
114
|
+
rendered.append("\n")
|
|
115
|
+
return "".join(rendered).removesuffix("\n")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def status_from_session(
|
|
119
|
+
session: Session,
|
|
120
|
+
*,
|
|
121
|
+
defaulted_atom_ids: tuple[str, ...] = (),
|
|
122
|
+
) -> StatusResult:
|
|
123
|
+
"""Build a public status report from already-authoritative session state."""
|
|
124
|
+
|
|
125
|
+
slice_summaries: list[SliceSummary] = []
|
|
126
|
+
for slice_ in session.slices:
|
|
127
|
+
owned = [atom for atom in session.atoms if atom.owner == slice_.id]
|
|
128
|
+
slice_summaries.append(
|
|
129
|
+
SliceSummary(
|
|
130
|
+
id=slice_.id,
|
|
131
|
+
title=slice_.title,
|
|
132
|
+
status=slice_.status,
|
|
133
|
+
pr_number=slice_.pr_number,
|
|
134
|
+
diffstat=DiffStat(
|
|
135
|
+
files_changed=len({atom.path for atom in owned}),
|
|
136
|
+
additions=sum(atom.final_len for atom in owned),
|
|
137
|
+
deletions=sum(atom.base_len for atom in owned),
|
|
138
|
+
),
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
status = StatusResult(
|
|
143
|
+
session=SessionSummary(
|
|
144
|
+
canonical_branch=session.canonical_branch,
|
|
145
|
+
base_ref=session.base_ref,
|
|
146
|
+
base_oid=session.base_oid,
|
|
147
|
+
slice_pr_prefix=session.slice_pr_prefix,
|
|
148
|
+
last_final_oid=session.last_final_oid,
|
|
149
|
+
focus_slice=session.focus_slice,
|
|
150
|
+
integration_pr=session.integration_pr,
|
|
151
|
+
archived=session.archived,
|
|
152
|
+
),
|
|
153
|
+
slices=slice_summaries,
|
|
154
|
+
atoms=session.atoms,
|
|
155
|
+
unassigned_count=sum(atom.state is AtomState.UNASSIGNED for atom in session.atoms),
|
|
156
|
+
ambiguous_count=sum(atom.state is AtomState.AMBIGUOUS for atom in session.atoms),
|
|
157
|
+
defaulted_atom_ids=list(defaulted_atom_ids),
|
|
158
|
+
)
|
|
159
|
+
return status
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _short_ref(ref: str) -> str:
|
|
163
|
+
prefix = "refs/heads/"
|
|
164
|
+
return ref[len(prefix) :] if ref.startswith(prefix) else ref
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _integration_base_ref(session: Session, remote: str) -> str:
|
|
168
|
+
# Assumes the session's base ref names a branch, plain or as
|
|
169
|
+
# ``<remote>/<branch>`` for this publish remote; any other init --base
|
|
170
|
+
# form (an OID, a tag, another remote) fails loudly at gh pr create.
|
|
171
|
+
base_ref = session.base_ref or session.base_oid
|
|
172
|
+
remote_prefix = f"{remote}/"
|
|
173
|
+
if base_ref.startswith(remote_prefix):
|
|
174
|
+
return base_ref[len(remote_prefix) :]
|
|
175
|
+
return _short_ref(base_ref)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _integration_title(session: Session) -> str:
|
|
179
|
+
return session.canonical_branch
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _find_integration_pr(
|
|
183
|
+
backend: GitHubBackend,
|
|
184
|
+
session: Session,
|
|
185
|
+
open_prs: list[PRRecord],
|
|
186
|
+
) -> PRRecord | None:
|
|
187
|
+
stored: PRRecord | None = None
|
|
188
|
+
if session.integration_pr is not None:
|
|
189
|
+
try:
|
|
190
|
+
stored = backend.get_pr(session.integration_pr)
|
|
191
|
+
except PullRequestNotFoundError:
|
|
192
|
+
stored = None
|
|
193
|
+
if stored is not None and stored.state is PRState.MERGED:
|
|
194
|
+
raise PublishError(
|
|
195
|
+
f"Integration pull request #{stored.number} is already merged; "
|
|
196
|
+
"run `git-paoding archive` instead of publishing again"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
matches = [pr for pr in open_prs if pr.head_ref == session.canonical_branch]
|
|
200
|
+
if len(matches) > 1:
|
|
201
|
+
numbers = ", ".join(f"#{pr.number}" for pr in matches)
|
|
202
|
+
raise PublishError(
|
|
203
|
+
f"Multiple open PRs use canonical head {session.canonical_branch!r}: {numbers}"
|
|
204
|
+
)
|
|
205
|
+
if matches:
|
|
206
|
+
return matches[0]
|
|
207
|
+
|
|
208
|
+
if stored is None or stored.state is not PRState.OPEN:
|
|
209
|
+
return None
|
|
210
|
+
if stored.head_ref != session.canonical_branch:
|
|
211
|
+
raise PublishError(
|
|
212
|
+
f"Stored integration PR #{stored.number} has head {stored.head_ref!r}, "
|
|
213
|
+
f"expected {session.canonical_branch!r}"
|
|
214
|
+
)
|
|
215
|
+
return stored
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _create_integration_pr(
|
|
219
|
+
backend: GitHubBackend,
|
|
220
|
+
session: Session,
|
|
221
|
+
*,
|
|
222
|
+
remote: str,
|
|
223
|
+
) -> PRRecord:
|
|
224
|
+
initial_body = rewrite_integration_body("", slices=[])
|
|
225
|
+
return backend.create_draft_pr(
|
|
226
|
+
title=_integration_title(session),
|
|
227
|
+
body=initial_body,
|
|
228
|
+
base_ref=_integration_base_ref(session, remote),
|
|
229
|
+
head_ref=session.canonical_branch,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _find_slice_pr(
|
|
234
|
+
backend: GitHubBackend,
|
|
235
|
+
*,
|
|
236
|
+
slice_id: str,
|
|
237
|
+
stored_number: int | None,
|
|
238
|
+
open_prs: list[PRRecord],
|
|
239
|
+
) -> PRRecord | None:
|
|
240
|
+
"""Resolve identity marker-first, then recover a damaged stored PR body."""
|
|
241
|
+
|
|
242
|
+
marker = slice_marker(slice_id)
|
|
243
|
+
matches = [pr for pr in open_prs if marker in pr.body]
|
|
244
|
+
if len(matches) > 1:
|
|
245
|
+
numbers = ", ".join(f"#{pr.number}" for pr in matches)
|
|
246
|
+
raise DuplicatePullRequestMarkerError(
|
|
247
|
+
f"Multiple open pull requests contain marker {marker!r}: {numbers}. "
|
|
248
|
+
"Close or repair the duplicate before publishing."
|
|
249
|
+
)
|
|
250
|
+
if matches:
|
|
251
|
+
return matches[0]
|
|
252
|
+
if stored_number is None:
|
|
253
|
+
return None
|
|
254
|
+
try:
|
|
255
|
+
stored = backend.get_pr(stored_number)
|
|
256
|
+
except PullRequestNotFoundError:
|
|
257
|
+
return None
|
|
258
|
+
if stored.state is PRState.MERGED:
|
|
259
|
+
raise MergedSlicePullRequestError(
|
|
260
|
+
f"Slice pull request #{stored.number} is merged; generated review projections "
|
|
261
|
+
"must only be closed, never merged"
|
|
262
|
+
)
|
|
263
|
+
return stored if stored.state is PRState.OPEN else None
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _slice_diffstat(session: Session, slice_id: str) -> DiffStat:
|
|
267
|
+
owned = [atom for atom in session.atoms if atom.owner == slice_id]
|
|
268
|
+
return DiffStat(
|
|
269
|
+
files_changed=len({atom.path for atom in owned}),
|
|
270
|
+
additions=sum(atom.final_len for atom in owned),
|
|
271
|
+
deletions=sum(atom.base_len for atom in owned),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _related_links(
|
|
276
|
+
session: Session,
|
|
277
|
+
*,
|
|
278
|
+
slice_id: str,
|
|
279
|
+
prs: dict[str, PRRecord],
|
|
280
|
+
) -> tuple[RelatedSliceLink, ...]:
|
|
281
|
+
owned_paths = {atom.path for atom in session.atoms if atom.owner == slice_id}
|
|
282
|
+
links: list[RelatedSliceLink] = []
|
|
283
|
+
for related in session.slices:
|
|
284
|
+
if related.id == slice_id or related.status is not SliceStatus.ACTIVE:
|
|
285
|
+
continue
|
|
286
|
+
related_pr = prs.get(related.id)
|
|
287
|
+
if related_pr is None:
|
|
288
|
+
continue
|
|
289
|
+
related_paths = {atom.path for atom in session.atoms if atom.owner == related.id}
|
|
290
|
+
shared = tuple(sorted(owned_paths & related_paths))
|
|
291
|
+
if shared:
|
|
292
|
+
links.append(
|
|
293
|
+
RelatedSliceLink(
|
|
294
|
+
number=related_pr.number,
|
|
295
|
+
title=related.title,
|
|
296
|
+
url=related_pr.url,
|
|
297
|
+
shared_paths=shared,
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
return tuple(links)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _commit_url(pr_url: str, oid: str) -> str:
|
|
304
|
+
"""Derive the repository commit URL from a GitHub pull-request URL."""
|
|
305
|
+
|
|
306
|
+
for component in ("/pull/", "/pulls/"):
|
|
307
|
+
if component in pr_url:
|
|
308
|
+
return f"{pr_url.split(component, maxsplit=1)[0]}/commit/{oid}"
|
|
309
|
+
return f"{pr_url.rstrip('/')}/commits/{oid}"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _owned_replay_atoms(
|
|
313
|
+
replay_atoms: tuple[ReplayAtom, ...], reconciled_session: Session
|
|
314
|
+
) -> tuple[ReplayAtom, ...]:
|
|
315
|
+
"""Pair reconciled ownership with current authoritative replay payloads."""
|
|
316
|
+
|
|
317
|
+
if len(replay_atoms) != len(reconciled_session.atoms):
|
|
318
|
+
raise PublishError("Reconciled atom metadata no longer matches current replay payloads")
|
|
319
|
+
return tuple(
|
|
320
|
+
replace(replay_atom, atom=atom)
|
|
321
|
+
for replay_atom, atom in zip(replay_atoms, reconciled_session.atoms, strict=True)
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def publish_session(
|
|
326
|
+
repo: Path,
|
|
327
|
+
*,
|
|
328
|
+
canonical_branch: str,
|
|
329
|
+
backend: GitHubBackend,
|
|
330
|
+
remote: str = "origin",
|
|
331
|
+
) -> PublishResult:
|
|
332
|
+
"""Publish all active slices, or return action-needed without remote effects."""
|
|
333
|
+
|
|
334
|
+
repository = repo.resolve()
|
|
335
|
+
store = JsonSessionStore(repository)
|
|
336
|
+
with SessionLock(repository, canonical_branch):
|
|
337
|
+
session = store.load(canonical_branch)
|
|
338
|
+
if session.archived:
|
|
339
|
+
raise PublishError("This review session is archived and cannot be published")
|
|
340
|
+
session, replay_atoms, status = reconcile_and_status(repository, session)
|
|
341
|
+
store.save(session)
|
|
342
|
+
|
|
343
|
+
if status.unassigned_count or status.ambiguous_count:
|
|
344
|
+
return PublishResult(action_needed=True, status=status)
|
|
345
|
+
|
|
346
|
+
backend.check_ready()
|
|
347
|
+
open_prs = backend.list_open_prs()
|
|
348
|
+
existing_integration_pr = _find_integration_pr(backend, session, open_prs)
|
|
349
|
+
resolved_prs: dict[str, PRRecord] = {}
|
|
350
|
+
for slice_ in session.slices:
|
|
351
|
+
existing = _find_slice_pr(
|
|
352
|
+
backend,
|
|
353
|
+
slice_id=slice_.id,
|
|
354
|
+
stored_number=slice_.pr_number,
|
|
355
|
+
open_prs=open_prs,
|
|
356
|
+
)
|
|
357
|
+
if existing is not None:
|
|
358
|
+
resolved_prs[slice_.id] = existing
|
|
359
|
+
current_replay_atoms = _owned_replay_atoms(replay_atoms, session)
|
|
360
|
+
|
|
361
|
+
# Prepare every active projection needed by a current pull request
|
|
362
|
+
# before mutating any pull request. Brand-new empty slices need no
|
|
363
|
+
# refs, while an existing slice that became empty needs a zero-diff
|
|
364
|
+
# projection so its Files changed view cannot retain stale content.
|
|
365
|
+
prepared_slices: dict[str, _PreparedSlice] = {}
|
|
366
|
+
for slice_ in session.slices:
|
|
367
|
+
if slice_.status is not SliceStatus.ACTIVE:
|
|
368
|
+
continue
|
|
369
|
+
owns_atoms = any(atom.owner == slice_.id for atom in session.atoms)
|
|
370
|
+
if not owns_atoms and slice_.id not in resolved_prs:
|
|
371
|
+
continue
|
|
372
|
+
if session.last_final_oid is None:
|
|
373
|
+
raise PublishError("Reconciliation did not resolve a canonical final commit")
|
|
374
|
+
|
|
375
|
+
refs = generated_refs(branch_key(session.canonical_branch), slice_.id)
|
|
376
|
+
projection = build_projection(
|
|
377
|
+
repository,
|
|
378
|
+
base_oid=session.base_oid,
|
|
379
|
+
final_oid=session.last_final_oid,
|
|
380
|
+
slice_id=slice_.id,
|
|
381
|
+
replay_atoms=current_replay_atoms,
|
|
382
|
+
)
|
|
383
|
+
prepared_slices[slice_.id] = _PreparedSlice(
|
|
384
|
+
base_ref=_short_ref(refs.base),
|
|
385
|
+
head_ref=_short_ref(refs.head),
|
|
386
|
+
ref_sync=sync_projection_refs(
|
|
387
|
+
repository,
|
|
388
|
+
remote,
|
|
389
|
+
refs,
|
|
390
|
+
base_oid=projection.base_commit_oid,
|
|
391
|
+
head_oid=projection.head_commit_oid,
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
integration_pr = existing_integration_pr or _create_integration_pr(
|
|
396
|
+
backend, session, remote=remote
|
|
397
|
+
)
|
|
398
|
+
if integration_pr not in open_prs:
|
|
399
|
+
open_prs.append(integration_pr)
|
|
400
|
+
session = session.model_copy(update={"integration_pr": integration_pr.number})
|
|
401
|
+
|
|
402
|
+
created_slice_ids: set[str] = set()
|
|
403
|
+
updated_slices = list(session.slices)
|
|
404
|
+
branch = branch_key(session.canonical_branch)
|
|
405
|
+
|
|
406
|
+
# Removed slices keep their stable record for archaeology. Publishing
|
|
407
|
+
# closes the review PR before deleting its generated refs.
|
|
408
|
+
for index, slice_ in enumerate(session.slices):
|
|
409
|
+
if slice_.status is SliceStatus.ACTIVE:
|
|
410
|
+
continue
|
|
411
|
+
existing = resolved_prs.get(slice_.id)
|
|
412
|
+
if existing is not None:
|
|
413
|
+
closed = remove_slice_pr(backend, existing.number, slice_id=slice_.id)
|
|
414
|
+
updated_slices[index] = slice_.model_copy(update={"pr_number": closed.number})
|
|
415
|
+
delete_projection_refs(
|
|
416
|
+
repository,
|
|
417
|
+
remote,
|
|
418
|
+
generated_refs(branch, slice_.id),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Resolve every stable identity before creating anything. Missing
|
|
422
|
+
# mappings are recovered from markers; stale stored numbers fall back
|
|
423
|
+
# to that same marker result before a new PR is considered.
|
|
424
|
+
for slice_ in session.slices:
|
|
425
|
+
if slice_.status is not SliceStatus.ACTIVE:
|
|
426
|
+
continue
|
|
427
|
+
|
|
428
|
+
owns_atoms = any(atom.owner == slice_.id for atom in session.atoms)
|
|
429
|
+
if not owns_atoms or slice_.id in resolved_prs:
|
|
430
|
+
continue
|
|
431
|
+
|
|
432
|
+
prepared = prepared_slices.get(slice_.id)
|
|
433
|
+
if prepared is None:
|
|
434
|
+
raise PublishError(f"Missing prepared projection for non-empty slice {slice_.id!r}")
|
|
435
|
+
created = backend.create_draft_pr(
|
|
436
|
+
title=f"[{session.slice_pr_prefix}] {slice_.title}",
|
|
437
|
+
body=rewrite_slice_body(
|
|
438
|
+
"",
|
|
439
|
+
slice_id=slice_.id,
|
|
440
|
+
integration_pr_url=integration_pr.url,
|
|
441
|
+
diffstat=_slice_diffstat(session, slice_.id),
|
|
442
|
+
),
|
|
443
|
+
base_ref=prepared.base_ref,
|
|
444
|
+
head_ref=prepared.head_ref,
|
|
445
|
+
)
|
|
446
|
+
open_prs.append(created)
|
|
447
|
+
resolved_prs[slice_.id] = created
|
|
448
|
+
created_slice_ids.add(slice_.id)
|
|
449
|
+
|
|
450
|
+
prior_prs = dict(resolved_prs)
|
|
451
|
+
for slice_ in session.slices:
|
|
452
|
+
if slice_.status is not SliceStatus.ACTIVE:
|
|
453
|
+
continue
|
|
454
|
+
existing = resolved_prs.get(slice_.id)
|
|
455
|
+
if existing is None:
|
|
456
|
+
continue
|
|
457
|
+
refreshed = rename_slice_pr(
|
|
458
|
+
backend,
|
|
459
|
+
existing.number,
|
|
460
|
+
slice_id=slice_.id,
|
|
461
|
+
title=slice_.title,
|
|
462
|
+
prefix=session.slice_pr_prefix,
|
|
463
|
+
integration_pr_url=integration_pr.url,
|
|
464
|
+
diffstat=_slice_diffstat(session, slice_.id),
|
|
465
|
+
related_slices=_related_links(session, slice_id=slice_.id, prs=resolved_prs),
|
|
466
|
+
currently_empty=not any(atom.owner == slice_.id for atom in session.atoms),
|
|
467
|
+
)
|
|
468
|
+
resolved_prs[slice_.id] = refreshed
|
|
469
|
+
|
|
470
|
+
slice_results: list[PublishSliceResult] = []
|
|
471
|
+
index_rows: list[IntegrationSliceLink] = []
|
|
472
|
+
for index, slice_ in enumerate(session.slices):
|
|
473
|
+
pr = resolved_prs.get(slice_.id)
|
|
474
|
+
if slice_.status is not SliceStatus.ACTIVE:
|
|
475
|
+
slice_results.append(
|
|
476
|
+
PublishSliceResult(
|
|
477
|
+
slice_id=slice_.id,
|
|
478
|
+
title=slice_.title,
|
|
479
|
+
outcome=PublishOutcome.SKIPPED,
|
|
480
|
+
pr_number=updated_slices[index].pr_number,
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
if pr is not None:
|
|
486
|
+
updated_slices[index] = slice_.model_copy(update={"pr_number": pr.number})
|
|
487
|
+
index_rows.append(
|
|
488
|
+
IntegrationSliceLink(
|
|
489
|
+
slice_id=slice_.id,
|
|
490
|
+
title=slice_.title,
|
|
491
|
+
number=pr.number if pr is not None else None,
|
|
492
|
+
url=pr.url if pr is not None else None,
|
|
493
|
+
)
|
|
494
|
+
)
|
|
495
|
+
owns_atoms = any(atom.owner == slice_.id for atom in session.atoms)
|
|
496
|
+
if not owns_atoms:
|
|
497
|
+
outcome = PublishOutcome.EMPTY
|
|
498
|
+
elif slice_.id in created_slice_ids:
|
|
499
|
+
outcome = PublishOutcome.CREATED
|
|
500
|
+
elif not prepared_slices[slice_.id].ref_sync.is_no_op or prior_prs[slice_.id] != pr:
|
|
501
|
+
outcome = PublishOutcome.REFRESHED
|
|
502
|
+
else:
|
|
503
|
+
outcome = PublishOutcome.NO_OP
|
|
504
|
+
slice_results.append(
|
|
505
|
+
PublishSliceResult(
|
|
506
|
+
slice_id=slice_.id,
|
|
507
|
+
title=slice_.title,
|
|
508
|
+
outcome=outcome,
|
|
509
|
+
pr_number=pr.number if pr is not None else None,
|
|
510
|
+
url=pr.url if pr is not None else None,
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
desired_integration_body = rewrite_integration_body(
|
|
515
|
+
integration_pr.body,
|
|
516
|
+
slices=index_rows,
|
|
517
|
+
)
|
|
518
|
+
desired_integration_title = _integration_title(session)
|
|
519
|
+
if (
|
|
520
|
+
integration_pr.title != desired_integration_title
|
|
521
|
+
or integration_pr.body != desired_integration_body
|
|
522
|
+
):
|
|
523
|
+
integration_pr = backend.update_pr(
|
|
524
|
+
integration_pr.number,
|
|
525
|
+
title=desired_integration_title,
|
|
526
|
+
body=desired_integration_body,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
session = session.model_copy(
|
|
530
|
+
update={"slices": updated_slices, "integration_pr": integration_pr.number}
|
|
531
|
+
)
|
|
532
|
+
store.save(session)
|
|
533
|
+
defaulted_status = (
|
|
534
|
+
status_from_session(
|
|
535
|
+
session,
|
|
536
|
+
defaulted_atom_ids=tuple(status.defaulted_atom_ids),
|
|
537
|
+
)
|
|
538
|
+
if status.defaulted_atom_ids
|
|
539
|
+
else None
|
|
540
|
+
)
|
|
541
|
+
return PublishResult(
|
|
542
|
+
slices=slice_results,
|
|
543
|
+
integration_pr=integration_pr.number,
|
|
544
|
+
integration_pr_url=integration_pr.url,
|
|
545
|
+
action_needed=False,
|
|
546
|
+
status=defaulted_status,
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def archive_session(
|
|
551
|
+
repo: Path,
|
|
552
|
+
*,
|
|
553
|
+
canonical_branch: str,
|
|
554
|
+
backend: GitHubBackend,
|
|
555
|
+
remote: str = "origin",
|
|
556
|
+
) -> StatusResult:
|
|
557
|
+
"""Close slice PRs, delete their refs, and durably archive the session."""
|
|
558
|
+
|
|
559
|
+
repository = repo.resolve()
|
|
560
|
+
store = JsonSessionStore(repository)
|
|
561
|
+
with SessionLock(repository, canonical_branch):
|
|
562
|
+
session = store.load(canonical_branch)
|
|
563
|
+
session, _replay_atoms, _status = reconcile_and_status(repository, session)
|
|
564
|
+
backend.check_ready()
|
|
565
|
+
open_prs = backend.list_open_prs()
|
|
566
|
+
|
|
567
|
+
integration_pr: PRRecord | None = None
|
|
568
|
+
if session.integration_pr is not None:
|
|
569
|
+
try:
|
|
570
|
+
integration_pr = backend.get_pr(session.integration_pr)
|
|
571
|
+
except PullRequestNotFoundError:
|
|
572
|
+
integration_pr = None
|
|
573
|
+
if integration_pr is None:
|
|
574
|
+
matches = [pr for pr in open_prs if pr.head_ref == session.canonical_branch]
|
|
575
|
+
if len(matches) != 1:
|
|
576
|
+
raise PublishError(
|
|
577
|
+
"Archive requires one identifiable integration PR for the canonical branch"
|
|
578
|
+
)
|
|
579
|
+
integration_pr = matches[0]
|
|
580
|
+
if integration_pr.state is not PRState.MERGED:
|
|
581
|
+
raise PublishError(
|
|
582
|
+
f"Integration pull request #{integration_pr.number} must be merged before "
|
|
583
|
+
f"archiving; current state is {integration_pr.state.value!r}"
|
|
584
|
+
)
|
|
585
|
+
if session.last_final_oid is None:
|
|
586
|
+
raise PublishError("Reconciliation did not resolve a canonical final commit")
|
|
587
|
+
|
|
588
|
+
updated_slices = list(session.slices)
|
|
589
|
+
branch = branch_key(session.canonical_branch)
|
|
590
|
+
archive_prs: dict[int, PRRecord] = {}
|
|
591
|
+
for index, slice_ in enumerate(session.slices):
|
|
592
|
+
marker_matches = [pr for pr in open_prs if slice_marker(slice_.id) in pr.body]
|
|
593
|
+
if len(marker_matches) > 1:
|
|
594
|
+
numbers = ", ".join(f"#{pr.number}" for pr in marker_matches)
|
|
595
|
+
raise DuplicatePullRequestMarkerError(
|
|
596
|
+
f"Multiple open pull requests contain marker {slice_marker(slice_.id)!r}: "
|
|
597
|
+
f"{numbers}. Close or repair the duplicate before archiving."
|
|
598
|
+
)
|
|
599
|
+
slice_pr = marker_matches[0] if marker_matches else None
|
|
600
|
+
if slice_pr is None and slice_.pr_number is not None:
|
|
601
|
+
try:
|
|
602
|
+
slice_pr = backend.get_pr(slice_.pr_number)
|
|
603
|
+
except PullRequestNotFoundError:
|
|
604
|
+
slice_pr = None
|
|
605
|
+
if slice_pr is not None and slice_pr.state is PRState.MERGED:
|
|
606
|
+
raise MergedSlicePullRequestError(
|
|
607
|
+
f"Slice pull request #{slice_pr.number} is merged; generated review "
|
|
608
|
+
"projections must only be closed, never merged"
|
|
609
|
+
)
|
|
610
|
+
if slice_pr is not None:
|
|
611
|
+
archive_prs[index] = slice_pr
|
|
612
|
+
|
|
613
|
+
for index, slice_ in enumerate(session.slices):
|
|
614
|
+
slice_pr = archive_prs.get(index)
|
|
615
|
+
if slice_pr is not None and slice_.status is SliceStatus.ACTIVE:
|
|
616
|
+
archived_pr = archive_slice_pr(
|
|
617
|
+
backend,
|
|
618
|
+
slice_pr.number,
|
|
619
|
+
integration_pr_number=integration_pr.number,
|
|
620
|
+
integration_pr_url=integration_pr.url,
|
|
621
|
+
merged_commit=session.last_final_oid,
|
|
622
|
+
merged_commit_url=_commit_url(integration_pr.url, session.last_final_oid),
|
|
623
|
+
)
|
|
624
|
+
updated_slices[index] = slice_.model_copy(
|
|
625
|
+
update={"pr_number": archived_pr.number, "status": SliceStatus.ARCHIVED}
|
|
626
|
+
)
|
|
627
|
+
else:
|
|
628
|
+
updated_slices[index] = slice_.model_copy(update={"status": SliceStatus.ARCHIVED})
|
|
629
|
+
delete_projection_refs(
|
|
630
|
+
repository,
|
|
631
|
+
remote,
|
|
632
|
+
generated_refs(branch, slice_.id),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
session = session.model_copy(
|
|
636
|
+
update={
|
|
637
|
+
"slices": updated_slices,
|
|
638
|
+
"focus_slice": None,
|
|
639
|
+
"integration_pr": integration_pr.number,
|
|
640
|
+
"archived": True,
|
|
641
|
+
}
|
|
642
|
+
)
|
|
643
|
+
store.save(session)
|
|
644
|
+
return status_from_session(session)
|