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.
- {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
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/lint.yaml +2 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/remove_branch.yaml +1 -1
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/PKG-INFO +3 -1
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/github.py +115 -6
- github_rest_api-0.44.0/github_rest_api/pr_content.py +227 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/create_pull_request.py +36 -7
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/pyproject.toml +6 -4
- github_rest_api-0.44.0/tests/test_create_pull_request.py +55 -0
- github_rest_api-0.44.0/tests/test_github.py +257 -0
- github_rest_api-0.44.0/tests/test_pr_content.py +212 -0
- github_rest_api-0.44.0/uv.lock +2055 -0
- github_rest_api-0.42.1/tests/test_github.py +0 -67
- github_rest_api-0.42.1/uv.lock +0 -652
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.devcontainer/devcontainer.json +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/release.yaml +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.github/workflows/test.yaml +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/.gitignore +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/GEMINI.md +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/README.md +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/__init__.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/__init__.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/__init__.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/benchmark.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/profiling.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/cargo/utils.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/__init__.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/build_container_images.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/config_container.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/container/update_version_containerfile.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/__init__.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/add_github_repo.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/release_on_github.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/remove_branch.py +0 -0
- {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
- {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
- {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
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/python/lint.yaml +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/python/test.yaml +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/github/workflows/remove_branch.yaml +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/scripts/utils.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/github_rest_api/utils.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/memory/MEMORY.md +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/memory/feedback_test_runner.md +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/__init__.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/test_build_container_images.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/test_release_on_github.py +0 -0
- {github_rest_api-0.42.1 → github_rest_api-0.44.0}/tests/test_utils.py +0 -0
|
@@ -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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: github-rest-api
|
|
3
|
-
Version: 0.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
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
|
|
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"
|