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/api.py ADDED
@@ -0,0 +1,383 @@
1
+ """Public library facade: one typed function per CLI verb.
2
+
3
+ The facade owns repository/session discovery and backend injection. Frontends
4
+ receive only typed Pydantic result objects and never depend on CLI internals.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Sequence
10
+ from pathlib import Path
11
+
12
+ from git_paoding.core.model import (
13
+ AssignBatchRequest,
14
+ AssignResult,
15
+ AtomState,
16
+ PaodingError,
17
+ PublishResult,
18
+ Session,
19
+ SessionAlreadyExistsError,
20
+ Slice,
21
+ SliceStatus,
22
+ StatusResult,
23
+ )
24
+ from git_paoding.core.publish import (
25
+ archive_session,
26
+ publish_session,
27
+ reconcile_and_status,
28
+ status_from_session,
29
+ )
30
+ from git_paoding.core.selectors import assign_batch_selectors, assign_selectors
31
+ from git_paoding.github.backend import GitHubBackend
32
+ from git_paoding.gitio.plumbing import rev_parse
33
+ from git_paoding.gitio.runner import GitCommandError, run_git
34
+ from git_paoding.store.jsonstore import JsonSessionStore
35
+ from git_paoding.store.lock import SessionLock
36
+
37
+
38
+ class BranchResolutionError(PaodingError):
39
+ """Raised when no canonical branch can be selected safely."""
40
+
41
+
42
+ class SliceAlreadyExistsError(PaodingError):
43
+ """Raised when adding a duplicate stable slice id."""
44
+
45
+
46
+ class SliceNotFoundError(PaodingError):
47
+ """Raised when an operation names an unknown active slice."""
48
+
49
+
50
+ class SessionArchivedError(PaodingError):
51
+ """Raised when a local mutation targets a completed review session."""
52
+
53
+
54
+ def _require_open_session(session: Session) -> None:
55
+ if session.archived:
56
+ raise SessionArchivedError("This review session is archived")
57
+
58
+
59
+ def _canonical_branch(repo: Path, requested: str | None) -> str:
60
+ if requested is not None:
61
+ if not requested:
62
+ raise BranchResolutionError("canonical_branch must not be empty")
63
+ return requested.removeprefix("refs/heads/")
64
+ try:
65
+ branch = run_git(("symbolic-ref", "--quiet", "--short", "HEAD"), cwd=repo).stdout_text()
66
+ except GitCommandError as error:
67
+ raise BranchResolutionError(
68
+ "Could not infer a canonical branch from detached HEAD; pass canonical_branch "
69
+ "to the library facade or check out the intended integration branch"
70
+ ) from error
71
+ branch = branch.strip()
72
+ if not branch:
73
+ raise BranchResolutionError("Git returned an empty canonical branch name")
74
+ return branch
75
+
76
+
77
+ def init_session(
78
+ repo: Path,
79
+ base: str,
80
+ *,
81
+ backend: GitHubBackend,
82
+ canonical_branch: str | None = None,
83
+ slice_pr_prefix: str = "slice",
84
+ ) -> StatusResult:
85
+ """Pin ``base`` and initialize a session for the canonical branch."""
86
+
87
+ repository = repo.resolve()
88
+ branch = _canonical_branch(repository, canonical_branch)
89
+ store = JsonSessionStore(repository)
90
+ with SessionLock(repository, branch):
91
+ if store.exists(branch):
92
+ raise SessionAlreadyExistsError(
93
+ f"A git-paoding session already exists for branch {branch!r}"
94
+ )
95
+ backend.check_ready()
96
+ base_oid = rev_parse(repository, f"{base}^{{commit}}")
97
+ # Verify the canonical branch ref independently of the current checkout.
98
+ rev_parse(repository, f"refs/heads/{branch}^{{commit}}")
99
+ session = Session(
100
+ canonical_branch=branch,
101
+ base_ref=base,
102
+ base_oid=base_oid,
103
+ slice_pr_prefix=slice_pr_prefix,
104
+ )
105
+ session, _replay_atoms, status = reconcile_and_status(repository, session)
106
+ store.save(session)
107
+ return status
108
+
109
+
110
+ def add_slice(
111
+ repo: Path,
112
+ slice_id: str,
113
+ title: str,
114
+ *,
115
+ canonical_branch: str | None = None,
116
+ ) -> StatusResult:
117
+ """Add one active stable slice identity and return refreshed status."""
118
+
119
+ repository = repo.resolve()
120
+ branch = _canonical_branch(repository, canonical_branch)
121
+ new_slice = Slice(id=slice_id, title=title)
122
+ store = JsonSessionStore(repository)
123
+ with SessionLock(repository, branch):
124
+ session = store.load(branch)
125
+ _require_open_session(session)
126
+ if any(slice_.id == slice_id for slice_ in session.slices):
127
+ raise SliceAlreadyExistsError(f"Slice {slice_id!r} already exists")
128
+ session = session.model_copy(update={"slices": [*session.slices, new_slice]})
129
+ session, _replay_atoms, status = reconcile_and_status(repository, session)
130
+ store.save(session)
131
+ return status
132
+
133
+
134
+ def get_status(
135
+ repo: Path,
136
+ *,
137
+ canonical_branch: str | None = None,
138
+ ) -> StatusResult:
139
+ """Reconcile against the live canonical tip and report status.
140
+
141
+ Fully read-only: no refs, no GitHub calls, and no session writes.
142
+ Reconciliation is deterministic, so a following ``assign`` re-derives the
143
+ same atom ids without any persisted cache.
144
+ """
145
+
146
+ repository = repo.resolve()
147
+ branch = _canonical_branch(repository, canonical_branch)
148
+ store = JsonSessionStore(repository)
149
+ session = store.load(branch)
150
+ _session, _replay_atoms, status = reconcile_and_status(repository, session)
151
+ return status
152
+
153
+
154
+ def get_full_status(
155
+ repo: Path,
156
+ *,
157
+ canonical_branch: str | None = None,
158
+ ) -> StatusResult:
159
+ """Return read-only status with complete current Git hunk payloads."""
160
+
161
+ repository = repo.resolve()
162
+ branch = _canonical_branch(repository, canonical_branch)
163
+ session = JsonSessionStore(repository).load(branch)
164
+ _session, _replay_atoms, status = reconcile_and_status(repository, session, full=True)
165
+ return status
166
+
167
+
168
+ def assign(
169
+ repo: Path,
170
+ slice_id: str,
171
+ selectors: Sequence[str],
172
+ *,
173
+ canonical_branch: str | None = None,
174
+ force: bool = False,
175
+ ) -> AssignResult:
176
+ """Resolve selectors against the current diff and persist their slice ownership."""
177
+
178
+ repository = repo.resolve()
179
+ branch = _canonical_branch(repository, canonical_branch)
180
+ store = JsonSessionStore(repository)
181
+ with SessionLock(repository, branch):
182
+ session = store.load(branch)
183
+ _require_open_session(session)
184
+ target = next((slice_ for slice_ in session.slices if slice_.id == slice_id), None)
185
+ if target is None or target.status is not SliceStatus.ACTIVE:
186
+ raise SliceNotFoundError(f"No active slice exists with id {slice_id!r}")
187
+ session, _replay_atoms, _status = reconcile_and_status(repository, session)
188
+ atoms, result = assign_selectors(
189
+ session.atoms,
190
+ slice_id=slice_id,
191
+ selectors=selectors,
192
+ force=force,
193
+ )
194
+ session = session.model_copy(update={"atoms": list(atoms)})
195
+ store.save(session)
196
+ return result
197
+
198
+
199
+ def assign_batch(
200
+ repo: Path,
201
+ request: AssignBatchRequest,
202
+ *,
203
+ canonical_branch: str | None = None,
204
+ ) -> AssignResult:
205
+ """Validate and apply one complete batch under one lock and one save."""
206
+
207
+ repository = repo.resolve()
208
+ branch = _canonical_branch(repository, canonical_branch)
209
+ store = JsonSessionStore(repository)
210
+ with SessionLock(repository, branch):
211
+ session = store.load(branch)
212
+ _require_open_session(session)
213
+ session, _replay_atoms, _status = reconcile_and_status(repository, session)
214
+ active_ids = {slice_.id for slice_ in session.slices if slice_.status is SliceStatus.ACTIVE}
215
+ atoms, result = assign_batch_selectors(
216
+ session.atoms,
217
+ assignments=request.assignments,
218
+ active_slice_ids=active_ids,
219
+ force=request.force,
220
+ )
221
+ store.save(session.model_copy(update={"atoms": list(atoms)}))
222
+ return result
223
+
224
+
225
+ def remove_slice(
226
+ repo: Path,
227
+ slice_id: str,
228
+ *,
229
+ canonical_branch: str | None = None,
230
+ ) -> StatusResult:
231
+ """Remove a slice locally and return its atoms to unassigned state."""
232
+
233
+ repository = repo.resolve()
234
+ branch = _canonical_branch(repository, canonical_branch)
235
+ store = JsonSessionStore(repository)
236
+ with SessionLock(repository, branch):
237
+ session = store.load(branch)
238
+ _require_open_session(session)
239
+ session, _replay_atoms, reconciled_status = reconcile_and_status(repository, session)
240
+ target_index = next(
241
+ (
242
+ index
243
+ for index, slice_ in enumerate(session.slices)
244
+ if slice_.id == slice_id and slice_.status is SliceStatus.ACTIVE
245
+ ),
246
+ None,
247
+ )
248
+ if target_index is None:
249
+ raise SliceNotFoundError(f"No active slice exists with id {slice_id!r}")
250
+ slices = list(session.slices)
251
+ slices[target_index] = slices[target_index].model_copy(
252
+ update={"status": SliceStatus.ARCHIVED}
253
+ )
254
+ atoms = [
255
+ atom.model_copy(update={"owner": None, "state": AtomState.UNASSIGNED})
256
+ if atom.owner == slice_id
257
+ else atom
258
+ for atom in session.atoms
259
+ ]
260
+ session = session.model_copy(
261
+ update={
262
+ "slices": slices,
263
+ "atoms": atoms,
264
+ "focus_slice": None if session.focus_slice == slice_id else session.focus_slice,
265
+ }
266
+ )
267
+ store.save(session)
268
+ return status_from_session(
269
+ session,
270
+ defaulted_atom_ids=tuple(reconciled_status.defaulted_atom_ids),
271
+ )
272
+
273
+
274
+ def rename_slice(
275
+ repo: Path,
276
+ slice_id: str,
277
+ title: str,
278
+ *,
279
+ canonical_branch: str | None = None,
280
+ ) -> StatusResult:
281
+ """Rename an active slice without changing its stable identity or PR mapping."""
282
+
283
+ repository = repo.resolve()
284
+ branch = _canonical_branch(repository, canonical_branch)
285
+ store = JsonSessionStore(repository)
286
+ with SessionLock(repository, branch):
287
+ session = store.load(branch)
288
+ _require_open_session(session)
289
+ session, _replay_atoms, reconciled_status = reconcile_and_status(repository, session)
290
+ slices = list(session.slices)
291
+ for index, slice_ in enumerate(slices):
292
+ if slice_.id == slice_id and slice_.status is SliceStatus.ACTIVE:
293
+ slices[index] = slice_.model_copy(update={"title": title})
294
+ break
295
+ else:
296
+ raise SliceNotFoundError(f"No active slice exists with id {slice_id!r}")
297
+ session = session.model_copy(update={"slices": slices})
298
+ store.save(session)
299
+ return status_from_session(
300
+ session,
301
+ defaulted_atom_ids=tuple(reconciled_status.defaulted_atom_ids),
302
+ )
303
+
304
+
305
+ def set_focus(
306
+ repo: Path,
307
+ slice_id: str | None,
308
+ *,
309
+ canonical_branch: str | None = None,
310
+ ) -> StatusResult:
311
+ """Set or clear the session-global prior for genuinely new atoms."""
312
+
313
+ repository = repo.resolve()
314
+ branch = _canonical_branch(repository, canonical_branch)
315
+ store = JsonSessionStore(repository)
316
+ with SessionLock(repository, branch):
317
+ session = store.load(branch)
318
+ _require_open_session(session)
319
+ session, _replay_atoms, reconciled_status = reconcile_and_status(repository, session)
320
+ if slice_id is not None and not any(
321
+ slice_.id == slice_id and slice_.status is SliceStatus.ACTIVE
322
+ for slice_ in session.slices
323
+ ):
324
+ raise SliceNotFoundError(f"No active slice exists with id {slice_id!r}")
325
+ session = session.model_copy(update={"focus_slice": slice_id})
326
+ store.save(session)
327
+ return status_from_session(
328
+ session,
329
+ defaulted_atom_ids=tuple(reconciled_status.defaulted_atom_ids),
330
+ )
331
+
332
+
333
+ def publish(
334
+ repo: Path,
335
+ *,
336
+ backend: GitHubBackend,
337
+ canonical_branch: str | None = None,
338
+ remote: str = "origin",
339
+ ) -> PublishResult:
340
+ """Run the idempotent projection/ref/PR publication pipeline."""
341
+
342
+ repository = repo.resolve()
343
+ branch = _canonical_branch(repository, canonical_branch)
344
+ return publish_session(
345
+ repository,
346
+ canonical_branch=branch,
347
+ backend=backend,
348
+ remote=remote,
349
+ )
350
+
351
+
352
+ def archive(
353
+ repo: Path,
354
+ *,
355
+ backend: GitHubBackend,
356
+ canonical_branch: str | None = None,
357
+ remote: str = "origin",
358
+ ) -> StatusResult:
359
+ """Archive every slice PR and generated ref after integration."""
360
+
361
+ repository = repo.resolve()
362
+ branch = _canonical_branch(repository, canonical_branch)
363
+ return archive_session(
364
+ repository,
365
+ canonical_branch=branch,
366
+ backend=backend,
367
+ remote=remote,
368
+ )
369
+
370
+
371
+ __all__ = [
372
+ "add_slice",
373
+ "archive",
374
+ "assign",
375
+ "assign_batch",
376
+ "get_full_status",
377
+ "get_status",
378
+ "init_session",
379
+ "publish",
380
+ "remove_slice",
381
+ "rename_slice",
382
+ "set_focus",
383
+ ]
@@ -0,0 +1 @@
1
+ """Command-line interface package."""
@@ -0,0 +1,133 @@
1
+ """Typed, replaceable seam between the Click shell and the public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
+ from typing import Protocol
8
+
9
+ from git_paoding import api
10
+ from git_paoding.core.model import (
11
+ AssignBatchRequest,
12
+ AssignResult,
13
+ PublishResult,
14
+ StatusResult,
15
+ )
16
+ from git_paoding.github.backend import GitHubBackend
17
+
18
+
19
+ class CliFacade(Protocol):
20
+ """Facade calls consumed by the complete CLI surface."""
21
+
22
+ def init_session(
23
+ self,
24
+ repo: Path,
25
+ base: str,
26
+ *,
27
+ backend: GitHubBackend,
28
+ slice_pr_prefix: str = "slice",
29
+ ) -> StatusResult: ...
30
+
31
+ def add_slice(self, repo: Path, slice_id: str, title: str) -> StatusResult: ...
32
+
33
+ def list_slices(self, repo: Path) -> StatusResult: ...
34
+
35
+ def remove_slice(self, repo: Path, slice_id: str) -> StatusResult: ...
36
+
37
+ def rename_slice(self, repo: Path, slice_id: str, title: str) -> StatusResult: ...
38
+
39
+ def get_status(self, repo: Path, *, full: bool) -> StatusResult: ...
40
+
41
+ def assign(
42
+ self,
43
+ repo: Path,
44
+ slice_id: str,
45
+ selectors: Sequence[str],
46
+ *,
47
+ force: bool,
48
+ ) -> AssignResult: ...
49
+
50
+ def assign_batch(self, repo: Path, request: AssignBatchRequest) -> AssignResult: ...
51
+
52
+ def set_focus(self, repo: Path, slice_id: str | None) -> StatusResult: ...
53
+
54
+ def publish(
55
+ self,
56
+ repo: Path,
57
+ *,
58
+ backend: GitHubBackend,
59
+ remote: str,
60
+ ) -> PublishResult: ...
61
+
62
+ def archive(
63
+ self,
64
+ repo: Path,
65
+ *,
66
+ backend: GitHubBackend,
67
+ remote: str,
68
+ ) -> StatusResult: ...
69
+
70
+
71
+ class ApiFacade:
72
+ """Default adapter over :mod:`git_paoding.api`."""
73
+
74
+ def init_session(
75
+ self,
76
+ repo: Path,
77
+ base: str,
78
+ *,
79
+ backend: GitHubBackend,
80
+ slice_pr_prefix: str = "slice",
81
+ ) -> StatusResult:
82
+ return api.init_session(repo, base, backend=backend, slice_pr_prefix=slice_pr_prefix)
83
+
84
+ def add_slice(self, repo: Path, slice_id: str, title: str) -> StatusResult:
85
+ return api.add_slice(repo, slice_id, title)
86
+
87
+ def list_slices(self, repo: Path) -> StatusResult:
88
+ return api.get_status(repo)
89
+
90
+ def remove_slice(self, repo: Path, slice_id: str) -> StatusResult:
91
+ return api.remove_slice(repo, slice_id)
92
+
93
+ def rename_slice(self, repo: Path, slice_id: str, title: str) -> StatusResult:
94
+ return api.rename_slice(repo, slice_id, title)
95
+
96
+ def get_status(self, repo: Path, *, full: bool) -> StatusResult:
97
+ if full:
98
+ return api.get_full_status(repo)
99
+ return api.get_status(repo)
100
+
101
+ def assign(
102
+ self,
103
+ repo: Path,
104
+ slice_id: str,
105
+ selectors: Sequence[str],
106
+ *,
107
+ force: bool,
108
+ ) -> AssignResult:
109
+ return api.assign(repo, slice_id, selectors, force=force)
110
+
111
+ def assign_batch(self, repo: Path, request: AssignBatchRequest) -> AssignResult:
112
+ return api.assign_batch(repo, request)
113
+
114
+ def set_focus(self, repo: Path, slice_id: str | None) -> StatusResult:
115
+ return api.set_focus(repo, slice_id)
116
+
117
+ def publish(
118
+ self,
119
+ repo: Path,
120
+ *,
121
+ backend: GitHubBackend,
122
+ remote: str,
123
+ ) -> PublishResult:
124
+ return api.publish(repo, backend=backend, remote=remote)
125
+
126
+ def archive(
127
+ self,
128
+ repo: Path,
129
+ *,
130
+ backend: GitHubBackend,
131
+ remote: str,
132
+ ) -> StatusResult:
133
+ return api.archive(repo, backend=backend, remote=remote)