github-rest-api 0.42.1__tar.gz → 0.44.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.1/github_rest_api/scripts/github → github_rest_api-0.44.0/.github}/workflows/create_pr_to_main.yaml +1 -1
  2. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/lint.yaml +2 -0
  3. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/remove_branch.yaml +1 -1
  4. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/PKG-INFO +3 -1
  5. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/github.py +115 -6
  6. github_rest_api-0.44.0/github_rest_api/pr_content.py +227 -0
  7. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/create_pull_request.py +36 -7
  8. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/pyproject.toml +6 -4
  9. github_rest_api-0.44.0/tests/test_create_pull_request.py +55 -0
  10. github_rest_api-0.44.0/tests/test_github.py +257 -0
  11. github_rest_api-0.44.0/tests/test_pr_content.py +212 -0
  12. github_rest_api-0.44.0/uv.lock +2055 -0
  13. github_rest_api-0.42.1/tests/test_github.py +0 -67
  14. github_rest_api-0.42.1/uv.lock +0 -652
  15. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.devcontainer/devcontainer.json +0 -0
  16. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/release.yaml +0 -0
  17. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/test.yaml +0 -0
  18. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.gitignore +0 -0
  19. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/GEMINI.md +0 -0
  20. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/README.md +0 -0
  21. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/__init__.py +0 -0
  22. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/__init__.py +0 -0
  23. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/__init__.py +0 -0
  24. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/benchmark.py +0 -0
  25. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/profiling.py +0 -0
  26. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/utils.py +0 -0
  27. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/__init__.py +0 -0
  28. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/build_container_images.py +0 -0
  29. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/config_container.py +0 -0
  30. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/update_version_containerfile.py +0 -0
  31. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/__init__.py +0 -0
  32. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/add_github_repo.py +0 -0
  33. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/release_on_github.py +0 -0
  34. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/remove_branch.py +0 -0
  35. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/create_pr_dev_to_main.yaml +0 -0
  36. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/create_pr_to_dev.yaml +0 -0
  37. {github_rest_api-0.42.1/.github → github_rest_api-0.44.0/github_rest_api/scripts/github}/workflows/create_pr_to_main.yaml +0 -0
  38. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/python/lint.yaml +0 -0
  39. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/python/test.yaml +0 -0
  40. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/remove_branch.yaml +0 -0
  41. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/utils.py +0 -0
  42. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/utils.py +0 -0
  43. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/memory/MEMORY.md +0 -0
  44. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/memory/feedback_test_runner.md +0 -0
  45. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/__init__.py +0 -0
  46. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/test_build_container_images.py +0 -0
  47. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/test_release_on_github.py +0 -0
  48. {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/test_utils.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.1
3
+ Version: 0.44.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,5 +1,6 @@
1
1
  """Simple wrapper of GitHub REST APIs."""
2
2
 
3
+ import logging
3
4
  import re
4
5
  from abc import ABCMeta, abstractmethod
5
6
  from base64 import b64encode
@@ -7,13 +8,23 @@ from collections.abc import Sequence
7
8
  from enum import StrEnum
8
9
  from pathlib import Path
9
10
  from typing import Any, Callable
11
+ from urllib.parse import quote
10
12
 
11
13
  import requests
12
14
  from nacl import encoding, public
13
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
+
14
24
  URL_API = "https://api.github.com"
15
25
 
16
- _SECRET_NAME_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
26
+ # Default LiteLLM 'provider/model' used to generate PR titles and descriptions.
27
+ DEFAULT_PR_MODEL = "anthropic/claude-haiku-4-5-20251001"
17
28
 
18
29
 
19
30
  def _validate_secret_name(name: str) -> None:
@@ -34,7 +45,7 @@ def _validate_secret_name(name: str) -> None:
34
45
  f"Invalid secret name {name!r}: names must not start with the "
35
46
  "reserved 'GITHUB_' prefix."
36
47
  )
37
- if not _SECRET_NAME_PATTERN.fullmatch(name):
48
+ if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
38
49
  raise ValueError(
39
50
  f"Invalid secret name {name!r}: names may only contain alphanumeric "
40
51
  "characters and underscores, and must not start with a digit."
@@ -192,6 +203,12 @@ class GitHub:
192
203
  params["page"] += 1
193
204
 
194
205
 
206
+ class IssueState(StrEnum):
207
+ OPEN = "open"
208
+ CLOSED = "closed"
209
+ ALL = "all"
210
+
211
+
195
212
  class Repository(GitHub):
196
213
  """Abstraction of a GitHub repository."""
197
214
 
@@ -211,6 +228,7 @@ class Repository(GitHub):
211
228
  self._url_issues = f"{self._url_repo}/issues"
212
229
  self._url_releases = f"{self._url_repo}/releases"
213
230
  self._url_secrets = f"{self._url_repo}/actions/secrets"
231
+ self._url_compare = f"{self._url_repo}/compare"
214
232
 
215
233
  def get_releases(self, n: int = 0) -> list[dict[str, Any]]:
216
234
  """List releases in this repository."""
@@ -270,21 +288,86 @@ class Repository(GitHub):
270
288
  """List pull requests in this repository."""
271
289
  return self._extract_all(url=self._url_pull, n=n)
272
290
 
273
- def create_pull_request(self, json: dict[str, str]) -> dict[str, Any] | None:
291
+ def get_issues(
292
+ self, state: IssueState = IssueState.OPEN, n: int = 0
293
+ ) -> list[dict[str, Any]]:
294
+ """List issues in this repository.
295
+
296
+ :param state: Filter issues by state: ``open`` (the default),
297
+ ``closed``, or ``all``. Note that GitHub's issues endpoint also
298
+ returns pull requests; each pull request carries a
299
+ ``pull_request`` key.
300
+ :param n: The maximum number of issues to return (0 means all).
301
+ """
302
+ return self._extract_all(url=self._url_issues, params={"state": state}, n=n)
303
+
304
+ def _generate_pull_request_content(
305
+ self, base: str, head: str, model: str
306
+ ) -> tuple[str, str] | None:
307
+ """Generate a `(title, body)` for a new PR from the head/base comparison.
308
+
309
+ Returns None when there is nothing to describe (an empty comparison), so
310
+ the caller keeps the provided title.
311
+
312
+ :param base: The base branch the PR merges into.
313
+ :param head: The head branch containing the changes.
314
+ :param model: The LiteLLM 'provider/model' string for LLM generation.
315
+ When empty, the title and body are generated deterministically from
316
+ the commit messages and changed files; otherwise an LLM is used,
317
+ falling back to deterministic generation on failure.
318
+ """
319
+ compare = self.compare(base=base, head=head)
320
+ if not compare.get("commits"):
321
+ return None
322
+ if model:
323
+ try:
324
+ return generate_pr_content(compare, model=model)
325
+ except Exception as error:
326
+ logger.warning(
327
+ "LLM PR generation failed (%s); "
328
+ "falling back to deterministic content.",
329
+ error,
330
+ exc_info=True,
331
+ )
332
+ return deterministic_title(compare), deterministic_body(compare)
333
+
334
+ def create_pull_request(
335
+ self,
336
+ json: dict[str, str],
337
+ model: str = "",
338
+ ) -> dict[str, Any] | None:
274
339
  """Create a pull request.
275
340
 
341
+ A Conventional-Commits title and a detailed Markdown body are generated
342
+ for a newly created PR. A caller-provided 'title' or 'body' in `json`
343
+ takes precedence; only the missing field(s) are generated. To skip
344
+ generation entirely, provide both 'title' and 'body' in `json`.
345
+
276
346
  :param json: A dict containing info (e.g., base, head, title, body, etc.)
277
347
  about the pull request to be created.
278
348
  It's passed to the json parameter of requests.post.
349
+ :param model: The LiteLLM 'provider/model' string. When empty (the
350
+ default), missing title/body are generated deterministically from
351
+ the commit messages and changed files. When non-empty, an LLM is
352
+ used (with the optional 'ai' extra installed and the matching
353
+ provider API key read from the environment), falling back to
354
+ deterministic generation on failure.
279
355
  """
280
356
  if not ("head" in json and "base" in json):
281
357
  raise ValueError("The data dict must contains keys head and base!")
282
358
  # return an existing PR
283
- prs = self.get_pull_requests()
284
- for pr in prs:
359
+ for pr in self.get_pull_requests():
285
360
  if pr["head"]["ref"] == json["head"] and pr["base"]["ref"] == json["base"]:
286
361
  return pr
287
- # creat a new PR
362
+ # generate any title/body not already provided by the caller
363
+ if "title" not in json or "body" not in json:
364
+ content = self._generate_pull_request_content(
365
+ base=json["base"], head=json["head"], model=model
366
+ )
367
+ if content is not None:
368
+ # caller-provided title/body take precedence over generated ones
369
+ json = {"title": content[0], "body": content[1], **json}
370
+ # create a new PR
288
371
  resp = self._post(
289
372
  url=self._url_pull,
290
373
  json=json,
@@ -308,11 +391,14 @@ class Repository(GitHub):
308
391
  :param update: The branch to update.
309
392
  :param upstream: The upstream branch.
310
393
  """
394
+ # Provide a title and an (empty) body so no description is generated for
395
+ # this mechanical, immediately merged update PR.
311
396
  pr = self.create_pull_request(
312
397
  {
313
398
  "base": update,
314
399
  "head": upstream,
315
400
  "title": f"Merge {upstream} into {update}",
401
+ "body": "",
316
402
  },
317
403
  )
318
404
  if pr is None:
@@ -328,6 +414,21 @@ class Repository(GitHub):
328
414
  """
329
415
  return self._extract_all(url=f"{self._url_pull}/{pr_number}/files", n=n)
330
416
 
417
+ def compare(self, base: str, head: str) -> dict[str, Any]:
418
+ """Compare two commits/branches in this repository.
419
+
420
+ :param base: The base branch (or commit) of the comparison.
421
+ :param head: The head branch (or commit) of the comparison.
422
+ :return: The comparison result containing `commits` and `files`
423
+ (each file with `filename`, `status`, `additions`, `deletions`,
424
+ and `patch`). See
425
+ https://docs.github.com/en/rest/commits/commits#compare-two-commits.
426
+ """
427
+ # Branch names may contain slashes, so each ref is URL-encoded while the
428
+ # literal `...` separator between them is preserved.
429
+ basehead = f"{quote(base, safe='')}...{quote(head, safe='')}"
430
+ return self._get(url=f"{self._url_compare}/{basehead}").json()
431
+
331
432
  def get_branches(self, n: int = 0) -> list[dict[str, Any]]:
332
433
  """List branches in this repository."""
333
434
  return self._extract_all(url=self._url_branches, n=n)
@@ -417,6 +518,14 @@ class Repository(GitHub):
417
518
  """
418
519
  return self.pr_has_change(pr_number=pr_number, pred=pred)
419
520
 
521
+ def get_issue_comments(self, issue_number: int, n: int = 0) -> list[dict[str, Any]]:
522
+ """List comments on an issue in this repository.
523
+
524
+ :param issue_number: The number of the issue.
525
+ :param n: The maximum number of comments to return (0 means all).
526
+ """
527
+ return self._extract_all(url=f"{self._url_issues}/{issue_number}/comments", n=n)
528
+
420
529
  def create_issue_comment(self, issue_number: int, body: str) -> dict[str, Any]:
421
530
  """Add a new comment to an issue.
422
531
 
@@ -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
@@ -7,6 +7,7 @@ import sys
7
7
  from argparse import ArgumentParser, Namespace
8
8
 
9
9
  from github_rest_api import Repository
10
+ from github_rest_api.github import DEFAULT_PR_MODEL
10
11
  from github_rest_api.utils import compile_patterns
11
12
 
12
13
 
@@ -36,6 +37,20 @@ def parse_args(args=None, namespace=None) -> Namespace:
36
37
  required=True,
37
38
  help="The base branch to merge changes into.",
38
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
+ )
39
54
  parser.add_argument(
40
55
  "--ignore-patterns",
41
56
  dest="ignore_patterns",
@@ -49,6 +64,16 @@ def parse_args(args=None, namespace=None) -> Namespace:
49
64
  action="store_true",
50
65
  help="Update the head branch using the base branch before creating the pull request.",
51
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
+ )
52
77
  return parser.parse_args(args=args, namespace=namespace)
53
78
 
54
79
 
@@ -75,13 +100,17 @@ def main() -> int:
75
100
  repo = Repository(args.token, os.environ["GITHUB_REPOSITORY"])
76
101
  if args.update:
77
102
  repo.update_branch(update=args.head_branch, upstream=args.base_branch)
78
- repo.create_pull_request(
79
- {
80
- "base": args.base_branch,
81
- "head": args.head_branch,
82
- "title": f"Merge {args.head_branch} Into {args.base_branch}",
83
- },
84
- )
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)
85
114
  return 0
86
115
 
87
116
 
@@ -4,7 +4,7 @@ requires = [ "hatchling" ]
4
4
 
5
5
  [project]
6
6
  name = "github-rest-api"
7
- version = "0.42.1"
7
+ version = "0.44.0"
8
8
  description = "Simple wrapper of GitHub REST APIs."
9
9
  readme = "README.md"
10
10
  authors = [ { name = "Ben Du", email = "longendu@yahoo.com" } ]
@@ -24,6 +24,9 @@ dependencies = [
24
24
  "tenacity>=9.1.4",
25
25
  "tomli-w>=1",
26
26
  ]
27
+ optional-dependencies.ai = [
28
+ "litellm>=1.50",
29
+ ]
27
30
  scripts.add_github_repo = "github_rest_api.scripts.github.add_github_repo:main"
28
31
  scripts.build_container_images = "github_rest_api.scripts.container.build_container_images:main"
29
32
  scripts.config_container = "github_rest_api.scripts.container.config_container:main"
@@ -42,7 +45,6 @@ dev = [
42
45
  "ty>=0.0.8",
43
46
  ]
44
47
 
45
- [tool.ruff.lint]
48
+ [tool.ruff]
46
49
  # select the 'I' rule set for import sorting
47
- extend-select = ["I"]
48
-
50
+ lint.extend-select = [ "I" ]
@@ -0,0 +1,55 @@
1
+ import sys
2
+ from unittest.mock import MagicMock, patch
3
+
4
+ from github_rest_api.scripts.github import create_pull_request
5
+
6
+
7
+ def test_parse_args_defaults():
8
+ args = create_pull_request.parse_args(
9
+ ["--token", "t", "--head-branch", "dev", "--base-branch", "main"]
10
+ )
11
+ assert args.title == ""
12
+ assert args.body == ""
13
+ # No model by default, so generation is deterministic.
14
+ assert args.model == ""
15
+
16
+
17
+ def _run_main(monkeypatch, extra_argv):
18
+ argv = [
19
+ "create_pull_request",
20
+ "--token",
21
+ "t",
22
+ "--head-branch",
23
+ "dev",
24
+ "--base-branch",
25
+ "main",
26
+ *extra_argv,
27
+ ]
28
+ monkeypatch.setattr(sys, "argv", argv)
29
+ monkeypatch.setenv("GITHUB_REPOSITORY", "owner/name")
30
+ repo = MagicMock()
31
+ with patch.object(create_pull_request, "Repository", return_value=repo):
32
+ assert create_pull_request.main() == 0
33
+ return repo.create_pull_request.call_args
34
+
35
+
36
+ def test_main_passes_title_and_body(monkeypatch):
37
+ call = _run_main(monkeypatch, ["--title", "My Title", "--body", "My Body"])
38
+ sent = call.args[0]
39
+ assert sent["title"] == "My Title"
40
+ assert sent["body"] == "My Body"
41
+
42
+
43
+ def test_main_without_options_defaults_to_deterministic(monkeypatch):
44
+ call = _run_main(monkeypatch, [])
45
+ sent = call.args[0]
46
+ # No title/body are set here; create_pull_request generates them
47
+ # deterministically (empty model).
48
+ assert "title" not in sent
49
+ assert "body" not in sent
50
+ assert call.kwargs["model"] == ""
51
+
52
+
53
+ def test_main_with_model_uses_llm(monkeypatch):
54
+ call = _run_main(monkeypatch, ["--model", "gemini/g"])
55
+ assert call.kwargs["model"] == "gemini/g"