api = GhApi()GhApi details
You can set an environment variable named GH_HOST to override the default of https://api.github.com incase you are running GitHub Enterprise(GHE). However, this library has not been tested on GHE, so proceed at your own risk.
GhSyncTransport
def GhSyncTransport(
debug:NoneType=None, limit_cb:NoneType=None, **kwargs
):Sync twin of GhTransport: same debug, header, and rate-limit handling over a blocking SyncTransport
GhTransport
def GhTransport(
debug:NoneType=None, limit_cb:NoneType=None, **kwargs
):Async transport converting JSON responses to AttrDicts and tracking rate-limit and response headers.
print_summary
def print_summary(
method, url, kwargs
):Debug callback for GhApi(debug=...): print each request with the token (if any) removed
GhApi
def GhApi(
owner:NoneType=None, repo:NoneType=None, token:NoneType=None, jwt_token:NoneType=None, debug:NoneType=None,
limit_cb:NoneType=None, gh_host:NoneType=None, # chkstyle: ignore-node
authenticate:bool=True, timeout:float=60.0, sync:bool=False, **kwargs
):GitHub API client. Endpoint groups (issues, pulls, …) are generated per-instance from GitHub’s OpenAPI metadata, so the class shows only convenience methods – inspect a live instance, e.g. doc(GhApi()), to see the full API.
Access by path
GhApi.__call__
def __call__(
path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data:NoneType=None
):Call a fully specified path (or full URL) using HTTP verb directly (returns an awaitable on an async client)
You can call a GhApi object as a function, passing in the path to the endpoint, the HTTP verb, and any route, query parameter, or post data parameters as required.
await api('/repos/{owner}/{repo}/git/ref/{ref}', 'GET', route=dict(
owner='fastai', repo='ghapi-test', ref='heads/master')){ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',
'object': { 'sha': 'b72d6c87a9237ca3c26298a64a6acf06217ace4a',
'type': 'commit',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/b72d6c87a9237ca3c26298a64a6acf06217ace4a'},
'ref': 'refs/heads/master',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}GhApi.__getitem__
def __getitem__(
k
):Lookup an endpoint by path and verb (which defaults to ‘GET’)
You can access endpoints by indexing into the object. When using the API this way, you do not need to specify what type of parameter (route, query, or post data) is being used. This is, therefore, the same call as above:
await api['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master'){ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',
'object': { 'sha': 'b72d6c87a9237ca3c26298a64a6acf06217ace4a',
'type': 'commit',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/b72d6c87a9237ca3c26298a64a6acf06217ace4a'},
'ref': 'refs/heads/master',
'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}Media types
For some endpoints GitHub lets you specify a media type the for response data, using the Accept header. If you choose a media type that is not JSON formatted (for instance application/vnd.github.v3.sha) then the call to the GhApi object will return a string instead of an object.
await api('/repos/{owner}/{repo}/commits/{ref}', 'GET', route=dict(owner='fastai', repo='ghapi-test', ref='refs/heads/master'),
headers={'Accept': 'application/vnd.github.VERSION.sha'})'b72d6c87a9237ca3c26298a64a6acf06217ace4a'
Rate limits
GitHub has various rate limits for their API. After each call, the response includes information about how many requests are remaining in the hourly quota. If you’d like to add alerts, or indications showing current quota usage, you can register a callback with GhApi by passing a callable to the limit_cb parameter. This callback will be called whenever the amount of quota used changes. It will be called with two arguments: the new quota remaining, and the total hourly quota.
def _f(rem,quota): print(f"Quota remaining: {rem} of {quota}")
api = GhApi(limit_cb=_f)
(await api['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master')).refQuota remaining: 4931 of 5000
'refs/heads/master'
You can always get the remaining quota from the limit_rem attribute:
api.limit_rem'4931'
Sync usage
Everything in ghapi is async by default. For code that can’t (or shouldn’t) be async, pass sync=True to get a client whose endpoint calls block and return results directly, with the same groups, names, and signatures:
sapi = GhApi(owner='fastai', repo='ghapi-test', sync=True)
test_eq(sapi.repos.get().name, 'ghapi-test')
test_eq(sapi['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master').object.type, 'commit')Only the generated endpoints are sync on such a client: the convenience methods below (read_issue, create_gist, …) are written as async code, so they stay awaitable-only. To use those from sync code, call them on a regular async client via fastcore.net.run_sync (a stdlib-only bridge that works even inside Jupyter), e.g. run_sync(GhApi().read_issue(205)). Pagination has sync twins, sync_paged and sync_pages (see page). And for a quick one-off endpoint call, call_gh wraps constructing a sync client and calling one operation:
call_gh
def call_gh(
op:str, *args, token:NoneType=None, **kwargs
):Call one GitHub operation op (e.g. 'repos.get') on a fresh sync client; handy for one-off calls from sync code
test_eq(call_gh('repos.get', owner='fastai', repo='ghapi-test').name, 'ghapi-test')Operations
Instead of passing a path to GhApi, you will more often use the operation methods provided in the API’s operation groups, which include documentation, signatures, and auto-complete.
If you provide owner and/or repo to the constructor, they will be automatically inserted into any calls which use them (except when calling GhApi as a function). You can also pass any other arbitrary keyword arguments you like to have them used as defaults for any relevant calls.
You must include a GitHub API token if you need to access any authenticated endpoints. If don’t pass the token param, then your GITHUB_TOKEN environment variable will be used, if available.
api = GhApi(owner='AnswerDotAI', repo='ghapi-test', token=token)Operation groups
The following groups of endpoints are provided, which you can list at any time along with a link to documentation for all endpoints in that group, by displaying the GhApi object:
api- actions
- activity
- agent_tasks
- agents
- api_insights
- apps
- billing
- campaigns
- checks
- classroom
- code_quality
- code_scanning
- code_security
- codes_of_conduct
- codespaces
- copilot
- copilot_spaces
- credentials
- dependabot
- dependency_graph
- emojis
- enterprise_team_memberships
- enterprise_team_organizations
- enterprise_teams
- gists
- git
- gitignore
- hosted_compute
- interactions
- issues
- licenses
- markdown
- meta
- migrations
- oidc
- orgs
- packages
- private_registries
- projects
- pulls
- rate_limit
- reactions
- repos
- search
- secret_scanning
- security_advisories
- teams
- users
api.codes_of_conduct- codes_of_conduct.get_all_codes_of_conduct(): Get all codes of conduct
- codes_of_conduct.get_conduct_code(key): Get a code of conduct
Calling endpoints
The GitHub API’s endpoint names generally start with a verb like “get”, “list”, “delete”, “create”, etc, followed _, then by a noun such as “ref”, “webhook”, “issue”, etc.
Each endpoint has a different signature, which you can see by using Shift-Tab in Jupyter, or by just printing the endpoint object (which also shows a link to the GitHub docs):
print(api.repos.create_webhook)repos.create_webhook(name: str = UNSET, config: dict = UNSET, owner: str = 'AnswerDotAI', repo: str = 'ghapi-test', events: list = ['push'], active: bool = True)
https://docs.github.com/rest/repos/webhooks#create-a-repository-webhook
Displaying an endpoint object in Jupyter also provides a formatted summary and link to the official GitHub documentation:
api.repos.create_webhookCreate a repository webhook
Docs: https://docs.github.com/rest/repos/webhooks#create-a-repository-webhook
Parameters: - name (str, optional): Use web to create a webhook. Default: web. This parameter only accepts the value web. - config (dict, optional): Key/value pairs to provide settings for this webhook. - owner (str, default: ‘AnswerDotAI’): The account owner of the repository. The name is not case sensitive. - repo (str, default: ‘ghapi-test’): The name of the repository without the .git extension. The name is not case sensitive. - events (list, default: [‘push’]): Determines what events the hook is triggered for. - active (bool, default: True): Determines if notifications are sent when the webhook is triggered. Set to true to send notifications.
Endpoint objects are called using standard Python method syntax:
ref = await api.git.get_ref('heads/master')
test_eq(ref.object.type, 'commit')Information about the endpoint are available as attributes:
api.git.get_ref.path,api.git.get_ref.verb('/repos/{owner}/{repo}/git/ref/{ref}', 'GET')
You can get a list of all endpoints available in a group, along with a link to documentation for each, by viewing the group:
api.git- git.create_blob(content, owner, repo, encoding): Create a blob
- git.get_blob(file_sha, owner, repo): Get a blob
- git.create_commit(message, tree, parents, author, committer, signature, owner, repo): Create a commit
- git.get_commit(commit_sha, owner, repo): Get a commit object
- git.list_matching_refs(ref, owner, repo): List matching references
- git.get_ref(ref, owner, repo): Get a reference
- git.create_ref(ref, sha, owner, repo): Create a reference
- git.update_ref(ref, sha, owner, repo, force): Update a reference
- git.delete_ref(ref, owner, repo): Delete a reference
- git.create_tag(tag, message, object, type, tagger, owner, repo): Create a tag object
- git.get_tag(tag_sha, owner, repo): Get a tag
- git.create_tree(tree, base_tree, owner, repo): Create a tree
- git.get_tree(tree_sha, recursive, owner, repo): Get a tree
For “list” endpoints, the noun will be a plural form, e.g.:
hooks = await api.repos.list_webhooks()
test_eq(len(hooks), 0)You can pass dicts, lists, etc. directly, where they are required for GitHub API endpoints:
url = 'https://example.com'
cfg = dict(url=url, content_type='json', secret='XXX')
hook = await api.repos.create_webhook(config=cfg, events=['ping'])
test_eq(hook.config.url, url)Let’s confirm that our new webhook has been created:
hooks = await api.repos.list_webhooks()
test_eq(len(hooks), 1)
test_eq(hooks[0].events, ['ping'])Finally, we can delete our new webhook:
await api.repos.delete_webhook(hooks[0].id)Convenience functions
date2gh
def date2gh(
dt:datetime
)->str:Convert dt (which is assumed to be in UTC time zone) to a format suitable for GitHub API operations
The GitHub API assumes that dates will be in a specific string format. date2gh converts Python standard datetime objects to that format. For instance, to find issues opened in the ‘fastcore’ repo in the last 4 weeks:
dt = date2gh(datetime.now(timezone.utc) - timedelta(weeks=4))
issues = await GhApi('fastai').issues.list_for_repo(repo='fastcore', since=dt)
len(issues)2
gh2date
def gh2date(
dtstr:str
)->datetime:Convert date string dtstr received from a GitHub API operation to a UTC datetime
# created = issues[0].created_at
# print(created, '->', gh2date(created))You can set the debug attribute to any callable to intercept all requests – it’s called with the method, URL, and request kwargs before each request. print_summary is provided for this purpose (it prints each request with the auth token removed), and setting the GHAPI_DEBUG environment variable enables it globally:
api.debug=print_summary
(await api.codes_of_conduct.get_all_codes_of_conduct())[0]
api.debug=NoneGET https://api.github.com/codes_of_conduct {'headers': {}, 'params': {}, 'json_data': None}
Convenience methods
Some methods in the GitHub API are a bit clunky or unintuitive. In these situations we add convenience methods to GhApi to make things simpler. There are also some multi-step processes in the GitHub API that GhApi provide convenient wrappers for. The methods currently available are shown below; do not hesitate to create an issue or pull request if there are other processes that you’d like to see supported better.
GhApi.create_gist
async def create_gist(
description, content, filename:str='gist.txt', public:bool=False, img_paths:NoneType=None
):Create a gist, optionally with images where each md img url will be placed with img upload urls.
gist = await api.create_gist("some description", "some content")
print(gist.html_url)https://gist.github.com/jph00/d68e0a59cd9fc8e984797d29898b8317
gist.files['gist.txt'].content'some content'
gist = await api.create_gist("some description", "some image\n\n", 'gist.md', img_paths=['../puppy.jpg'])
print(gist.html_url)https://gist.github.com/jph00/929068e9fa15a998e3c58bbb54e1bd24
gist.files['gist.md'].content'some image\n\n'
Note that if you want to create a gist with multiple files, call the GitHub API directly, e.g.:
api.gists.create("some description", files={"f1.txt": {"content": "my content"}, ...})GhApi.update_gist
async def update_gist(
gist_id:str, content:str
):Update the first file in a gist with new content
GhApi.gist_file
def gist_file(
gist_id:str
):Get the first file from a gist; coro if async client
GhApi.load_gist
def load_gist(
gist_id:str
):Retrieve a gist by id, or by user/id (as it appears in gist URLs); coro if async client
load_gist accepts a bare gist id or a user/id string (as it appears in a gist’s URL). gist_file and update_gist work with the first file in a gist; for gists with multiple files, use api.gists.get/api.gists.update directly. load_gist and gist_file follow the client’s mode: on an async client they return awaitables as usual, while on a GhApi(sync=True) client they return results directly (gist_file does this via fastcore’s then).
gistid = 'jph00/e7cfd4ded593e8ef6217e78a0131960c'
loaded = await api.load_gist(gistid)
test_eq(loaded.id, 'e7cfd4ded593e8ef6217e78a0131960c')
gfile = await api.gist_file(gistid)
assert gfile.content
sapi = GhApi(sync=True)
test_eq(sapi.load_gist(gistid).id, 'e7cfd4ded593e8ef6217e78a0131960c')
assert sapi.gist_file(gistid).content
g = await api.create_gist('update_gist test', 'v1')
url = await api.update_gist(g.id, 'v2')
test_eq(url, g.html_url)
for _ in range(20):
content = first((await api.gists.get(g.id)).files.values()).content
if content == 'v2': break
sleep(1)
test_eq(content, 'v2')
await api.gists.delete(g.id)Releases
GhApi.delete_release
async def delete_release(
release
):Delete a release and its associated tag
GhApi.upload_file
async def upload_file(
rel, fn
):Upload fn to endpoint for release rel
GhApi.create_release
async def create_release(
tag_name, branch:str='master', name:NoneType=None, body:str='', draft:bool=False, prerelease:bool=False,
files:NoneType=None, make_latest:Unset=UNSET
):Wrapper for GhApi.repos.create_release which also uploads files
Creating a release and attaching files to it is normally a multi-stage process, so create_release wraps this up for you. It takes the same arguments as repos.create_release, along with files, which can contain a single file name, or a list of file names to upload to your release. Arguments left at their defaults (such as make_latest) are omitted from the API call, so GitHub’s own defaults apply; pass make_latest='false' when releasing an update to an older version, so it doesn’t take over the repo’s “Latest” marker:
rel = await api.create_release('0.0.1', files=['../README.md'])
test_eq(rel.name, 'v0.0.1')for _ in range(20):
rels = await api.repos.list_releases()
if len(rels) >= 1: break
sleep(0.5)
test_eq(len(rels), 1)We can check that our file has been uploaded; GitHub refers to them as “assets”:
assets = await api.repos.list_release_assets(rels[0].id)
test_eq(assets[0].name, 'README.md')GhApi.delete_release
async def delete_release(
release
):Delete a release and its associated tag
GhApi.list_branches
async def list_branches(
prefix:str=''
):List all branches, optionally filtered to those starting with prefix
Branches can be listed in the exactly the same way as tags.
test_eq(len(await api.list_branches('master')), 1)We can delete our release and confirm that it is removed:
await api.delete_release(rels[0])
test_eq(len(await api.repos.list_releases()), 0)# #| hide
# #not working
# #| export
# @patch
# def create_branch_empty(self:GhApi, branch):
# c = self.git.create_commit(f'create {branch}', EMPTY_TREE_SHA)
# return self.git.create_ref(f'refs/heads/{branch}', c.sha)GhApi.create_branch_empty
async def create_branch_empty(
branch
):Call self as a function.
ref = await api.create_branch_empty("testme")
test_eq(len(await api.list_branches('testme')), 1)GhApi.delete_tag
async def delete_tag(
tag:str
):Delete a tag
GhApi.delete_branch
async def delete_branch(
branch:str
):Delete a branch
await api.delete_branch('testme')
for _ in range(20):
if not await api.list_branches('testme'): break
sleep(0.5)
test_eq(len(await api.list_branches('testme')), 0)GhApi.get_branch
async def get_branch(
branch:NoneType=None
):Call self as a function.
Content (git files)
GhApi.list_files
async def list_files(
branch:NoneType=None
):Call self as a function.
files = await api.list_files()
files['README.md']{ 'mode': '100644',
'path': 'README.md',
'sha': 'eaea0f2698e76c75602058bf4e2e9fd7940ac4e3',
'size': 72,
'type': 'blob',
'url': 'https://api.github.com/repos/AnswerDotAI/ghapi-test/git/blobs/eaea0f2698e76c75602058bf4e2e9fd7940ac4e3'}GhApi.get_content
async def get_content(
path
):Call self as a function.
readme = (await api.get_content('README.md')).decode()
assert 'ghapi' in readmeGhApi.create_or_update_file
async def create_or_update_file(
path, message, committer, author, content:NoneType=None, sha:NoneType=None, branch:str=''
):Call self as a function.
GhApi.create_file
async def create_file(
path, message, committer, author, content:NoneType=None, branch:NoneType=None
):Call self as a function.
person = dict(name="Monalisa Octocat", email="[email protected]")
res = await api.create_file(path='foo', message="Create foo", content="foobar", committer=person, author=person)
test_eq('foobar', (await api.get_content('foo')).decode())GhApi.delete_file
async def delete_file(
path, message, committer, author, sha:NoneType=None, branch:NoneType=None
):Call self as a function.
await api.delete_file('foo', 'delete foo', committer=person, author=person)
assert 'foo' not in await api.list_files()GhApi.update_contents
async def update_contents(
path, message, committer, author, content, sha:NoneType=None, branch:NoneType=None
):Call self as a function.
res = await api.update_contents(path='README.md', message="Update README", committer=person, author=person, content=readme+"foobar")
res.content.size78
readme = (await api.get_content('README.md')).decode()
assert 'foobar' in readme
await api.update_contents('README.md', "Revert README", committer=person, author=person, content=readme[:-6]);api = GhApi(token=token)# Pinned to a fixed commit (not "main") so the file count below stays deterministic as fastcore evolves
owner, repo, branch = "AnswerDotAI", "fastcore", "6c22f5d5a79cb8c9edbd51847d2e7b56c92727a3"Repo files can be filtered using fnmatch Unix shell-style wildcards.
_find_matches('README.md', ['*.py', '*test_*', '*/test*/*', '*.md', 'README.md'])['*.md', 'README.md']
The include/exclude logic follows the rsync/grep model: a file must match at least one include pattern (if specified), AND must not match any exclude pattern. Exclude always wins—there’s no ambiguity. This is simpler and more predictable than gitignore-style ordering rules. Additionally, LLMs are already familiar with this common pattern from tools like rg and rsync, making it natural to use when this function is provided as an AI tool.
With rsync/grep style, exclude always wins. To get “all .md except README.md”, you’d include README.md explicitly in your results separately.
assert not _include('README.md', ['README.md'], ['*.md']) # exclude wins
assert not _include('CONTRIBUTING.md', ['README.md'], ['*.md'])Include all .py files except for tests
assert not _include('examples/test_fastcore2.py', ['*.py'], ['*test_*', '*/test*/*'])
assert not _include('examples/tests/some_test.py', ['*.py'], ['*test_*', '*/tests/*'])
assert not _include('examples/test/some_test.py', ['*.py'], ['*test_*', '*/test/*'])assert _include('cool/module.py', ['*.py'], ['setup.py'])
assert not _include('cool/_modidx', ['*.py'], ['*/_modidx'])
assert not _include('setup.py', ['*.py'], ['setup.py'])test_repo_files = ['README.md', 'CONTRIBUTING.md', 'dir/MARKDOWN.md', 'tests/file.py', # chkstyle: ignore-node
'module/file.py', 'module/app/file.py', 'nbs/00.ipynb', 'file2.py',
'.gitignore', 'module/.dotfile', '_hidden.py', 'module/_hidden.py']Here is an example where we filter to include the README, all python files except for the ones under tests directory, include all notebooks, and exclude all files starting with an underscore.
inc,exc = ['README.md', '*.py', '*.ipynb'], ['tests/*.py', '_*', '*/_*']
[fn for fn in test_repo_files if _include(fn,inc,exc)]['README.md',
'module/file.py',
'module/app/file.py',
'nbs/00.ipynb',
'file2.py']
Let’s exclude files starting with test_ and setup.py too.
exc += ['*test_*.py', '*/*test*.py', 'setup.py']
exc['tests/*.py', '_*', '*/_*', '*test_*.py', '*/*test*.py', 'setup.py']
A function to get repo files with optional filtering
GhApi.get_repo_files
async def get_repo_files(
owner, repo, branch:str='main', inc:NoneType=None, exc:NoneType=None
):Get all file items of a repo, optionally filtered.
The list of files that are kept based on the filtering logic:
repo_files = await api.get_repo_files(owner, repo, branch, inc=inc, exc=exc)
test_eq(len(repo_files), 44)repo_files.attrgot("path")['README.md', 'fastcore/all.py', 'fastcore/ansi.py', 'fastcore/basics.py', 'fastcore/dispatch.py', 'fastcore/docments.py', 'fastcore/docscrape.py', 'fastcore/foundation.py', 'fastcore/imghdr.py', 'fastcore/imports.py', 'fastcore/meta.py', 'fastcore/nb_imports.py', 'fastcore/nbio.py', 'fastcore/net.py', 'fastcore/parallel.py', 'fastcore/py2pyi.py', 'fastcore/script.py', 'fastcore/shutil.py', 'fastcore/style.py', 'fastcore/tools.py', 'fastcore/transform.py', 'fastcore/utils.py', 'fastcore/xdg.py', 'fastcore/xml.py', 'fastcore/xtras.py', 'nbs/000_tour.ipynb', 'nbs/00_test.ipynb', 'nbs/01_basics.ipynb', 'nbs/02_foundation.ipynb', 'nbs/03_xtras.ipynb', 'nbs/03a_parallel.ipynb', 'nbs/03b_net.ipynb', 'nbs/04_docments.ipynb', 'nbs/05_meta.ipynb', 'nbs/06_script.ipynb', 'nbs/07_xdg.ipynb', 'nbs/08_style.ipynb', 'nbs/09_xml.ipynb', 'nbs/10_py2pyi.ipynb', 'nbs/11_external.ipynb', 'nbs/12_tools.ipynb', 'nbs/13_nbio.ipynb', 'nbs/index.ipynb', 'tests/minimal.ipynb']
GhApi.get_file_content
async def get_file_content(
path, owner, repo, branch:str='main'
):Call self as a function.
GhApi.get_repo_contents
async def get_repo_contents(
owner, repo, branch:str='main', n_workers:int=16, # Max concurrent downloads
inc:NoneType=None, exc:NoneType=None
):Get all file items of a repo, optionally filtered.
contents = await api.get_repo_contents(owner, repo, branch, inc=inc, exc=exc)
md = "\n\n".join(f"**[{o.path}]({o.html_url})**\n```{o.path.split('.')[-1]}\n{chr(10).join(o.content_decoded.split(chr(10))[:5])}\n```" for o in contents[:3])
display(Markdown(md))# Welcome to fastcore
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->from .imports import *
from .foundation import *
from .utils import *
from .parallel import *
from .net import *"Filters for processing ANSI colors."
# Copyright (c) IPython Development Team.
# Modifications by Jeremy Howard.GitHub Pages
GhApi.enable_pages
async def enable_pages(
branch:NoneType=None, path:str='/'
):Enable or update pages for a repo to point to a branch and path.
branch is set to the default branch if None. path must be /docs or /.
api = GhApi(owner='AnswerDotAI', repo='ghapi-test', token=token)res = await api.enable_pages(branch='new-branch', path='/')
test_eq(res.source.branch, 'new-branch')
test_eq(res.source.path, '/')
await api.repos.delete_pages_site()
await api.delete_branch('new-branch')Issues and pull requests
Fetching everything needed to review an issue or PR – title, body, comments, and (for PRs) the diff, inline review comments, and review summaries – normally takes several separate calls, and the REST API doesn’t distinguish PRs from issues for some of them (a PR is an issue, so its general comments come from issues.list_comments, not pulls.*). read_issue bundles all of this into one call.
GhApi.read_issue
async def read_issue(
issue_number:int
):Fetch an issue or PR: title, body, comments, and (for PRs) diff, review comments, and reviews
api = GhApi(owner='fastai', repo='ghapi', token=token)
pr = await api.read_issue(205)
assert pr.is_pr
assert pr.diff.startswith('diff --git')
test_eq(len(pr.review_comments), 1)
test_eq(len(pr.reviews), 1)
iss = await api.read_issue(206)
assert not iss.is_pr
assert 'diff' in iss and iss.diff.startswith('diff --git')
assert '(issue)' in repr(iss)
r = repr(pr)
assert r.startswith(f'**{pr.title}** (PR)')
assert 'diff --git' not in r
assert '1 review comments' in r
prUse Content-Type to determine response parsing (PR)
Replaces the hardcoded _decode_response endpoint list with Content-Type based response handling.
JSON endpoints return AttrDict, text endpoints return str, binary endpoints return bytes — all determined by the response Content-Type header, not a maintained list of paths.
Supersedes #204.
diff: 4 files, 190 lines (see .diff)
0 comments; 1 review comments; 1 commented
read_pr
async def read_pr(
pr_number:int | str, # Issue/PR number, or GitHub issue/PR URL
owner:str=None, # Owner (not needed if URL passed)
repo:str=None, # Repo (not needed if URL passed)
folder:str='', # For diffs, limit to only files in `folder`
replies:bool=False, # Include comments, review comments, and reviews?
):Fetch a GitHub PR or issue as one markdown string: title, body, diff (if any), and optionally replies
While read_issue returns structured data, read_pr formats the whole thing as a single LLM-ready markdown string: title, body, the diff reduced to just headers and changed lines, and (with replies=True) comments, inline review comments, and review verdicts. You can pass a number plus owner/repo, or just paste a full GitHub URL.
res = await read_pr('https://github.com/fastai/ghapi/pull/205', replies=True)
for s in ('# Use Content-Type', '## Diff', '## Review comments', '## Reviews'): assert s in res
res = await read_pr(206, 'fastai', 'ghapi')
assert '## Diff' in res
with expect_fail(ValueError): await read_pr(205)pr_file_diff is the better choice when you want the complete, untruncated patch for one specific file with addition/deletion counts. read_pr(folder=...) is better for getting a reduced overview of all changes in a subdirectory along with the PR context.
pr_file_diff
async def pr_file_diff(
pr_number:int | str, # Issue/PR number, or GitHub issue/PR URL
filename:str, # File to get the diff for
owner:str=None, # Owner (not needed if URL passed)
repo:str=None, # Repo (not needed if URL passed)
)->str:Get the untruncated patch/diff for a single file in a PR
res = await pr_file_diff('https://github.com/fastai/ghapi/pull/205', 'ghapi/core.py')
for s in ('## ghapi/core.py (+9 -18)', '```diff'): assert s in resThe REST API bypasses issue templates entirely (they’re a web-UI feature), so a programmatically-created issue can easily ignore a form the repo requires, and maintainers will bounce it. issue_template fetches a repo’s templates and parses yml issue forms into their section labels, falling back to the owner-level .github community-health repo when the repo has none.
GhApi.issue_template
async def issue_template():Parsed issue templates for the repo (or the owner’s .github repo as fallback); pass one to issue_body
For example, quarto-cli uses yml issue forms; each parsed template lists the ### section labels a compliant issue body needs:
qapi = GhApi(owner='quarto-dev', repo='quarto-cli', token=token)
tmpls = await qapi.issue_template()
bug = first(t for t in tmpls if 'bug' in t.name)
assert bug.sections and all(s.label for s in bug.sections)
test_eq(await GhApi(owner='fastai', repo='ghapi', token=token).issue_template(), [])
[s.label for s in bug.sections]['I have:',
'Bug description',
'Steps to reproduce',
'Actual behavior',
'Expected behavior',
'Your environment',
'Quarto check output']
issue_body
def issue_body(
tmpl, sections
):Build an issue body following form tmpl from issue_template: ### <label> headings in template order. sections maps label to content (for checkbox sections: list of checked options, or True for all)
issue_body then turns a {label: content} dict into the body GitHub’s own web form would produce, checking required sections and unknown labels so a non-compliant issue fails before it reaches the tracker:
tmpl = _parse_tmpl('bug_report.yml', """
name: Bug report
description: Report an error
body:
- type: markdown
attributes:
value: Welcome!
- type: checkboxes
attributes:
label: "I have:"
options:
- label: searched the issue tracker
- label: read the docs
- type: textarea
attributes:
label: Bug description
validations:
required: true
""")
test_eq([s.label for s in tmpl.sections], ['I have:', 'Bug description'])
body = issue_body(tmpl, {'I have:': True, 'Bug description': 'It breaks.'})
test_eq(body, '### I have:\n\n- [x] searched the issue tracker\n- [x] read the docs\n\n### Bug description\n\nIt breaks.')
body = issue_body(tmpl, {'I have:': ['read the docs'], 'Bug description': 'It breaks.'})
assert '- [ ] searched the issue tracker\n- [x] read the docs' in body
test_fail(lambda: issue_body(tmpl, {'I have:': True}), contains='required')
test_fail(lambda: issue_body(tmpl, {'Bug description': 'x', 'Wrong': 'y'}), contains='template')Check / CI status
GitHub Actions results are reported through the Checks API (checks.list_for_ref); other CI systems generally use the older Commit Status API (repos.get_combined_status_for_ref). Most repos only populate one or the other, so check_status merges both into a single result.
GhApi.pr_status
async def pr_status(
pull_number:int
):Combined status and check-run results for a PR’s head commit
GhApi.check_status
async def check_status(
ref:str
):Combined commit status and check-run results for ref (a SHA, branch, or tag)
sha = (await api.actions.list_workflow_runs_for_repo(per_page=1)).workflow_runs[0].head_sha
st = await api.check_status(sha)
assert 'state' in st and 'statuses' in st and 'check_runs' in st
assert len(st.check_runs) > 0
pr_st = await api.pr_status(205)
assert 'state' in pr_st and 'statuses' in pr_st and 'check_runs' in pr_st
test_eq(repr(pr_st), 'no check runs')
r = repr(st)
assert r.startswith('**')
assert all(l.startswith('- ') for l in r.splitlines()[2:])
assert 'html_url' not in r
stfailure
- build: failure