Skip to content

Commit f078937

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

8 files changed

Lines changed: 2194 additions & 13 deletions

File tree

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)

github_rest_api/pr_content.py

Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
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+
22+
def parse_conventional(subject: str) -> tuple[str, str | None, bool, str] | None:
23+
"""Parse a Conventional-Commits subject line.
24+
25+
:param subject: The first line of a commit message.
26+
:return: A `(type, scope, breaking, description)` tuple, or None when the
27+
subject does not follow the Conventional-Commits grammar.
28+
"""
29+
pattern = re.compile(
30+
r"^(?P<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)"
31+
r"(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?: (?P<description>.+)$"
32+
)
33+
match = pattern.match(subject)
34+
if match is None:
35+
return None
36+
return (
37+
match["type"],
38+
match["scope"],
39+
bool(match["breaking"]),
40+
match["description"],
41+
)
42+
43+
44+
def _commit_messages(compare: dict[str, Any], skip_merges: bool = True) -> list[str]:
45+
"""Extract commit messages from a comparison result.
46+
47+
:param compare: The comparison result from `Repository.compare`.
48+
:param skip_merges: Whether to skip merge commits (subjects starting with
49+
``Merge ``).
50+
"""
51+
messages = []
52+
for commit in compare.get("commits") or []:
53+
message = (commit.get("commit") or {}).get("message", "")
54+
if not message:
55+
continue
56+
subject = message.splitlines()[0].strip()
57+
if skip_merges and subject.startswith("Merge "):
58+
continue
59+
messages.append(message)
60+
return messages
61+
62+
63+
def _common_scope(compare: dict[str, Any]) -> str | None:
64+
"""Derive a Conventional-Commits scope from the changed files.
65+
66+
The scope is the common top-level directory shared by every changed file,
67+
or None when the files do not share one (or live at the repository root).
68+
"""
69+
filenames = [
70+
file["filename"] for file in compare.get("files") or [] if file.get("filename")
71+
]
72+
if not filenames or not all("/" in name for name in filenames):
73+
return None
74+
segments = {name.split("/", 1)[0] for name in filenames}
75+
return next(iter(segments)) if len(segments) == 1 else None
76+
77+
78+
def deterministic_title(compare: dict[str, Any]) -> str:
79+
"""Derive a Conventional-Commits title from a comparison result.
80+
81+
The type is the most significant type present across the commits
82+
(``feat`` > ``fix`` > the most frequent parsed type > ``chore``); the scope
83+
is the common top-level directory of the changed files; ``!`` is appended
84+
for breaking changes.
85+
86+
:param compare: The comparison result from `Repository.compare`.
87+
"""
88+
messages = _commit_messages(compare)
89+
subjects = [message.splitlines()[0].strip() for message in messages]
90+
parsed = [parse_conventional(subject) for subject in subjects]
91+
types = [item[0] for item in parsed if item]
92+
scope = _common_scope(compare)
93+
prefix_scope = f"({scope})" if scope else ""
94+
if not subjects:
95+
return f"chore{prefix_scope}: update"
96+
if "feat" in types:
97+
type_ = "feat"
98+
elif "fix" in types:
99+
type_ = "fix"
100+
elif types:
101+
type_ = Counter(types).most_common(1)[0][0]
102+
else:
103+
type_ = "chore"
104+
# The Conventional Commits spec allows the breaking-change footer token to
105+
# be spelled either "BREAKING CHANGE" or "BREAKING-CHANGE".
106+
breaking_change_pattern = re.compile(r"BREAKING[ -]CHANGE")
107+
breaking = any(item[2] for item in parsed if item) or any(
108+
breaking_change_pattern.search(message) for message in messages
109+
)
110+
if len(subjects) == 1:
111+
description = parsed[0][3] if parsed[0] else subjects[0]
112+
else:
113+
description = next(
114+
(item[3] for item in parsed if item and item[0] == type_),
115+
f"update {len(subjects)} commits",
116+
)
117+
return f"{type_}{prefix_scope}{'!' if breaking else ''}: {description}"
118+
119+
120+
def deterministic_body(compare: dict[str, Any]) -> str:
121+
"""Build a Markdown PR body from a comparison result.
122+
123+
:param compare: The comparison result from `Repository.compare`.
124+
"""
125+
sections = []
126+
subjects = [
127+
message.splitlines()[0].strip() for message in _commit_messages(compare)
128+
]
129+
if subjects:
130+
summary = "\n".join(f"- {subject}" for subject in subjects)
131+
sections.append(f"## Summary\n\n{summary}")
132+
files = compare.get("files") or []
133+
if files:
134+
# Conventional-Commit statuses ordered for a stable, readable "Changed
135+
# files" section. Any status not listed is appended afterwards.
136+
status_order = ("added", "modified", "removed", "renamed", "copied", "changed")
137+
by_status: dict[str, list[dict[str, Any]]] = {}
138+
for file in files:
139+
by_status.setdefault(file.get("status") or "modified", []).append(file)
140+
ordered = [status for status in status_order if status in by_status]
141+
ordered += [status for status in by_status if status not in status_order]
142+
lines = []
143+
for status in ordered:
144+
lines.append(f"**{status.capitalize()}**")
145+
for file in by_status[status]:
146+
lines.append(
147+
f"- `{file.get('filename', '')}` "
148+
f"(+{file.get('additions', 0)}/-{file.get('deletions', 0)})"
149+
)
150+
sections.append("## Changed files\n\n" + "\n".join(lines))
151+
commits = compare.get("commits") or []
152+
if commits:
153+
lines = []
154+
for commit in commits:
155+
sha = (commit.get("sha") or "")[:7]
156+
message = (commit.get("commit") or {}).get("message", "")
157+
subject = message.splitlines()[0].strip() if message else ""
158+
lines.append(f"- {sha} {subject}")
159+
sections.append("## Commits\n\n" + "\n".join(lines))
160+
return "\n\n".join(sections)
161+
162+
163+
def summarize_for_ai(compare: dict[str, Any], max_chars: int = 12000) -> str:
164+
"""Assemble a compact, size-capped context for the AI prompt.
165+
166+
Combines the deterministic body with per-file patches, truncating once the
167+
character budget is exhausted so very large diffs do not blow up the prompt.
168+
169+
:param compare: The comparison result from `Repository.compare`.
170+
:param max_chars: The approximate maximum size of the returned context.
171+
"""
172+
parts = [deterministic_body(compare), "## Diff"]
173+
budget = max_chars - sum(len(part) for part in parts)
174+
for file in compare.get("files") or []:
175+
patch = file.get("patch")
176+
if not patch:
177+
continue
178+
chunk = f"### {file.get('filename', '')}\n{patch}"
179+
if len(chunk) > budget:
180+
parts.append("<remaining patches omitted: size limit reached>")
181+
break
182+
parts.append(chunk)
183+
budget -= len(chunk)
184+
return "\n\n".join(parts)
185+
186+
187+
def generate_pr_content(compare: dict[str, Any], model: str) -> tuple[str, str]:
188+
"""Generate a PR title and body with an LLM via LiteLLM.
189+
190+
:param compare: The comparison result from `Repository.compare`.
191+
:param model: A LiteLLM ``provider/model`` string (e.g.
192+
``anthropic/claude-haiku-4-5-20251001``, ``gemini/gemini-2.5-flash``).
193+
The matching provider API key is read from the environment by LiteLLM.
194+
:return: A `(title, body)` tuple.
195+
:raises Exception: If LiteLLM is not installed, no provider key is set, the
196+
request fails, or the reply cannot be parsed. The caller is expected to
197+
fall back to the deterministic layer.
198+
"""
199+
import litellm
200+
201+
prompt = (
202+
"You are writing a GitHub pull request from the changes below. "
203+
"Respond with a JSON object containing exactly two string keys: "
204+
"'title' and 'body'. The 'title' must be a single concise line "
205+
"following the Conventional Commits specification "
206+
"(e.g. 'feat(scope): summary'). The 'body' must be GitHub-flavored "
207+
"Markdown describing the motivation and the key changes.\n\n"
208+
f"{summarize_for_ai(compare)}"
209+
)
210+
response = litellm.completion(
211+
model=model,
212+
messages=[{"role": "user", "content": prompt}],
213+
response_format={"type": "json_object"},
214+
# Drop response_format for providers that do not support it rather than
215+
# erroring; the prompt already requests JSON, so parsing still works.
216+
drop_params=True,
217+
)
218+
response_any = cast(Any, response)
219+
data = json.loads(response_any["choices"][0]["message"]["content"])
220+
if not isinstance(data, dict):
221+
raise ValueError("The model did not return a JSON object.")
222+
title = str(data.get("title") or "").strip()
223+
body = str(data.get("body") or "").strip()
224+
if not title or not body:
225+
raise ValueError("The model returned an empty title or body.")
226+
return title, body

0 commit comments

Comments
 (0)