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,220 @@
|
|
|
1
|
+
"""Pure, deterministic attribution reconciliation for Base-anchored atoms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from collections.abc import Iterable, Sequence
|
|
7
|
+
|
|
8
|
+
from git_paoding.core.model import Atom, AtomKind, AtomState
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReconcileResult(tuple[Atom, ...]):
|
|
12
|
+
"""Reconciled atoms plus the ids that received the optional focus prior.
|
|
13
|
+
|
|
14
|
+
The result deliberately remains a tuple subtype so existing consumers of
|
|
15
|
+
``reconcile`` keep their frozen sequence behavior. The additional report
|
|
16
|
+
metadata is internal and does not change a persistent or JSON model.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
defaulted_atom_ids: tuple[str, ...]
|
|
20
|
+
|
|
21
|
+
def __new__(
|
|
22
|
+
cls,
|
|
23
|
+
atoms: Iterable[Atom] = (),
|
|
24
|
+
*,
|
|
25
|
+
defaulted_atom_ids: Iterable[str] = (),
|
|
26
|
+
) -> ReconcileResult:
|
|
27
|
+
result = super().__new__(cls, atoms)
|
|
28
|
+
result.defaulted_atom_ids = tuple(defaulted_atom_ids)
|
|
29
|
+
return result
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _base_range_key(atom: Atom) -> tuple[str, int, int, int]:
|
|
33
|
+
"""Return the exact, non-fuzzy identity of an atom's Base range."""
|
|
34
|
+
|
|
35
|
+
return (atom.path, atom.base_start, atom.base_len, atom.gap_seq)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _is_insertion(atom: Atom) -> bool:
|
|
39
|
+
"""Return whether an atom is anchored at a text-file Base gap."""
|
|
40
|
+
|
|
41
|
+
return atom.base_len == 0 and atom.kind is not AtomKind.WHOLE_FILE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ranges_overlap(left: Atom, right: Atom) -> bool:
|
|
45
|
+
"""Return whether two positive-width Base ranges overlap."""
|
|
46
|
+
|
|
47
|
+
if left.path != right.path or left.base_len == 0 or right.base_len == 0:
|
|
48
|
+
return False
|
|
49
|
+
left_end = left.base_start + left.base_len
|
|
50
|
+
right_end = right.base_start + right.base_len
|
|
51
|
+
return left.base_start < right_end and right.base_start < left_end
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _with_owner(new_atom: Atom, old_atom: Atom, *, exact: bool) -> Atom:
|
|
55
|
+
"""Carry one confident owner to a new atom and choose its visible state."""
|
|
56
|
+
|
|
57
|
+
state = (
|
|
58
|
+
AtomState.ASSIGNED
|
|
59
|
+
if exact and old_atom.content_hash == new_atom.content_hash
|
|
60
|
+
else AtomState.UPDATED
|
|
61
|
+
)
|
|
62
|
+
return new_atom.model_copy(update={"owner": old_atom.owner, "state": state})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _without_owner(new_atom: Atom, state: AtomState) -> Atom:
|
|
66
|
+
return new_atom.model_copy(update={"owner": None, "state": state})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _with_focus(new_atom: Atom, focus_slice: str | None) -> tuple[Atom, bool]:
|
|
70
|
+
if focus_slice is None:
|
|
71
|
+
return _without_owner(new_atom, AtomState.UNASSIGNED), False
|
|
72
|
+
return (
|
|
73
|
+
new_atom.model_copy(update={"owner": focus_slice, "state": AtomState.ASSIGNED}),
|
|
74
|
+
True,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _match_insertions(
|
|
79
|
+
old_atoms: Sequence[Atom],
|
|
80
|
+
new_atoms: Sequence[Atom],
|
|
81
|
+
) -> dict[int, Atom | None]:
|
|
82
|
+
"""Match same-gap insertions one-to-one, preferring unchanged content.
|
|
83
|
+
|
|
84
|
+
Content matches are allocated for the complete gap before positional
|
|
85
|
+
``gap_seq`` matches. This preserves attribution when another insertion at
|
|
86
|
+
the same gap is added, removed, or reordered. Duplicate old candidates
|
|
87
|
+
for the same content/sequence are treated as ambiguous instead of guessed.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
matches: dict[int, Atom | None] = {}
|
|
91
|
+
old_by_gap: dict[tuple[str, int], list[Atom]] = defaultdict(list)
|
|
92
|
+
new_by_gap: dict[tuple[str, int], list[tuple[int, Atom]]] = defaultdict(list)
|
|
93
|
+
for old_atom in old_atoms:
|
|
94
|
+
if _is_insertion(old_atom):
|
|
95
|
+
old_by_gap[(old_atom.path, old_atom.base_start)].append(old_atom)
|
|
96
|
+
for index, new_atom in enumerate(new_atoms):
|
|
97
|
+
if _is_insertion(new_atom):
|
|
98
|
+
new_by_gap[(new_atom.path, new_atom.base_start)].append((index, new_atom))
|
|
99
|
+
|
|
100
|
+
for gap, new_group in new_by_gap.items():
|
|
101
|
+
old_group = sorted(
|
|
102
|
+
old_by_gap.get(gap, ()),
|
|
103
|
+
key=lambda atom: (atom.gap_seq, atom.atom_id),
|
|
104
|
+
)
|
|
105
|
+
unused = set(range(len(old_group)))
|
|
106
|
+
|
|
107
|
+
# First retain byte-identical insertions even if their sequence changed.
|
|
108
|
+
for new_index, new_atom in new_group:
|
|
109
|
+
content_candidates = [
|
|
110
|
+
candidate_index
|
|
111
|
+
for candidate_index in unused
|
|
112
|
+
if old_group[candidate_index].content_hash == new_atom.content_hash
|
|
113
|
+
]
|
|
114
|
+
if not content_candidates:
|
|
115
|
+
continue
|
|
116
|
+
exact_sequence = [
|
|
117
|
+
candidate_index
|
|
118
|
+
for candidate_index in content_candidates
|
|
119
|
+
if old_group[candidate_index].gap_seq == new_atom.gap_seq
|
|
120
|
+
]
|
|
121
|
+
candidates = exact_sequence or content_candidates
|
|
122
|
+
if len(candidates) != 1:
|
|
123
|
+
matches[new_index] = None
|
|
124
|
+
continue
|
|
125
|
+
candidate_index = candidates[0]
|
|
126
|
+
unused.remove(candidate_index)
|
|
127
|
+
matches[new_index] = old_group[candidate_index]
|
|
128
|
+
|
|
129
|
+
# Only then use the stable positional identity for changed content.
|
|
130
|
+
for new_index, new_atom in new_group:
|
|
131
|
+
if new_index in matches:
|
|
132
|
+
continue
|
|
133
|
+
sequence_candidates = [
|
|
134
|
+
candidate_index
|
|
135
|
+
for candidate_index in unused
|
|
136
|
+
if old_group[candidate_index].gap_seq == new_atom.gap_seq
|
|
137
|
+
]
|
|
138
|
+
if len(sequence_candidates) != 1:
|
|
139
|
+
if len(sequence_candidates) > 1:
|
|
140
|
+
matches[new_index] = None
|
|
141
|
+
continue
|
|
142
|
+
candidate_index = sequence_candidates[0]
|
|
143
|
+
unused.remove(candidate_index)
|
|
144
|
+
matches[new_index] = old_group[candidate_index]
|
|
145
|
+
|
|
146
|
+
return matches
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def reconcile(
|
|
150
|
+
old_atoms: Sequence[Atom],
|
|
151
|
+
new_atoms: Sequence[Atom],
|
|
152
|
+
*,
|
|
153
|
+
focus_slice: str | None = None,
|
|
154
|
+
) -> ReconcileResult:
|
|
155
|
+
"""Reconcile current atoms with prior ownership without fuzzy matching.
|
|
156
|
+
|
|
157
|
+
Exact Base ranges retain their owner, while a positive-width range that
|
|
158
|
+
overlaps exactly one prior owned range inherits that owner as ``updated``.
|
|
159
|
+
Multiple overlaps are ambiguous. New atoms remain unassigned unless a
|
|
160
|
+
focus prior is supplied; ids assigned by that prior are exposed on the
|
|
161
|
+
returned tuple's ``defaulted_atom_ids`` report field.
|
|
162
|
+
|
|
163
|
+
Previously stored atoms absent from ``new_atoms`` naturally disappear.
|
|
164
|
+
The function reads no repository or session state and mutates no input.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
owned_atoms = tuple(atom for atom in old_atoms if atom.owner is not None)
|
|
168
|
+
insertion_matches = _match_insertions(old_atoms, new_atoms)
|
|
169
|
+
defaulted_atom_ids: list[str] = []
|
|
170
|
+
reconciled: list[Atom] = []
|
|
171
|
+
|
|
172
|
+
for index, new_atom in enumerate(new_atoms):
|
|
173
|
+
if _is_insertion(new_atom):
|
|
174
|
+
if index in insertion_matches:
|
|
175
|
+
matched_atom = insertion_matches[index]
|
|
176
|
+
if matched_atom is None:
|
|
177
|
+
reconciled.append(_without_owner(new_atom, AtomState.AMBIGUOUS))
|
|
178
|
+
elif matched_atom.owner is None:
|
|
179
|
+
atom, defaulted = _with_focus(new_atom, focus_slice)
|
|
180
|
+
reconciled.append(atom)
|
|
181
|
+
if defaulted:
|
|
182
|
+
defaulted_atom_ids.append(new_atom.atom_id)
|
|
183
|
+
else:
|
|
184
|
+
reconciled.append(_with_owner(new_atom, matched_atom, exact=True))
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
atom, defaulted = _with_focus(new_atom, focus_slice)
|
|
188
|
+
reconciled.append(atom)
|
|
189
|
+
if defaulted:
|
|
190
|
+
defaulted_atom_ids.append(new_atom.atom_id)
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
exact_matches = [
|
|
194
|
+
old_atom
|
|
195
|
+
for old_atom in owned_atoms
|
|
196
|
+
if not _is_insertion(old_atom)
|
|
197
|
+
and _base_range_key(old_atom) == _base_range_key(new_atom)
|
|
198
|
+
]
|
|
199
|
+
overlap_matches = [
|
|
200
|
+
old_atom
|
|
201
|
+
for old_atom in owned_atoms
|
|
202
|
+
if not _is_insertion(old_atom) and _ranges_overlap(old_atom, new_atom)
|
|
203
|
+
]
|
|
204
|
+
candidates = exact_matches + [
|
|
205
|
+
old_atom for old_atom in overlap_matches if old_atom not in exact_matches
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
if len(candidates) == 1:
|
|
209
|
+
reconciled.append(
|
|
210
|
+
_with_owner(new_atom, candidates[0], exact=candidates[0] in exact_matches)
|
|
211
|
+
)
|
|
212
|
+
elif len(candidates) > 1:
|
|
213
|
+
reconciled.append(_without_owner(new_atom, AtomState.AMBIGUOUS))
|
|
214
|
+
else:
|
|
215
|
+
atom, defaulted = _with_focus(new_atom, focus_slice)
|
|
216
|
+
reconciled.append(atom)
|
|
217
|
+
if defaulted:
|
|
218
|
+
defaulted_atom_ids.append(new_atom.atom_id)
|
|
219
|
+
|
|
220
|
+
return ReconcileResult(reconciled, defaulted_atom_ids=defaulted_atom_ids)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Resolve author-facing selectors into atomic attribution updates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Collection, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from difflib import get_close_matches
|
|
9
|
+
from pathlib import PurePosixPath
|
|
10
|
+
|
|
11
|
+
from git_paoding.core.model import (
|
|
12
|
+
AssignmentRecord,
|
|
13
|
+
AssignResult,
|
|
14
|
+
Atom,
|
|
15
|
+
AtomState,
|
|
16
|
+
PaodingError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_ATOM_ID_RE = re.compile(r"^[0-9a-f]{8}(?:-[1-9][0-9]*)?$")
|
|
20
|
+
_RANGE_RE = re.compile(r"^(?P<path>.+):(?P<start>[1-9][0-9]*)-(?P<end>[1-9][0-9]*)$")
|
|
21
|
+
_GLOB_CHARACTERS = frozenset("*?[")
|
|
22
|
+
_HINT_LIMIT = 3
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SelectorError(PaodingError):
|
|
26
|
+
"""Base class for selector validation and resolution failures."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class SelectorNotFoundError(SelectorError):
|
|
30
|
+
"""An input could not be resolved to any atom in the current diff."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SelectorConflictError(SelectorError):
|
|
34
|
+
"""Raised when one batch assigns the same atom to different slices."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UnknownBatchSliceError(SelectorError):
|
|
38
|
+
"""Raised when a batch names a slice outside the active slice set."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class _ResolvedSelector:
|
|
43
|
+
indexes: tuple[int, ...]
|
|
44
|
+
explicit_atom_id: bool
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _display_final_range(atom: Atom) -> str:
|
|
48
|
+
if atom.final_len == 0:
|
|
49
|
+
return f"gap@{atom.final_start}"
|
|
50
|
+
return f"{atom.final_start}-{atom.final_start + atom.final_len - 1}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _repository_path(value: str) -> str:
|
|
54
|
+
return value[2:] if value.startswith("./") else value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _nearby_path_hints(atoms: Sequence[Atom], selector: str) -> str:
|
|
58
|
+
paths = list(dict.fromkeys(atom.path for atom in atoms))
|
|
59
|
+
normalized = selector.rstrip("/")
|
|
60
|
+
parent = normalized.rpartition("/")[0]
|
|
61
|
+
candidates = [path for path in paths if parent and path.startswith(f"{parent}/")]
|
|
62
|
+
if not candidates:
|
|
63
|
+
candidates = get_close_matches(normalized, paths, n=_HINT_LIMIT, cutoff=0.25)
|
|
64
|
+
if not candidates:
|
|
65
|
+
candidates = paths[:_HINT_LIMIT]
|
|
66
|
+
if not candidates:
|
|
67
|
+
return " The current diff contains no atoms."
|
|
68
|
+
return f" Nearby paths: {', '.join(repr(path) for path in candidates[:_HINT_LIMIT])}."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _nearby_range_hints(atoms: Sequence[Atom], *, path: str, start: int, end: int) -> str:
|
|
72
|
+
same_file = [atom for atom in atoms if atom.path == path]
|
|
73
|
+
if not same_file:
|
|
74
|
+
return _nearby_path_hints(atoms, path)
|
|
75
|
+
|
|
76
|
+
def distance(atom: Atom) -> int:
|
|
77
|
+
if atom.final_len == 0:
|
|
78
|
+
return min(abs(atom.final_start - start), abs(atom.final_start - end))
|
|
79
|
+
atom_start = atom.final_start
|
|
80
|
+
atom_end = atom.final_start + atom.final_len - 1
|
|
81
|
+
return min(abs(atom_start - end), abs(start - atom_end))
|
|
82
|
+
|
|
83
|
+
nearby = sorted(same_file, key=lambda atom: (distance(atom), atom.final_start, atom.atom_id))
|
|
84
|
+
rendered = ", ".join(
|
|
85
|
+
f"{atom.atom_id} final:{_display_final_range(atom)}" for atom in nearby[:_HINT_LIMIT]
|
|
86
|
+
)
|
|
87
|
+
return f" Nearby atoms in {path!r}: {rendered}."
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _resolve_selector(atoms: Sequence[Atom], selector: str) -> _ResolvedSelector:
|
|
91
|
+
if not selector:
|
|
92
|
+
raise SelectorError("Selectors must not be empty")
|
|
93
|
+
|
|
94
|
+
by_id = {atom.atom_id: index for index, atom in enumerate(atoms)}
|
|
95
|
+
if selector in by_id:
|
|
96
|
+
return _ResolvedSelector((by_id[selector],), explicit_atom_id=True)
|
|
97
|
+
|
|
98
|
+
if _ATOM_ID_RE.fullmatch(selector):
|
|
99
|
+
current_ids = ", ".join(atom.atom_id for atom in atoms[:_HINT_LIMIT]) or "(none)"
|
|
100
|
+
raise SelectorNotFoundError(
|
|
101
|
+
f"Atom id {selector!r} is stale or unknown; run `git-paoding status` again. "
|
|
102
|
+
f"Current atom ids include: {current_ids}."
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
normalized_selector = _repository_path(selector)
|
|
106
|
+
exact_path = tuple(
|
|
107
|
+
index for index, atom in enumerate(atoms) if atom.path == normalized_selector
|
|
108
|
+
)
|
|
109
|
+
if exact_path:
|
|
110
|
+
return _ResolvedSelector(exact_path, explicit_atom_id=False)
|
|
111
|
+
|
|
112
|
+
range_match = _RANGE_RE.fullmatch(selector)
|
|
113
|
+
if range_match is not None:
|
|
114
|
+
path = _repository_path(range_match.group("path"))
|
|
115
|
+
start = int(range_match.group("start"))
|
|
116
|
+
end = int(range_match.group("end"))
|
|
117
|
+
if end < start:
|
|
118
|
+
raise SelectorError(
|
|
119
|
+
f"Invalid Final line range {selector!r}: end must be greater than or equal to start"
|
|
120
|
+
)
|
|
121
|
+
indexes = tuple(
|
|
122
|
+
index
|
|
123
|
+
for index, atom in enumerate(atoms)
|
|
124
|
+
if atom.path == path
|
|
125
|
+
and atom.final_len > 0
|
|
126
|
+
and atom.final_start <= end
|
|
127
|
+
and atom.final_start + atom.final_len - 1 >= start
|
|
128
|
+
)
|
|
129
|
+
if not indexes:
|
|
130
|
+
raise SelectorNotFoundError(
|
|
131
|
+
f"Final-coordinate range {selector!r} matched no atom."
|
|
132
|
+
+ _nearby_range_hints(atoms, path=path, start=start, end=end)
|
|
133
|
+
)
|
|
134
|
+
return _ResolvedSelector(indexes, explicit_atom_id=False)
|
|
135
|
+
|
|
136
|
+
normalized_directory = _repository_path(selector.removesuffix("/"))
|
|
137
|
+
directory_matches = tuple(
|
|
138
|
+
index
|
|
139
|
+
for index, atom in enumerate(atoms)
|
|
140
|
+
if normalized_directory in {"", "."} or atom.path.startswith(f"{normalized_directory}/")
|
|
141
|
+
)
|
|
142
|
+
if directory_matches:
|
|
143
|
+
return _ResolvedSelector(directory_matches, explicit_atom_id=False)
|
|
144
|
+
|
|
145
|
+
if any(character in selector for character in _GLOB_CHARACTERS):
|
|
146
|
+
normalized_glob = _repository_path(selector)
|
|
147
|
+
glob_matches = tuple(
|
|
148
|
+
index
|
|
149
|
+
for index, atom in enumerate(atoms)
|
|
150
|
+
if PurePosixPath(atom.path).match(normalized_glob)
|
|
151
|
+
)
|
|
152
|
+
if glob_matches:
|
|
153
|
+
return _ResolvedSelector(glob_matches, explicit_atom_id=False)
|
|
154
|
+
|
|
155
|
+
raise SelectorNotFoundError(
|
|
156
|
+
f"Selector {selector!r} matched no atom in the current diff by id, path, directory, "
|
|
157
|
+
"glob, or line range." + _nearby_path_hints(atoms, selector)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_selectors(
|
|
162
|
+
atoms: Sequence[Atom], selectors: Sequence[str]
|
|
163
|
+
) -> tuple[tuple[int, bool], ...]:
|
|
164
|
+
if not selectors:
|
|
165
|
+
raise SelectorError(
|
|
166
|
+
"At least one atom id, path, directory, glob, or line-range selector is required"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
selected: dict[int, bool] = {}
|
|
170
|
+
for selector in selectors:
|
|
171
|
+
resolved = _resolve_selector(atoms, selector)
|
|
172
|
+
for index in resolved.indexes:
|
|
173
|
+
selected[index] = selected.get(index, False) or resolved.explicit_atom_id
|
|
174
|
+
return tuple(selected.items())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _assignment_record(atom: Atom, *, owner: str, previous_owner: str | None) -> AssignmentRecord:
|
|
178
|
+
return AssignmentRecord(
|
|
179
|
+
atom_id=atom.atom_id,
|
|
180
|
+
path=atom.path,
|
|
181
|
+
previous_owner=previous_owner,
|
|
182
|
+
owner=owner,
|
|
183
|
+
preview=atom.preview,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _apply_plan(
|
|
188
|
+
atoms: Sequence[Atom],
|
|
189
|
+
plan: Sequence[tuple[int, str, bool]],
|
|
190
|
+
*,
|
|
191
|
+
force: bool,
|
|
192
|
+
) -> tuple[tuple[Atom, ...], AssignResult]:
|
|
193
|
+
updated_atoms = list(atoms)
|
|
194
|
+
assigned: list[AssignmentRecord] = []
|
|
195
|
+
skipped: list[AssignmentRecord] = []
|
|
196
|
+
|
|
197
|
+
for index, slice_id, explicit_atom_id in plan:
|
|
198
|
+
atom = updated_atoms[index]
|
|
199
|
+
previous_owner = atom.owner
|
|
200
|
+
if previous_owner == slice_id:
|
|
201
|
+
skipped.append(_assignment_record(atom, owner=slice_id, previous_owner=previous_owner))
|
|
202
|
+
continue
|
|
203
|
+
if previous_owner is not None and not (force or explicit_atom_id):
|
|
204
|
+
skipped.append(
|
|
205
|
+
_assignment_record(atom, owner=previous_owner, previous_owner=previous_owner)
|
|
206
|
+
)
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
updated_atoms[index] = atom.model_copy(
|
|
210
|
+
update={"owner": slice_id, "state": AtomState.ASSIGNED}
|
|
211
|
+
)
|
|
212
|
+
assigned.append(_assignment_record(atom, owner=slice_id, previous_owner=previous_owner))
|
|
213
|
+
|
|
214
|
+
return tuple(updated_atoms), AssignResult(assigned=assigned, skipped=skipped)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def assign_selectors(
|
|
218
|
+
atoms: Sequence[Atom],
|
|
219
|
+
*,
|
|
220
|
+
slice_id: str,
|
|
221
|
+
selectors: Sequence[str],
|
|
222
|
+
force: bool = False,
|
|
223
|
+
) -> tuple[tuple[Atom, ...], AssignResult]:
|
|
224
|
+
"""Resolve selectors and assign their atoms to one slice atomically.
|
|
225
|
+
|
|
226
|
+
Exact atom ids may take ownership without ``force``. Broader selectors
|
|
227
|
+
preserve already-owned atoms unless ``force`` is true. Every selector is
|
|
228
|
+
resolved before any updated atom tuple is produced.
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
resolved = _resolve_selectors(atoms, selectors)
|
|
232
|
+
plan = tuple((index, slice_id, explicit_atom_id) for index, explicit_atom_id in resolved)
|
|
233
|
+
return _apply_plan(atoms, plan, force=force)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def assign_batch_selectors(
|
|
237
|
+
atoms: Sequence[Atom],
|
|
238
|
+
*,
|
|
239
|
+
assignments: Mapping[str, Sequence[str]],
|
|
240
|
+
active_slice_ids: Collection[str],
|
|
241
|
+
force: bool = False,
|
|
242
|
+
) -> tuple[tuple[Atom, ...], AssignResult]:
|
|
243
|
+
"""Validate and resolve an entire multi-slice assignment plan before applying it.
|
|
244
|
+
|
|
245
|
+
A batch that names an unknown slice, contains an invalid selector, or assigns
|
|
246
|
+
one atom to different slices fails without returning any mutated atoms.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
if not assignments:
|
|
250
|
+
raise SelectorError("Batch assignments must contain at least one slice")
|
|
251
|
+
|
|
252
|
+
active = set(active_slice_ids)
|
|
253
|
+
unknown = [slice_id for slice_id in assignments if slice_id not in active]
|
|
254
|
+
if unknown:
|
|
255
|
+
rendered = ", ".join(repr(slice_id) for slice_id in unknown)
|
|
256
|
+
raise UnknownBatchSliceError(f"Batch names unknown or inactive slices: {rendered}")
|
|
257
|
+
|
|
258
|
+
plan_by_index: dict[int, tuple[str, bool]] = {}
|
|
259
|
+
ordered_indexes: list[int] = []
|
|
260
|
+
for slice_id, selectors in assignments.items():
|
|
261
|
+
resolved = _resolve_selectors(atoms, selectors)
|
|
262
|
+
for index, explicit_atom_id in resolved:
|
|
263
|
+
prior = plan_by_index.get(index)
|
|
264
|
+
if prior is not None and prior[0] != slice_id:
|
|
265
|
+
atom = atoms[index]
|
|
266
|
+
raise SelectorConflictError(
|
|
267
|
+
f"Batch assigns atom {atom.atom_id!r} ({atom.path}) to both "
|
|
268
|
+
f"{prior[0]!r} and {slice_id!r}"
|
|
269
|
+
)
|
|
270
|
+
if prior is None:
|
|
271
|
+
ordered_indexes.append(index)
|
|
272
|
+
plan_by_index[index] = (slice_id, explicit_atom_id)
|
|
273
|
+
else:
|
|
274
|
+
plan_by_index[index] = (slice_id, prior[1] or explicit_atom_id)
|
|
275
|
+
|
|
276
|
+
plan = tuple(
|
|
277
|
+
(index, plan_by_index[index][0], plan_by_index[index][1]) for index in ordered_indexes
|
|
278
|
+
)
|
|
279
|
+
return _apply_plan(atoms, plan, force=force)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""GitHub integration package."""
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Backend-neutral GitHub pull-request operations.
|
|
2
|
+
|
|
3
|
+
The protocol is intentionally small: core publishing code should know about
|
|
4
|
+
pull requests, but never about ``gh`` arguments or JSON response shapes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Protocol, runtime_checkable
|
|
10
|
+
|
|
11
|
+
from git_paoding.core.model import PaodingError, PRRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class GitHubBackendError(PaodingError):
|
|
15
|
+
"""Base class for failures at the GitHub backend boundary."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DuplicatePullRequestMarkerError(GitHubBackendError):
|
|
19
|
+
"""Raised when one stable slice marker identifies multiple open PRs."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PullRequestNotFoundError(GitHubBackendError):
|
|
23
|
+
"""Raised when a requested pull-request identity no longer exists."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@runtime_checkable
|
|
27
|
+
class GitHubBackend(Protocol):
|
|
28
|
+
"""Thin interface consumed by publish and archive orchestration."""
|
|
29
|
+
|
|
30
|
+
def check_ready(self) -> None:
|
|
31
|
+
"""Verify that the backend is installed, supported, and authenticated."""
|
|
32
|
+
|
|
33
|
+
def create_draft_pr(
|
|
34
|
+
self,
|
|
35
|
+
*,
|
|
36
|
+
title: str,
|
|
37
|
+
body: str,
|
|
38
|
+
base_ref: str,
|
|
39
|
+
head_ref: str,
|
|
40
|
+
) -> PRRecord:
|
|
41
|
+
"""Create a draft pull request and return its backend-neutral record."""
|
|
42
|
+
|
|
43
|
+
def update_pr(self, number: int, *, title: str, body: str) -> PRRecord:
|
|
44
|
+
"""Replace the title and body of an existing pull request."""
|
|
45
|
+
|
|
46
|
+
def close_pr(self, number: int) -> PRRecord:
|
|
47
|
+
"""Close an existing pull request without deleting its history."""
|
|
48
|
+
|
|
49
|
+
def get_pr(self, number: int) -> PRRecord:
|
|
50
|
+
"""Return one pull request by number."""
|
|
51
|
+
|
|
52
|
+
def list_open_prs(self) -> list[PRRecord]:
|
|
53
|
+
"""Return open pull requests with body text available for marker search."""
|