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,339 @@
1
+ """``gh`` CLI implementation of the GitHub backend protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Final, Sequence
11
+
12
+ from git_paoding.core.model import PRRecord, PRState
13
+ from git_paoding.github.backend import GitHubBackendError, PullRequestNotFoundError
14
+
15
+ MINIMUM_GH_VERSION: Final = (2, 45, 0)
16
+ _OPEN_PR_LIST_LIMIT: Final = 1000
17
+ _PR_JSON_FIELDS: Final = "number,url,title,body,state,isDraft,baseRefName,headRefName"
18
+ _VERSION_PATTERN: Final = re.compile(r"\bgh version (\d+)\.(\d+)\.(\d+)\b")
19
+
20
+
21
+ class GhCliError(GitHubBackendError):
22
+ """Base class for actionable ``gh`` failures."""
23
+
24
+
25
+ class GhUnavailableError(GhCliError):
26
+ """Raised when the ``gh`` executable is absent."""
27
+
28
+
29
+ class GhAuthenticationError(GhCliError):
30
+ """Raised when ``gh`` has no usable authenticated account."""
31
+
32
+
33
+ class GhNetworkError(GhCliError):
34
+ """Raised when GitHub cannot be reached reliably."""
35
+
36
+
37
+ class GhNotFoundError(GhCliError, PullRequestNotFoundError):
38
+ """Raised when a requested GitHub repository or pull request is absent."""
39
+
40
+
41
+ class GhRateLimitError(GhCliError):
42
+ """Raised when GitHub refuses a call because a rate limit was reached."""
43
+
44
+
45
+ class GhVersionError(GhCliError):
46
+ """Raised when the installed ``gh`` version is unsupported or unreadable."""
47
+
48
+
49
+ class GhCommandError(GhCliError):
50
+ """A non-zero ``gh`` command result."""
51
+
52
+ def __init__(self, *, args: tuple[str, ...], returncode: int, stderr: str) -> None:
53
+ self.args_list = args
54
+ self.returncode = returncode
55
+ self.stderr = stderr
56
+ detail = stderr.strip() or "gh exited without an error message"
57
+ super().__init__(f"gh {' '.join(args)} failed: {detail}")
58
+
59
+
60
+ class GhResponseError(GhCliError):
61
+ """Raised when successful ``gh`` output violates the expected JSON shape."""
62
+
63
+
64
+ def _version_text(version: tuple[int, int, int]) -> str:
65
+ return ".".join(str(part) for part in version)
66
+
67
+
68
+ def _mapped_command_error(
69
+ *,
70
+ args: tuple[str, ...],
71
+ returncode: int,
72
+ stderr: str,
73
+ ) -> GhCliError:
74
+ """Map stable ``gh``/HTTP diagnostics to actionable backend errors."""
75
+
76
+ normalized = stderr.casefold()
77
+ detail = stderr.strip() or "gh exited without an error message"
78
+ if any(
79
+ marker in normalized
80
+ for marker in (
81
+ "rate limit exceeded",
82
+ "secondary rate limit",
83
+ "http 429",
84
+ "status code 429",
85
+ "too many requests",
86
+ )
87
+ ):
88
+ return GhRateLimitError(
89
+ f"GitHub rate limit reached while running `gh {' '.join(args)}`: {detail}. "
90
+ "Wait for the limit to reset and try again."
91
+ )
92
+ if any(
93
+ marker in normalized
94
+ for marker in (
95
+ "could not resolve host",
96
+ "connection refused",
97
+ "connection reset",
98
+ "error connecting to",
99
+ "failed to connect",
100
+ "connection timed out",
101
+ "network is unreachable",
102
+ "network error",
103
+ "temporary failure in name resolution",
104
+ "tls handshake timeout",
105
+ "i/o timeout",
106
+ "dial tcp",
107
+ )
108
+ ):
109
+ return GhNetworkError(
110
+ f"Could not reach GitHub while running `gh {' '.join(args)}`: {detail}. "
111
+ "Check the network connection and try again."
112
+ )
113
+ if any(
114
+ marker in normalized
115
+ for marker in (
116
+ "not logged into",
117
+ "authentication required",
118
+ "authentication failed",
119
+ "authentication",
120
+ "http 401",
121
+ "bad credentials",
122
+ "oauth token",
123
+ )
124
+ ):
125
+ return GhAuthenticationError(
126
+ "GitHub CLI is not authenticated. Run `gh auth login` and try again."
127
+ )
128
+ if any(
129
+ marker in normalized
130
+ for marker in (
131
+ "http 404",
132
+ "status code 404",
133
+ "could not resolve to a pullrequest",
134
+ "could not resolve to a repository",
135
+ "no pull requests found",
136
+ "repository not found",
137
+ )
138
+ ):
139
+ return GhNotFoundError(
140
+ f"GitHub resource was not found while running `gh {' '.join(args)}`: {detail}"
141
+ )
142
+ return GhCommandError(args=args, returncode=returncode, stderr=stderr)
143
+
144
+
145
+ class GhCliBackend:
146
+ """GitHub PR operations implemented by commands in one repository."""
147
+
148
+ def __init__(self, cwd: Path, *, executable: str = "gh") -> None:
149
+ self.cwd = cwd
150
+ self.executable = executable
151
+
152
+ def _run(self, args: Sequence[str]) -> str:
153
+ command_args = tuple(args)
154
+ process_env = os.environ.copy()
155
+ process_env["LC_ALL"] = "C"
156
+ try:
157
+ completed = subprocess.run(
158
+ (self.executable, *command_args),
159
+ cwd=self.cwd,
160
+ env=process_env,
161
+ text=True,
162
+ stdout=subprocess.PIPE,
163
+ stderr=subprocess.PIPE,
164
+ check=False,
165
+ )
166
+ except FileNotFoundError as error:
167
+ raise GhUnavailableError(
168
+ "GitHub CLI (`gh`) was not found on PATH. Install it from "
169
+ "https://cli.github.com/ and then run `gh auth login`."
170
+ ) from error
171
+
172
+ if completed.returncode != 0:
173
+ raise _mapped_command_error(
174
+ args=command_args,
175
+ returncode=completed.returncode,
176
+ stderr=completed.stderr,
177
+ )
178
+ return completed.stdout
179
+
180
+ def check_ready(self) -> None:
181
+ """Check executable presence, minimum version, and authentication."""
182
+
183
+ output = self._run(("--version",))
184
+ match = _VERSION_PATTERN.search(output)
185
+ if match is None:
186
+ raise GhVersionError(
187
+ "Could not determine the installed GitHub CLI version from `gh --version`. "
188
+ "Upgrade gh from https://cli.github.com/."
189
+ )
190
+ installed = (int(match.group(1)), int(match.group(2)), int(match.group(3)))
191
+ if installed < MINIMUM_GH_VERSION:
192
+ raise GhVersionError(
193
+ f"GitHub CLI {_version_text(installed)} is too old; git-paoding requires "
194
+ f"gh >= {_version_text(MINIMUM_GH_VERSION)}. Upgrade gh from "
195
+ "https://cli.github.com/."
196
+ )
197
+
198
+ try:
199
+ self._run(("auth", "status"))
200
+ except GhCommandError as error:
201
+ raise GhAuthenticationError(
202
+ "GitHub CLI is not authenticated. Run `gh auth login` and try again."
203
+ ) from error
204
+
205
+ def create_draft_pr(
206
+ self,
207
+ *,
208
+ title: str,
209
+ body: str,
210
+ base_ref: str,
211
+ head_ref: str,
212
+ ) -> PRRecord:
213
+ """Create a Draft PR, then read it back through structured JSON."""
214
+
215
+ url = self._run(
216
+ (
217
+ "pr",
218
+ "create",
219
+ "--draft",
220
+ "--base",
221
+ base_ref,
222
+ "--head",
223
+ head_ref,
224
+ "--title",
225
+ title,
226
+ "--body",
227
+ body,
228
+ )
229
+ ).strip()
230
+ if not url:
231
+ raise GhResponseError("`gh pr create` succeeded but returned no pull-request URL")
232
+ return self._view_pr(url)
233
+
234
+ def update_pr(self, number: int, *, title: str, body: str) -> PRRecord:
235
+ """Replace title/body and return the refreshed record."""
236
+
237
+ self._run(("pr", "edit", str(number), "--title", title, "--body", body))
238
+ return self.get_pr(number)
239
+
240
+ def close_pr(self, number: int) -> PRRecord:
241
+ """Close a PR while retaining its discussion and URL."""
242
+
243
+ self._run(("pr", "close", str(number)))
244
+ return self.get_pr(number)
245
+
246
+ def get_pr(self, number: int) -> PRRecord:
247
+ """Read one PR through ``gh pr view --json``."""
248
+
249
+ return self._view_pr(str(number))
250
+
251
+ def _view_pr(self, selector: str) -> PRRecord:
252
+ output = self._run(("pr", "view", selector, "--json", _PR_JSON_FIELDS))
253
+ payload = self._load_json(output, context="gh pr view")
254
+ if not isinstance(payload, dict):
255
+ raise GhResponseError("`gh pr view --json` returned a non-object payload")
256
+ return _parse_pr(payload)
257
+
258
+ def list_open_prs(self) -> list[PRRecord]:
259
+ """List all open PRs needed for exact body-marker recovery.
260
+
261
+ Raises ``GhResponseError`` when the listing fills the request limit,
262
+ because a truncated listing could miss an existing marker and let a
263
+ publish create a duplicate slice PR.
264
+ """
265
+
266
+ output = self._run(
267
+ (
268
+ "pr",
269
+ "list",
270
+ "--state",
271
+ "open",
272
+ "--limit",
273
+ str(_OPEN_PR_LIST_LIMIT),
274
+ "--json",
275
+ _PR_JSON_FIELDS,
276
+ )
277
+ )
278
+ payload = self._load_json(output, context="gh pr list")
279
+ if not isinstance(payload, list):
280
+ raise GhResponseError("`gh pr list --json` returned a non-array payload")
281
+ if len(payload) >= _OPEN_PR_LIST_LIMIT:
282
+ raise GhResponseError(
283
+ f"This repository has {_OPEN_PR_LIST_LIMIT} or more open pull requests; "
284
+ "marker search over a truncated listing is unsafe. Close stale PRs first."
285
+ )
286
+ return [_parse_pr(item) for item in payload]
287
+
288
+ @staticmethod
289
+ def _load_json(output: str, *, context: str) -> object:
290
+ try:
291
+ return json.loads(output)
292
+ except json.JSONDecodeError as error:
293
+ raise GhResponseError(
294
+ f"`{context} --json` returned invalid JSON: {error.msg}"
295
+ ) from error
296
+
297
+
298
+ def _required_str(payload: dict[str, object], key: str) -> str:
299
+ value = payload.get(key)
300
+ if not isinstance(value, str):
301
+ raise GhResponseError(f"GitHub PR JSON field {key!r} has an invalid or missing value")
302
+ return value
303
+
304
+
305
+ def _required_int(payload: dict[str, object], key: str) -> int:
306
+ value = payload.get(key)
307
+ if not isinstance(value, int) or isinstance(value, bool):
308
+ raise GhResponseError(f"GitHub PR JSON field {key!r} has an invalid or missing value")
309
+ return value
310
+
311
+
312
+ def _required_bool(payload: dict[str, object], key: str) -> bool:
313
+ value = payload.get(key)
314
+ if not isinstance(value, bool):
315
+ raise GhResponseError(f"GitHub PR JSON field {key!r} has an invalid or missing value")
316
+ return value
317
+
318
+
319
+ def _parse_pr(value: object) -> PRRecord:
320
+ if not isinstance(value, dict) or not all(isinstance(key, str) for key in value):
321
+ raise GhResponseError("GitHub PR JSON entry must be an object with string keys")
322
+ payload = value
323
+ number = _required_int(payload, "number")
324
+ is_draft = _required_bool(payload, "isDraft")
325
+ state_text = _required_str(payload, "state")
326
+ try:
327
+ state = PRState(str(state_text).casefold())
328
+ except ValueError as error:
329
+ raise GhResponseError(f"Unknown GitHub PR state: {state_text!r}") from error
330
+ return PRRecord(
331
+ number=number,
332
+ url=_required_str(payload, "url"),
333
+ title=_required_str(payload, "title"),
334
+ body=_required_str(payload, "body"),
335
+ state=state,
336
+ is_draft=is_draft,
337
+ base_ref=_required_str(payload, "baseRefName"),
338
+ head_ref=_required_str(payload, "headRefName"),
339
+ )
@@ -0,0 +1,123 @@
1
+ """Backend-neutral pull-request lifecycle operations for review slices."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from git_paoding.core.model import DiffStat, PaodingError, PRRecord, PRState, SliceId
8
+ from git_paoding.github.backend import GitHubBackend
9
+ from git_paoding.github.prbody import (
10
+ RelatedSliceLink,
11
+ rewrite_archived_slice_body,
12
+ rewrite_removed_slice_body,
13
+ rewrite_slice_body,
14
+ )
15
+
16
+
17
+ class MergedSlicePullRequestError(PaodingError):
18
+ """Raised when a generated slice pull request was merged accidentally."""
19
+
20
+
21
+ def _reject_merged_slice(current: PRRecord) -> None:
22
+ if current.state is PRState.MERGED:
23
+ raise MergedSlicePullRequestError(
24
+ f"Slice pull request #{current.number} is merged; generated review projections "
25
+ "must only be closed, never merged"
26
+ )
27
+
28
+
29
+ def _update_if_changed(
30
+ backend: GitHubBackend,
31
+ current: PRRecord,
32
+ *,
33
+ title: str,
34
+ body: str,
35
+ ) -> PRRecord:
36
+ if current.title == title and current.body == body:
37
+ return current
38
+ return backend.update_pr(current.number, title=title, body=body)
39
+
40
+
41
+ def rename_slice_pr(
42
+ backend: GitHubBackend,
43
+ number: int,
44
+ *,
45
+ slice_id: SliceId | str,
46
+ title: str,
47
+ prefix: str = "slice",
48
+ integration_pr_url: str,
49
+ diffstat: DiffStat,
50
+ related_slices: Sequence[RelatedSliceLink] = (),
51
+ currently_empty: bool = False,
52
+ ) -> PRRecord:
53
+ """Rename and refresh a slice in place, preserving its PR identity."""
54
+
55
+ current = backend.get_pr(number)
56
+ _reject_merged_slice(current)
57
+ desired_body = rewrite_slice_body(
58
+ current.body,
59
+ slice_id=slice_id,
60
+ integration_pr_url=integration_pr_url,
61
+ diffstat=diffstat,
62
+ related_slices=related_slices,
63
+ currently_empty=currently_empty,
64
+ )
65
+ return _update_if_changed(
66
+ backend,
67
+ current,
68
+ title=f"[{prefix}] {title}",
69
+ body=desired_body,
70
+ )
71
+
72
+
73
+ def remove_slice_pr(
74
+ backend: GitHubBackend,
75
+ number: int,
76
+ *,
77
+ slice_id: SliceId | str,
78
+ ) -> PRRecord:
79
+ """Close a removed slice after preserving its narrative and adding a note."""
80
+
81
+ current = backend.get_pr(number)
82
+ _reject_merged_slice(current)
83
+ desired_body = rewrite_removed_slice_body(current.body, slice_id=slice_id)
84
+ current = _update_if_changed(
85
+ backend,
86
+ current,
87
+ title=current.title,
88
+ body=desired_body,
89
+ )
90
+ if current.state is PRState.CLOSED:
91
+ return current
92
+ return backend.close_pr(current.number)
93
+
94
+
95
+ def archive_slice_pr(
96
+ backend: GitHubBackend,
97
+ number: int,
98
+ *,
99
+ integration_pr_number: int,
100
+ integration_pr_url: str,
101
+ merged_commit: str,
102
+ merged_commit_url: str,
103
+ ) -> PRRecord:
104
+ """Close one projection with a durable pointer to merged integration state."""
105
+
106
+ current = backend.get_pr(number)
107
+ _reject_merged_slice(current)
108
+ desired_body = rewrite_archived_slice_body(
109
+ current.body,
110
+ integration_pr_number=integration_pr_number,
111
+ integration_pr_url=integration_pr_url,
112
+ merged_commit=merged_commit,
113
+ merged_commit_url=merged_commit_url,
114
+ )
115
+ current = _update_if_changed(
116
+ backend,
117
+ current,
118
+ title=current.title,
119
+ body=desired_body,
120
+ )
121
+ if current.state is PRState.CLOSED:
122
+ return current
123
+ return backend.close_pr(current.number)