Skip to content

Commit 8a74f96

Browse files
committed
feat: support generating PR title and description using AI-agents
1 parent ba34b67 commit 8a74f96

9 files changed

Lines changed: 2199 additions & 16 deletions

File tree

.github/workflows/lint.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ jobs:
1414
run: |
1515
curl -LsSf https://astral.sh/uv/install.sh | sudo env UV_INSTALL_DIR="/usr/local/bin" sh
1616
uv sync --all-extras
17+
- name: Check pyproject.toml formatting
18+
run: uv run pyproject-fmt --check pyproject.toml
1719
- name: Check code format
1820
run: uv run ruff format --check ./
1921
- name: Lint with ruff

github_rest_api/github.py

Lines changed: 88 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
11
"""Simple wrapper of GitHub REST APIs."""
22

3+
import logging
34
import re
45
from abc import ABCMeta, abstractmethod
56
from base64 import b64encode
67
from collections.abc import Sequence
78
from enum import StrEnum
89
from pathlib import Path
910
from typing import Any, Callable
11+
from urllib.parse import quote
1012

1113
import requests
1214
from nacl import encoding, public
1315

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+
1424
URL_API = "https://api.github.com"
1525

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"
1728

1829

1930
def _validate_secret_name(name: str) -> None:
@@ -34,7 +45,7 @@ def _validate_secret_name(name: str) -> None:
3445
f"Invalid secret name {name!r}: names must not start with the "
3546
"reserved 'GITHUB_' prefix."
3647
)
37-
if not _SECRET_NAME_PATTERN.fullmatch(name):
48+
if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
3849
raise ValueError(
3950
f"Invalid secret name {name!r}: names may only contain alphanumeric "
4051
"characters and underscores, and must not start with a digit."
@@ -211,6 +222,7 @@ def __init__(self, token: str, repo: str):
211222
self._url_issues = f"{self._url_repo}/issues"
212223
self._url_releases = f"{self._url_repo}/releases"
213224
self._url_secrets = f"{self._url_repo}/actions/secrets"
225+
self._url_compare = f"{self._url_repo}/compare"
214226

215227
def get_releases(self, n: int = 0) -> list[dict[str, Any]]:
216228
"""List releases in this repository."""
@@ -270,21 +282,73 @@ def get_pull_requests(self, n: int = 0) -> list[dict[str, Any]]:
270282
"""List pull requests in this repository."""
271283
return self._extract_all(url=self._url_pull, n=n)
272284

273-
def create_pull_request(self, json: dict[str, str]) -> dict[str, Any] | None:
285+
def _generate_pull_request_content(
286+
self, base: str, head: str, model: str
287+
) -> tuple[str, str] | None:
288+
"""Generate a `(title, body)` for a new PR from the head/base comparison.
289+
290+
Returns None when there is nothing to describe (an empty comparison), so
291+
the caller keeps the provided title.
292+
293+
:param base: The base branch the PR merges into.
294+
:param head: The head branch containing the changes.
295+
:param model: The LiteLLM 'provider/model' string for LLM generation.
296+
When empty, the title and body are generated deterministically from
297+
the commit messages and changed files; otherwise an LLM is used,
298+
falling back to deterministic generation on failure.
299+
"""
300+
compare = self.compare(base=base, head=head)
301+
if not compare.get("commits"):
302+
return None
303+
if model:
304+
try:
305+
return generate_pr_content(compare, model=model)
306+
except Exception as error:
307+
logger.warning(
308+
"LLM PR generation failed (%s); "
309+
"falling back to deterministic content.",
310+
error,
311+
exc_info=True,
312+
)
313+
return deterministic_title(compare), deterministic_body(compare)
314+
315+
def create_pull_request(
316+
self,
317+
json: dict[str, str],
318+
model: str = "",
319+
) -> dict[str, Any] | None:
274320
"""Create a pull request.
275321
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+
276327
:param json: A dict containing info (e.g., base, head, title, body, etc.)
277328
about the pull request to be created.
278329
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.
279336
"""
280337
if not ("head" in json and "base" in json):
281338
raise ValueError("The data dict must contains keys head and base!")
282339
# return an existing PR
283-
prs = self.get_pull_requests()
284-
for pr in prs:
340+
for pr in self.get_pull_requests():
285341
if pr["head"]["ref"] == json["head"] and pr["base"]["ref"] == json["base"]:
286342
return pr
287-
# creat a new PR
343+
# generate any title/body not already provided by the caller
344+
if "title" not in json or "body" not in json:
345+
content = self._generate_pull_request_content(
346+
base=json["base"], head=json["head"], model=model
347+
)
348+
if content is not None:
349+
# caller-provided title/body take precedence over generated ones
350+
json = {"title": content[0], "body": content[1], **json}
351+
# create a new PR
288352
resp = self._post(
289353
url=self._url_pull,
290354
json=json,
@@ -308,11 +372,14 @@ def update_branch(self, update: str, upstream: str) -> dict[str, Any] | None:
308372
:param update: The branch to update.
309373
:param upstream: The upstream branch.
310374
"""
375+
# Provide a title and an (empty) body so no description is generated for
376+
# this mechanical, immediately merged update PR.
311377
pr = self.create_pull_request(
312378
{
313379
"base": update,
314380
"head": upstream,
315381
"title": f"Merge {upstream} into {update}",
382+
"body": "",
316383
},
317384
)
318385
if pr is None:
@@ -328,6 +395,21 @@ def get_pull_request_files(
328395
"""
329396
return self._extract_all(url=f"{self._url_pull}/{pr_number}/files", n=n)
330397

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+
331413
def get_branches(self, n: int = 0) -> list[dict[str, Any]]:
332414
"""List branches in this repository."""
333415
return self._extract_all(url=self._url_branches, n=n)

0 commit comments

Comments
 (0)