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.
@@ -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)