github-rest-api 0.42.0__tar.gz → 0.43.0__tar.gz

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.
Files changed (48) hide show
  1. {github_rest_api-0.42.0/github_rest_api/scripts/github → github_rest_api-0.43.0/.github}/workflows/create_pr_to_main.yaml +1 -1
  2. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/lint.yaml +2 -0
  3. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/remove_branch.yaml +1 -1
  4. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/PKG-INFO +3 -1
  5. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/github.py +118 -5
  6. github_rest_api-0.43.0/github_rest_api/pr_content.py +227 -0
  7. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/benchmark.py +8 -6
  8. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/profiling.py +7 -5
  9. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/build_container_images.py +7 -6
  10. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/config_container.py +3 -2
  11. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/update_version_containerfile.py +4 -2
  12. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/add_github_repo.py +2 -1
  13. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/create_pull_request.py +38 -8
  14. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/release_on_github.py +3 -2
  15. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/remove_branch.py +2 -1
  16. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/utils.py +4 -3
  17. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/utils.py +2 -2
  18. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/pyproject.toml +8 -1
  19. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/test_build_container_images.py +1 -0
  20. github_rest_api-0.43.0/tests/test_create_pull_request.py +55 -0
  21. github_rest_api-0.43.0/tests/test_github.py +238 -0
  22. github_rest_api-0.43.0/tests/test_pr_content.py +212 -0
  23. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/test_release_on_github.py +2 -0
  24. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/test_utils.py +1 -0
  25. github_rest_api-0.43.0/uv.lock +2055 -0
  26. github_rest_api-0.42.0/tests/test_github.py +0 -32
  27. github_rest_api-0.42.0/uv.lock +0 -652
  28. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.devcontainer/devcontainer.json +0 -0
  29. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/release.yaml +0 -0
  30. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/test.yaml +0 -0
  31. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.gitignore +0 -0
  32. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/GEMINI.md +0 -0
  33. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/README.md +0 -0
  34. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/__init__.py +0 -0
  35. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/__init__.py +0 -0
  36. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/__init__.py +0 -0
  37. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/utils.py +0 -0
  38. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/__init__.py +0 -0
  39. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/__init__.py +0 -0
  40. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/create_pr_dev_to_main.yaml +0 -0
  41. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/create_pr_to_dev.yaml +0 -0
  42. {github_rest_api-0.42.0/.github → github_rest_api-0.43.0/github_rest_api/scripts/github}/workflows/create_pr_to_main.yaml +0 -0
  43. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/python/lint.yaml +0 -0
  44. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/python/test.yaml +0 -0
  45. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/remove_branch.yaml +0 -0
  46. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/memory/MEMORY.md +0 -0
  47. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/memory/feedback_test_runner.md +0 -0
  48. {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/__init__.py +0 -0
@@ -16,5 +16,5 @@ jobs:
16
16
  uvx --from github-rest-api@latest create_pull_request \
17
17
  --head-branch ${{ github.ref_name }} \
18
18
  --base-branch main \
19
- --token ${{ secrets.GITHUBACTIONS }}
19
+ --token ${{ secrets.ACTIONS_TOKEN }}
20
20
 
@@ -14,6 +14,8 @@ jobs:
14
14
  run: |
15
15
  curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
16
16
  uv sync --all-extras
17
+ - name: Check pyproject.toml formatting
18
+ run: uv run pyproject-fmt --check pyproject.toml
17
19
  - name: Check code format
18
20
  run: uv run ruff format --check ./
19
21
  - name: Lint with ruff
@@ -15,4 +15,4 @@ jobs:
15
15
  uvx --from github-rest-api@latest remove_branch \
16
16
  --repo ${{ github.repository }} \
17
17
  --pattern '^(?!main$)' \
18
- --token ${{ secrets.GITHUBACTIONS }}
18
+ --token ${{ secrets.ACTIONS_TOKEN }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: github-rest-api
3
- Version: 0.42.0
3
+ Version: 0.43.0
4
4
  Summary: Simple wrapper of GitHub REST APIs.
5
5
  Author-email: Ben Du <longendu@yahoo.com>
6
6
  Classifier: Programming Language :: Python :: 3 :: Only
@@ -15,6 +15,8 @@ Requires-Dist: pyyaml>=6
15
15
  Requires-Dist: requests>=2.28.2
16
16
  Requires-Dist: tenacity>=9.1.4
17
17
  Requires-Dist: tomli-w>=1
18
+ Provides-Extra: ai
19
+ Requires-Dist: litellm>=1.50; extra == 'ai'
18
20
  Description-Content-Type: text/markdown
19
21
 
20
22
  # GitHub REST APIs | [@GitHub](https://github.com/legendu-net/github_rest_api) | [@PyPI](https://pypi.org/project/github-rest-api/)
@@ -1,16 +1,56 @@
1
1
  """Simple wrapper of GitHub REST APIs."""
2
2
 
3
+ import logging
4
+ import re
3
5
  from abc import ABCMeta, abstractmethod
4
6
  from base64 import b64encode
5
7
  from collections.abc import Sequence
6
8
  from enum import StrEnum
7
- from typing import Any, Callable
8
9
  from pathlib import Path
10
+ from typing import Any, Callable
11
+ from urllib.parse import quote
12
+
9
13
  import requests
10
14
  from nacl import encoding, public
11
15
 
16
+ from github_rest_api.pr_content import (
17
+ deterministic_body,
18
+ deterministic_title,
19
+ generate_pr_content,
20
+ )
21
+
22
+ logger = logging.getLogger(__name__)
23
+
12
24
  URL_API = "https://api.github.com"
13
25
 
26
+ # Default LiteLLM 'provider/model' used to generate PR titles and descriptions.
27
+ DEFAULT_PR_MODEL = "anthropic/claude-haiku-4-5-20251001"
28
+
29
+
30
+ def _validate_secret_name(name: str) -> None:
31
+ """Validate a secret name against GitHub's naming rules.
32
+
33
+ GitHub rejects invalid secret names with a 422 response. Validating the
34
+ name client-side surfaces a clear error before the request is sent.
35
+
36
+ :param name: The name of the secret.
37
+ :raises ValueError: If the name is empty, starts with the reserved
38
+ ``GITHUB_`` prefix, starts with a digit, or contains characters other
39
+ than alphanumerics and underscores.
40
+ """
41
+ if not name:
42
+ raise ValueError("A secret name must not be empty.")
43
+ if name.upper().startswith("GITHUB_"):
44
+ raise ValueError(
45
+ f"Invalid secret name {name!r}: names must not start with the "
46
+ "reserved 'GITHUB_' prefix."
47
+ )
48
+ if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
49
+ raise ValueError(
50
+ f"Invalid secret name {name!r}: names may only contain alphanumeric "
51
+ "characters and underscores, and must not start with a digit."
52
+ )
53
+
14
54
 
15
55
  def _encrypt_secret(public_key: str, value: str) -> str:
16
56
  """Encrypt a secret value using a LibSodium sealed box.
@@ -182,6 +222,7 @@ class Repository(GitHub):
182
222
  self._url_issues = f"{self._url_repo}/issues"
183
223
  self._url_releases = f"{self._url_repo}/releases"
184
224
  self._url_secrets = f"{self._url_repo}/actions/secrets"
225
+ self._url_compare = f"{self._url_repo}/compare"
185
226
 
186
227
  def get_releases(self, n: int = 0) -> list[dict[str, Any]]:
187
228
  """List releases in this repository."""
@@ -241,21 +282,73 @@ class Repository(GitHub):
241
282
  """List pull requests in this repository."""
242
283
  return self._extract_all(url=self._url_pull, n=n)
243
284
 
244
- def create_pull_request(self, json: dict[str, str]) -> dict[str, Any] | None:
285
+ def _generate_pull_request_content(
286
+ self, base: str, head: str, model: str
287
+ ) -> tuple[str, str] | None:
288
+ """Generate a `(title, body)` for a new PR from the head/base comparison.
289
+
290
+ Returns None when there is nothing to describe (an empty comparison), so
291
+ the caller keeps the provided title.
292
+
293
+ :param base: The base branch the PR merges into.
294
+ :param head: The head branch containing the changes.
295
+ :param model: The LiteLLM 'provider/model' string for LLM generation.
296
+ When empty, the title and body are generated deterministically from
297
+ the commit messages and changed files; otherwise an LLM is used,
298
+ falling back to deterministic generation on failure.
299
+ """
300
+ compare = self.compare(base=base, head=head)
301
+ if not compare.get("commits"):
302
+ return None
303
+ if model:
304
+ try:
305
+ return generate_pr_content(compare, model=model)
306
+ except Exception as error:
307
+ logger.warning(
308
+ "LLM PR generation failed (%s); "
309
+ "falling back to deterministic content.",
310
+ error,
311
+ exc_info=True,
312
+ )
313
+ return deterministic_title(compare), deterministic_body(compare)
314
+
315
+ def create_pull_request(
316
+ self,
317
+ json: dict[str, str],
318
+ model: str = "",
319
+ ) -> dict[str, Any] | None:
245
320
  """Create a pull request.
246
321
 
322
+ A Conventional-Commits title and a detailed Markdown body are generated
323
+ for a newly created PR. A caller-provided 'title' or 'body' in `json`
324
+ takes precedence; only the missing field(s) are generated. To skip
325
+ generation entirely, provide both 'title' and 'body' in `json`.
326
+
247
327
  :param json: A dict containing info (e.g., base, head, title, body, etc.)
248
328
  about the pull request to be created.
249
329
  It's passed to the json parameter of requests.post.
330
+ :param model: The LiteLLM 'provider/model' string. When empty (the
331
+ default), missing title/body are generated deterministically from
332
+ the commit messages and changed files. When non-empty, an LLM is
333
+ used (with the optional 'ai' extra installed and the matching
334
+ provider API key read from the environment), falling back to
335
+ deterministic generation on failure.
250
336
  """
251
337
  if not ("head" in json and "base" in json):
252
338
  raise ValueError("The data dict must contains keys head and base!")
253
339
  # return an existing PR
254
- prs = self.get_pull_requests()
255
- for pr in prs:
340
+ for pr in self.get_pull_requests():
256
341
  if pr["head"]["ref"] == json["head"] and pr["base"]["ref"] == json["base"]:
257
342
  return pr
258
- # creat a new PR
343
+ # generate any title/body not already provided by the caller
344
+ if "title" not in json or "body" not in json:
345
+ content = self._generate_pull_request_content(
346
+ base=json["base"], head=json["head"], model=model
347
+ )
348
+ if content is not None:
349
+ # caller-provided title/body take precedence over generated ones
350
+ json = {"title": content[0], "body": content[1], **json}
351
+ # create a new PR
259
352
  resp = self._post(
260
353
  url=self._url_pull,
261
354
  json=json,
@@ -279,11 +372,14 @@ class Repository(GitHub):
279
372
  :param update: The branch to update.
280
373
  :param upstream: The upstream branch.
281
374
  """
375
+ # Provide a title and an (empty) body so no description is generated for
376
+ # this mechanical, immediately merged update PR.
282
377
  pr = self.create_pull_request(
283
378
  {
284
379
  "base": update,
285
380
  "head": upstream,
286
381
  "title": f"Merge {upstream} into {update}",
382
+ "body": "",
287
383
  },
288
384
  )
289
385
  if pr is None:
@@ -299,6 +395,21 @@ class Repository(GitHub):
299
395
  """
300
396
  return self._extract_all(url=f"{self._url_pull}/{pr_number}/files", n=n)
301
397
 
398
+ def compare(self, base: str, head: str) -> dict[str, Any]:
399
+ """Compare two commits/branches in this repository.
400
+
401
+ :param base: The base branch (or commit) of the comparison.
402
+ :param head: The head branch (or commit) of the comparison.
403
+ :return: The comparison result containing `commits` and `files`
404
+ (each file with `filename`, `status`, `additions`, `deletions`,
405
+ and `patch`). See
406
+ https://docs.github.com/en/rest/commits/commits#compare-two-commits.
407
+ """
408
+ # Branch names may contain slashes, so each ref is URL-encoded while the
409
+ # literal `...` separator between them is preserved.
410
+ basehead = f"{quote(base, safe='')}...{quote(head, safe='')}"
411
+ return self._get(url=f"{self._url_compare}/{basehead}").json()
412
+
302
413
  def get_branches(self, n: int = 0) -> list[dict[str, Any]]:
303
414
  """List branches in this repository."""
304
415
  return self._extract_all(url=self._url_branches, n=n)
@@ -354,6 +465,7 @@ class Repository(GitHub):
354
465
  automatically. Fetch it once and reuse it to avoid a redundant
355
466
  request when creating or updating multiple secrets.
356
467
  """
468
+ _validate_secret_name(name)
357
469
  if public_key is None:
358
470
  public_key = self.get_secret_public_key()
359
471
  return self._put(
@@ -549,6 +661,7 @@ class Organization(Owner):
549
661
  :param selected_repository_ids: Repository IDs that can access the secret
550
662
  when visibility is `selected`.
551
663
  """
664
+ _validate_secret_name(name)
552
665
  if selected_repository_ids and visibility != SecretVisibility.SELECTED:
553
666
  raise ValueError(
554
667
  "`selected_repository_ids` can only be provided when `visibility` is 'selected'."
@@ -0,0 +1,227 @@
1
+ """Generate pull request titles and descriptions from a comparison result.
2
+
3
+ The functions here operate on the JSON returned by the GitHub "compare two
4
+ commits" endpoint (see `Repository.compare`). They provide two layers:
5
+
6
+ - A deterministic layer (`deterministic_title` / `deterministic_body`) that
7
+ derives a Conventional-Commits title and a Markdown body purely from commit
8
+ messages and changed files. It has no extra dependencies and is always
9
+ available.
10
+ - An optional AI layer (`generate_pr_content`) that uses LiteLLM to produce a
11
+ richer title and description. It raises on any failure (missing `litellm`,
12
+ missing provider key, malformed reply) so the caller can fall back to the
13
+ deterministic layer.
14
+ """
15
+
16
+ import json
17
+ import re
18
+ from collections import Counter
19
+ from typing import Any, cast
20
+
21
+ _CONVENTIONAL_PATTERN = re.compile(
22
+ r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
23
+ r"(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$"
24
+ )
25
+ # The Conventional Commits spec allows the breaking-change footer token to be
26
+ # spelled either "BREAKING CHANGE" or "BREAKING-CHANGE".
27
+ _BREAKING_CHANGE_PATTERN = re.compile(r"BREAKING[ -]CHANGE")
28
+
29
+
30
+ def parse_conventional(subject: str) -> tuple[str, str | None, bool, str] | None:
31
+ """Parse a Conventional-Commits subject line.
32
+
33
+ :param subject: The first line of a commit message.
34
+ :return: A `(type, scope, breaking, description)` tuple, or None when the
35
+ subject does not follow the Conventional-Commits grammar.
36
+ """
37
+ match = _CONVENTIONAL_PATTERN.match(subject)
38
+ if match is None:
39
+ return None
40
+ return (
41
+ match["type"],
42
+ match["scope"],
43
+ bool(match["breaking"]),
44
+ match["description"],
45
+ )
46
+
47
+
48
+ def _commit_messages(compare: dict[str, Any], skip_merges: bool = True) -> list[str]:
49
+ """Extract commit messages from a comparison result.
50
+
51
+ :param compare: The comparison result from `Repository.compare`.
52
+ :param skip_merges: Whether to skip merge commits (subjects starting with
53
+ ``Merge ``).
54
+ """
55
+ messages = []
56
+ for commit in compare.get("commits") or []:
57
+ message = (commit.get("commit") or {}).get("message", "")
58
+ if not message:
59
+ continue
60
+ subject = message.splitlines()[0].strip()
61
+ if skip_merges and subject.startswith("Merge "):
62
+ continue
63
+ messages.append(message)
64
+ return messages
65
+
66
+
67
+ def _common_scope(compare: dict[str, Any]) -> str | None:
68
+ """Derive a Conventional-Commits scope from the changed files.
69
+
70
+ The scope is the common top-level directory shared by every changed file,
71
+ or None when the files do not share one (or live at the repository root).
72
+ """
73
+ filenames = [
74
+ file["filename"] for file in compare.get("files") or [] if file.get("filename")
75
+ ]
76
+ if not filenames or not all("/" in name for name in filenames):
77
+ return None
78
+ segments = {name.split("/", 1)[0] for name in filenames}
79
+ return next(iter(segments)) if len(segments) == 1 else None
80
+
81
+
82
+ def deterministic_title(compare: dict[str, Any]) -> str:
83
+ """Derive a Conventional-Commits title from a comparison result.
84
+
85
+ The type is the most significant type present across the commits
86
+ (``feat`` > ``fix`` > the most frequent parsed type > ``chore``); the scope
87
+ is the common top-level directory of the changed files; ``!`` is appended
88
+ for breaking changes.
89
+
90
+ :param compare: The comparison result from `Repository.compare`.
91
+ """
92
+ messages = _commit_messages(compare)
93
+ subjects = [message.splitlines()[0].strip() for message in messages]
94
+ parsed = [parse_conventional(subject) for subject in subjects]
95
+ types = [item[0] for item in parsed if item]
96
+ scope = _common_scope(compare)
97
+ prefix_scope = f"({scope})" if scope else ""
98
+ if not subjects:
99
+ return f"chore{prefix_scope}: update"
100
+ if "feat" in types:
101
+ type_ = "feat"
102
+ elif "fix" in types:
103
+ type_ = "fix"
104
+ elif types:
105
+ type_ = Counter(types).most_common(1)[0][0]
106
+ else:
107
+ type_ = "chore"
108
+ breaking = any(item[2] for item in parsed if item) or any(
109
+ _BREAKING_CHANGE_PATTERN.search(message) for message in messages
110
+ )
111
+ if len(subjects) == 1:
112
+ description = parsed[0][3] if parsed[0] else subjects[0]
113
+ else:
114
+ description = next(
115
+ (item[3] for item in parsed if item and item[0] == type_),
116
+ f"update {len(subjects)} commits",
117
+ )
118
+ return f"{type_}{prefix_scope}{'!' if breaking else ''}: {description}"
119
+
120
+
121
+ def deterministic_body(compare: dict[str, Any]) -> str:
122
+ """Build a Markdown PR body from a comparison result.
123
+
124
+ :param compare: The comparison result from `Repository.compare`.
125
+ """
126
+ sections = []
127
+ subjects = [
128
+ message.splitlines()[0].strip() for message in _commit_messages(compare)
129
+ ]
130
+ if subjects:
131
+ summary = "\n".join(f"- {subject}" for subject in subjects)
132
+ sections.append(f"## Summary\n\n{summary}")
133
+ files = compare.get("files") or []
134
+ if files:
135
+ # Conventional-Commit statuses ordered for a stable, readable "Changed
136
+ # files" section. Any status not listed is appended afterwards.
137
+ status_order = ("added", "modified", "removed", "renamed", "copied", "changed")
138
+ by_status: dict[str, list[dict[str, Any]]] = {}
139
+ for file in files:
140
+ by_status.setdefault(file.get("status") or "modified", []).append(file)
141
+ ordered = [status for status in status_order if status in by_status]
142
+ ordered += [status for status in by_status if status not in status_order]
143
+ lines = []
144
+ for status in ordered:
145
+ lines.append(f"**{status.capitalize()}**")
146
+ for file in by_status[status]:
147
+ lines.append(
148
+ f"- `{file.get('filename', '')}` "
149
+ f"(+{file.get('additions', 0)}/-{file.get('deletions', 0)})"
150
+ )
151
+ sections.append("## Changed files\n\n" + "\n".join(lines))
152
+ commits = compare.get("commits") or []
153
+ if commits:
154
+ lines = []
155
+ for commit in commits:
156
+ sha = (commit.get("sha") or "")[:7]
157
+ message = (commit.get("commit") or {}).get("message", "")
158
+ subject = message.splitlines()[0].strip() if message else ""
159
+ lines.append(f"- {sha} {subject}")
160
+ sections.append("## Commits\n\n" + "\n".join(lines))
161
+ return "\n\n".join(sections)
162
+
163
+
164
+ def summarize_for_ai(compare: dict[str, Any], max_chars: int = 12000) -> str:
165
+ """Assemble a compact, size-capped context for the AI prompt.
166
+
167
+ Combines the deterministic body with per-file patches, truncating once the
168
+ character budget is exhausted so very large diffs do not blow up the prompt.
169
+
170
+ :param compare: The comparison result from `Repository.compare`.
171
+ :param max_chars: The approximate maximum size of the returned context.
172
+ """
173
+ parts = [deterministic_body(compare), "## Diff"]
174
+ budget = max_chars - sum(len(part) for part in parts)
175
+ for file in compare.get("files") or []:
176
+ patch = file.get("patch")
177
+ if not patch:
178
+ continue
179
+ chunk = f"### {file.get('filename', '')}\n{patch}"
180
+ if len(chunk) > budget:
181
+ parts.append("<remaining patches omitted: size limit reached>")
182
+ break
183
+ parts.append(chunk)
184
+ budget -= len(chunk)
185
+ return "\n\n".join(parts)
186
+
187
+
188
+ def generate_pr_content(compare: dict[str, Any], model: str) -> tuple[str, str]:
189
+ """Generate a PR title and body with an LLM via LiteLLM.
190
+
191
+ :param compare: The comparison result from `Repository.compare`.
192
+ :param model: A LiteLLM ``provider/model`` string (e.g.
193
+ ``anthropic/claude-haiku-4-5-20251001``, ``gemini/gemini-2.5-flash``).
194
+ The matching provider API key is read from the environment by LiteLLM.
195
+ :return: A `(title, body)` tuple.
196
+ :raises Exception: If LiteLLM is not installed, no provider key is set, the
197
+ request fails, or the reply cannot be parsed. The caller is expected to
198
+ fall back to the deterministic layer.
199
+ """
200
+ import litellm
201
+
202
+ prompt = (
203
+ "You are writing a GitHub pull request from the changes below. "
204
+ "Respond with a JSON object containing exactly two string keys: "
205
+ "'title' and 'body'. The 'title' must be a single concise line "
206
+ "following the Conventional Commits specification "
207
+ "(e.g. 'feat(scope): summary'). The 'body' must be GitHub-flavored "
208
+ "Markdown describing the motivation and the key changes.\n\n"
209
+ f"{summarize_for_ai(compare)}"
210
+ )
211
+ response = litellm.completion(
212
+ model=model,
213
+ messages=[{"role": "user", "content": prompt}],
214
+ response_format={"type": "json_object"},
215
+ # Drop response_format for providers that do not support it rather than
216
+ # erroring; the prompt already requests JSON, so parsing still works.
217
+ drop_params=True,
218
+ )
219
+ response_any = cast(Any, response)
220
+ data = json.loads(response_any["choices"][0]["message"]["content"])
221
+ if not isinstance(data, dict):
222
+ raise ValueError("The model did not return a JSON object.")
223
+ title = str(data.get("title") or "").strip()
224
+ body = str(data.get("body") or "").strip()
225
+ if not title or not body:
226
+ raise ValueError("The model returned an empty title or body.")
227
+ return title, body
@@ -1,18 +1,20 @@
1
1
  """Benchmark action using cargo criterion."""
2
2
 
3
- from typing import Callable
4
- import tempfile
5
- from pathlib import Path
6
3
  import datetime
7
4
  import shutil
8
5
  import subprocess as sp
6
+ import tempfile
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
9
10
  from dulwich import porcelain
11
+
10
12
  from ..utils import (
13
+ commit_benchmarks,
11
14
  config_git,
12
- switch_branch,
13
- push_branch,
14
15
  gen_temp_branch,
15
- commit_benchmarks,
16
+ push_branch,
17
+ switch_branch,
16
18
  )
17
19
 
18
20
 
@@ -1,14 +1,16 @@
1
1
  """Utils for profiling Rust applications."""
2
2
 
3
- from typing import Iterable
4
- from pathlib import Path
5
- import time
6
3
  import datetime
7
4
  import subprocess as sp
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
8
9
  import psutil
9
- from .utils import build_project
10
- from ..utils import config_git, switch_branch, push_branch, commit_profiling
10
+
11
11
  from ...utils import partition
12
+ from ..utils import commit_profiling, config_git, push_branch, switch_branch
13
+ from .utils import build_project
12
14
 
13
15
 
14
16
  def launch_application(cmd: list[str]) -> int:
@@ -1,16 +1,17 @@
1
1
  import argparse
2
- from collections.abc import Sequence
3
2
  import datetime
4
- from pathlib import Path
5
3
  import subprocess as sp
6
4
  import sys
5
+ from collections.abc import Sequence
6
+ from pathlib import Path
7
7
  from typing import cast
8
+
8
9
  import yaml
9
- from dulwich.repo import Repo
10
- from dulwich.refs import Ref
11
- from dulwich.objects import Commit
12
- from dulwich.errors import NotGitRepository
13
10
  from dulwich.diff_tree import tree_changes
11
+ from dulwich.errors import NotGitRepository
12
+ from dulwich.objects import Commit
13
+ from dulwich.refs import Ref
14
+ from dulwich.repo import Repo
14
15
  from tenacity import retry, stop_after_attempt, wait_exponential
15
16
 
16
17
 
@@ -1,11 +1,12 @@
1
1
  import argparse
2
2
  import json
3
3
  import shutil
4
+ import subprocess as sp
4
5
  import sys
5
6
  import tomllib
6
- import tomli_w
7
7
  from pathlib import Path
8
- import subprocess as sp
8
+
9
+ import tomli_w
9
10
 
10
11
 
11
12
  def config_docker(data_root: str = "/mnt/docker"):
@@ -1,13 +1,15 @@
1
1
  import argparse
2
2
  import datetime
3
3
  import os
4
+ import re
4
5
  import sys
5
6
  from pathlib import Path
6
- import re
7
+
7
8
  from dulwich import porcelain
9
+ from requests.exceptions import HTTPError
10
+
8
11
  from github_rest_api import Repository
9
12
  from github_rest_api.utils import next_minor_or_strip_patch
10
- from requests.exceptions import HTTPError
11
13
 
12
14
 
13
15
  def parse_latest_version(repo: str) -> str:
@@ -7,9 +7,10 @@ import shutil
7
7
  import sys
8
8
  from collections.abc import Sequence
9
9
  from pathlib import Path
10
+
10
11
  from dulwich import porcelain
11
12
 
12
- from github_rest_api import User, Organization
13
+ from github_rest_api import Organization, User
13
14
 
14
15
 
15
16
  def _validate_repo(repo: str) -> None:
@@ -2,10 +2,12 @@
2
2
  The branch is updated (using dev) before creating the PR.
3
3
  """
4
4
 
5
- from argparse import ArgumentParser, Namespace
6
5
  import os
7
6
  import sys
7
+ from argparse import ArgumentParser, Namespace
8
+
8
9
  from github_rest_api import Repository
10
+ from github_rest_api.github import DEFAULT_PR_MODEL
9
11
  from github_rest_api.utils import compile_patterns
10
12
 
11
13
 
@@ -35,6 +37,20 @@ def parse_args(args=None, namespace=None) -> Namespace:
35
37
  required=True,
36
38
  help="The base branch to merge changes into.",
37
39
  )
40
+ parser.add_argument(
41
+ "--title",
42
+ dest="title",
43
+ default="",
44
+ help="The title of the pull request. Takes precedence over a generated "
45
+ "title and the default 'Merge <head> Into <base>' title.",
46
+ )
47
+ parser.add_argument(
48
+ "--body",
49
+ dest="body",
50
+ default="",
51
+ help="The body (description) of the pull request. Takes precedence over "
52
+ "a generated description.",
53
+ )
38
54
  parser.add_argument(
39
55
  "--ignore-patterns",
40
56
  dest="ignore_patterns",
@@ -48,6 +64,16 @@ def parse_args(args=None, namespace=None) -> Namespace:
48
64
  action="store_true",
49
65
  help="Update the head branch using the base branch before creating the pull request.",
50
66
  )
67
+ parser.add_argument(
68
+ "--model",
69
+ dest="model",
70
+ default="",
71
+ help="The LiteLLM 'provider/model' string for generating the title and "
72
+ f"description with an LLM (e.g. '{DEFAULT_PR_MODEL}' or "
73
+ "'gemini/gemini-2.5-flash'); the matching provider API key is read from "
74
+ "the environment. When omitted, the title and description are generated "
75
+ "deterministically from the commit messages and changed files.",
76
+ )
51
77
  return parser.parse_args(args=args, namespace=namespace)
52
78
 
53
79
 
@@ -74,13 +100,17 @@ def main() -> int:
74
100
  repo = Repository(args.token, os.environ["GITHUB_REPOSITORY"])
75
101
  if args.update:
76
102
  repo.update_branch(update=args.head_branch, upstream=args.base_branch)
77
- repo.create_pull_request(
78
- {
79
- "base": args.base_branch,
80
- "head": args.head_branch,
81
- "title": f"Merge {args.head_branch} Into {args.base_branch}",
82
- },
83
- )
103
+ pull_request = {
104
+ "base": args.base_branch,
105
+ "head": args.head_branch,
106
+ }
107
+ if args.title:
108
+ pull_request["title"] = args.title
109
+ if args.body:
110
+ pull_request["body"] = args.body
111
+ # An empty model generates the title/body deterministically; a --model uses
112
+ # an LLM (falling back to deterministic generation on failure).
113
+ repo.create_pull_request(pull_request, model=args.model)
84
114
  return 0
85
115
 
86
116
 
@@ -1,9 +1,10 @@
1
+ import argparse
2
+ import getpass
1
3
  import os
2
4
  import re
3
5
  import sys
4
- import argparse
5
- import getpass
6
6
  from pathlib import Path
7
+
7
8
  from github_rest_api import Repository
8
9
  from github_rest_api.scripts.utils import (
9
10
  find_project_root,