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.
- {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
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/lint.yaml +2 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/remove_branch.yaml +1 -1
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/PKG-INFO +3 -1
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/github.py +118 -5
- github_rest_api-0.43.0/github_rest_api/pr_content.py +227 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/benchmark.py +8 -6
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/profiling.py +7 -5
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/build_container_images.py +7 -6
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/config_container.py +3 -2
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/update_version_containerfile.py +4 -2
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/add_github_repo.py +2 -1
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/create_pull_request.py +38 -8
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/release_on_github.py +3 -2
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/remove_branch.py +2 -1
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/utils.py +4 -3
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/utils.py +2 -2
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/pyproject.toml +8 -1
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/test_build_container_images.py +1 -0
- github_rest_api-0.43.0/tests/test_create_pull_request.py +55 -0
- github_rest_api-0.43.0/tests/test_github.py +238 -0
- github_rest_api-0.43.0/tests/test_pr_content.py +212 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/test_release_on_github.py +2 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/test_utils.py +1 -0
- github_rest_api-0.43.0/uv.lock +2055 -0
- github_rest_api-0.42.0/tests/test_github.py +0 -32
- github_rest_api-0.42.0/uv.lock +0 -652
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.devcontainer/devcontainer.json +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/release.yaml +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.github/workflows/test.yaml +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/.gitignore +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/GEMINI.md +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/README.md +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/__init__.py +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/__init__.py +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/__init__.py +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/utils.py +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/container/__init__.py +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/__init__.py +0 -0
- {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
- {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
- {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
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/python/lint.yaml +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/python/test.yaml +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/workflows/remove_branch.yaml +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/memory/MEMORY.md +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/memory/feedback_test_runner.md +0 -0
- {github_rest_api-0.42.0 → github_rest_api-0.43.0}/tests/__init__.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.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
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
{github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/benchmark.py
RENAMED
|
@@ -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
|
-
|
|
16
|
+
push_branch,
|
|
17
|
+
switch_branch,
|
|
16
18
|
)
|
|
17
19
|
|
|
18
20
|
|
{github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/cargo/profiling.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
{github_rest_api-0.42.0 → github_rest_api-0.43.0}/github_rest_api/scripts/github/add_github_repo.py
RENAMED
|
@@ -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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|