11"""Simple wrapper of GitHub REST APIs."""
22
3+ import logging
34import re
45from abc import ABCMeta , abstractmethod
56from base64 import b64encode
67from collections .abc import Sequence
78from enum import StrEnum
89from pathlib import Path
910from typing import Any , Callable
11+ from urllib .parse import quote
1012
1113import requests
1214from 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+
1424URL_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
1930def _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