GhApi details

Detailed information on the GhApi API

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.


source

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


source

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.


source

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


source

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)

api = GhApi()

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'}

source

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')).ref
Quota 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:


source

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.codes_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_webhook

Create 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

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


source

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

source

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=None
GET 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.


source

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![image](puppy.jpg)", '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![image](https://gist.githubusercontent.com/jph00/929068e9fa15a998e3c58bbb54e1bd24/raw/c7f420c839f58c6ac0c05f1116317645d31d7e80/puppy.jpg)'

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"}, ...})

source

GhApi.update_gist

async def update_gist(
    gist_id:str, content:str
):

Update the first file in a gist with new content


source

GhApi.gist_file

def gist_file(
    gist_id:str
):

Get the first file from a gist; coro if async client


source

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


source

GhApi.delete_release

async def delete_release(
    release
):

Delete a release and its associated tag


source

GhApi.upload_file

async def upload_file(
    rel, fn
):

Upload fn to endpoint for release rel


source

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')

source

GhApi.delete_release

async def delete_release(
    release
):

Delete a release and its associated tag

Branches and tags


source

GhApi.list_tags

async def list_tags(
    prefix:str=''
):

List all tags, optionally filtered to those starting with prefix

With no prefix, all tags are listed.

test_eq(len(await api.list_tags()), 1)

Using the full tag name will return just that tag.

test_eq(len(await api.list_tags(rel.tag_name)), 1)

source

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)

source

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)

source

GhApi.delete_tag

async def delete_tag(
    tag:str
):

Delete a tag


source

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)

source

GhApi.get_branch

async def get_branch(
    branch:NoneType=None
):

Call self as a function.

Content (git files)


source

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'}

source

GhApi.get_content

async def get_content(
    path
):

Call self as a function.

readme = (await api.get_content('README.md')).decode()
assert 'ghapi' in readme

source

GhApi.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.


source

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())

source

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()

source

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.size
78
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


source

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']

source

GhApi.get_file_content

async def get_file_content(
    path, owner, repo, branch:str='main'
):

Call self as a function.


source

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))

README.md

# Welcome to fastcore


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

fastcore/all.py

from .imports import *
from .foundation import *
from .utils import *
from .parallel import *
from .net import *

fastcore/ansi.py

"Filters for processing ANSI colors."

# Copyright (c) IPython Development Team.
# Modifications by Jeremy Howard.

GitHub Pages


source

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.


source

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
pr

Use 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


source

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.


source

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 res

The 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.


source

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']

source

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.


source

GhApi.pr_status

async def pr_status(
    pull_number:int
):

Combined status and check-run results for a PR’s head commit


source

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
st

failure

  • build: failure