Quickstart¶
Installation¶
Pick the extra matching the HTTP backend you want to run on — it pulls in
the corresponding extra of
action0-client (the stdlib
urllib and thread-pool backends need no extra):
uv add "action0-github-api[httpx]" # or [requests], [aiohttp], [urllib3], [twisted], [all]
The first request¶
A GitHubClient plus an operation is all
it takes. Without a token, public data works at 60 requests/hour:
from action0.client.backends.requests import RequestsBackend
from action0.github import GetRepo, GitHubClient
with RequestsBackend() as backend:
client = GitHubClient(backend)
repo = client.send(GetRepo(owner="python", repo="cpython")) # a Repo
print(repo.full_name, repo.language, repo.stargazers_count)
The same operation on asyncio — only the backend changes, and send()
now returns an awaitable (the type checker knows):
from action0.client.backends.httpx import AsyncHttpxBackend
from action0.github import GetRepo, GitHubClient
async with AsyncHttpxBackend() as backend:
client = GitHubClient(backend)
repo = await client.send(GetRepo(owner="python", repo="cpython")) # Awaitable[Repo]
— or on Twisted, as a Deferred[Repo]:
from action0.client.backends.twisted import TwistedBackend
from action0.github import GetRepo, GitHubClient
client = GitHubClient(TwistedBackend())
deferred = client.send(GetRepo(owner="python", repo="cpython")) # Deferred[Repo]
deferred.addCallback(lambda repo: print(repo.full_name))
examples/get_repo.py in the repository shows all three side by side and
a runnable, network-free demo.
Listing repositories¶
ListOrgRepos and
ListUserRepos show the query
side: every filter value is an enum — your IDE completes the legal values,
no GitHub docs lookup needed — and a None filter is simply not sent,
falling back to GitHub’s default:
from action0.github import ListOrgRepos, RepoSort, SortDirection
repos = client.send( # a Page[Repo] — sequence-like, iterate away
ListOrgRepos(org="python", sort=RepoSort.PUSHED, direction=SortDirection.DESC, per_page=10)
)
Pagination¶
Listings return a Page: it
behaves like the list of its items, and its next attribute is the
ready-to-send operation for the following page — present exactly when
the response’s Link header announced one, None on the last page.
Pagination is plain data, so it works identically in every execution
model:
page = client.send(ListOrgRepos(org="python")) # or await, or as a Deferred
while True:
for repo in page:
...
if page.next is None:
break
page = client.send(page.next)
The flattening helpers spare you the loop — one per execution model.
all_items (sync) and all_items_async fetch pages lazily as the
iteration proceeds; all_items_deferred gathers everything into one
list (a Deferred cannot stream). Each page is one API request — mind
the rate limit on huge listings:
from action0.github import all_items, all_items_async, all_items_deferred
for repo in all_items(client, ListOrgRepos(org="python")): # sync
...
async for repo in all_items_async(client, ListOrgRepos(org="python")): # asyncio
...
deferred = all_items_deferred(client, ListOrgRepos(org="python")) # Twisted: Deferred[list[Repo]]
Searching¶
SearchRepos takes GitHub’s
query syntax
and returns a SearchPage — a
Page plus the search envelope (total_count, incomplete_results).
It paginates like the listings (next, or the all_items helpers;
GitHub caps search results at 1000 items):
from action0.github import RepoSearchSort, SearchRepos
hits = client.send(SearchRepos(q="http client language:python", sort=RepoSearchSort.STARS))
print(hits.total_count, [r.full_name for r in hits])
SearchIssues and
SearchUsers work the
same way. Issue search returns pull requests too — filter with
is:issue/is:pr in the query or via is_pull_request after the
fact; user search hits carry only the embedded-user fields
(SimpleUser), so follow up with GetUser for a full profile:
from action0.github import IssueSearchSort, SearchIssues, SearchUsers, UserSearchSort
bugs = client.send(
SearchIssues(q="repo:python/cpython is:open label:bug", sort=IssueSearchSort.REACTIONS)
)
users = client.send(SearchUsers(q="fullname:Guido type:user", sort=UserSearchSort.FOLLOWERS))
Working with issues¶
ListIssues filters the same
way (note: GitHub returns pull requests here too — every pull request is
an issue — so filter via is_pull_request):
from action0.github import IssueStateFilter, ListIssues
issues = client.send(ListIssues(owner="python", repo="peps", state=IssueStateFilter.OPEN))
real_issues = [i for i in issues if not i.is_pull_request] # one Page — follow .next for more
CreateIssue is the first
write operation: its non-path fields become the JSON request body
(None fields are omitted), and it needs a token with write access:
from action0.github import CreateIssue
issue = client.send(CreateIssue(owner="octo", repo="demo", title="Found a bug", labels=["bug"]))
print(issue.number, issue.html_url) # the server-assigned number and URL
GetIssue fetches one
issue by number, and
UpdateIssue is the first
PATCH operation: only the fields you set are changed — a None field
is omitted from the body and leaves the issue untouched (so closing an
issue does not blank its title). Being non-idempotent, a PATCH is
never blindly repeated by the retry policy:
from action0.github import GetIssue, IssueState, IssueStateReason, UpdateIssue
issue = client.send(GetIssue(owner="octo", repo="demo", issue_number=1347))
issue = client.send(
UpdateIssue(
owner="octo",
repo="demo",
issue_number=1347,
state=IssueState.CLOSED,
state_reason=IssueStateReason.NOT_PLANNED,
)
)
print(issue.state) # IssueState.CLOSED
Comments work on issues and pull requests alike (a pull request’s conversation is its issue’s comment thread):
from action0.github import CreateIssueComment, ListIssueComments
comments = client.send(ListIssueComments(owner="octo", repo="demo", issue_number=1347))
print([c.user.login for c in comments if c.user]) # one Page — follow .next for more
comment = client.send(
CreateIssueComment(owner="octo", repo="demo", issue_number=1347, body="Fixed in v2.")
)
print(comment.html_url)
Editing and deleting address a comment by its id (repository-global —
no issue number in the path).
DeleteIssueComment is a
no-content operation: GitHub answers 204 and send yields plain
None. Locking a conversation works the same way:
from action0.github import DeleteIssueComment, LockIssue, LockReason, UpdateIssueComment
comment = client.send(
UpdateIssueComment(owner="octo", repo="demo", comment_id=comment.id, body="Fixed in v2.1.")
)
client.send(DeleteIssueComment(owner="octo", repo="demo", comment_id=comment.id)) # None
client.send(
LockIssue(owner="octo", repo="demo", issue_number=1347, lock_reason=LockReason.RESOLVED)
) # None; UnlockIssue reverses it
Labels and assignees can also be changed incrementally — unlike
UpdateIssue’s labels/assignees, which replace the whole set:
AddIssueLabels /
RemoveIssueLabel answer
with the resulting label set,
AddAssignees /
RemoveAssignees with the
updated issue (the removal is a DELETE carrying a JSON body — GitHub’s
design). ListRepoLabels
and ListMilestones
list what a repository has to offer — and an issue carries its
milestone as issue.milestone:
from action0.github import AddIssueLabels, ListMilestones, RemoveIssueLabel
labels = client.send(AddIssueLabels(owner="octo", repo="demo", issue_number=1347, labels=["ui"]))
labels = client.send(
RemoveIssueLabel(owner="octo", repo="demo", issue_number=1347, name="bug")
) # the remaining set
milestones = client.send(ListMilestones(owner="octo", repo="demo"))
print([m.title for m in milestones], [label.name for label in labels])
Both vocabularies are fully manageable too:
CreateLabel /
UpdateLabel (renaming
goes through new_name and cascades to every issue) /
DeleteLabel, and
CreateMilestone /
UpdateMilestone /
DeleteMilestone
(deleting a milestone leaves its issues in place, unassigned):
from datetime import datetime, timezone
from action0.github import CreateMilestone, IssueState, UpdateMilestone
milestone = client.send(
CreateMilestone(
owner="octo",
repo="demo",
title="v1.0",
due_on=datetime(2026, 10, 9, tzinfo=timezone.utc),
)
)
milestone = client.send(
UpdateMilestone(
owner="octo", repo="demo", milestone_number=milestone.number, state=IssueState.CLOSED
)
)
Pull requests¶
ListPulls lists a
repository’s pull requests, filterable by head and base branch.
GetPull fetches one —
including the merge/diff statistics the listings omit (mergeable,
commits, additions, …). A pull request is an issue, so state is
the same open/closed vocabulary — “merged” is not a state but the
derived is_merged (closed with a merged_at timestamp):
from action0.github import GetPull, ListPulls, PullStateFilter
pulls = client.send(ListPulls(owner="python", repo="peps", state=PullStateFilter.OPEN))
ready = [p for p in pulls if not p.draft] # one Page — follow .next for more
pull = client.send(GetPull(owner="python", repo="peps", pull_number=42))
print(pull.is_merged, pull.mergeable, pull.changed_files) # the stats only GetPull carries
CreatePull opens one —
head is the branch with the changes ("owner:branch" for a fork),
base the branch to merge into (needs a token with write access):
from action0.github import CreatePull
pull = client.send(
CreatePull(
owner="octo",
repo="demo",
title="Amazing new feature",
head="octocat:new-topic",
base="main",
draft=True,
)
)
print(pull.number, pull.html_url) # the server-assigned number and URL
UpdatePull edits with the
same PATCH semantics as UpdateIssue;
ListPullFiles and
ListPullCommits page
through the diff and the commits (the same shapes the commit endpoints
use). MergePull merges —
pass the sha guard and nothing pushed after your last look can slip
in (an unmergeable pull request raises APIError, GitHub answers
405/409):
from action0.github import MergeMethod, MergePull
result = client.send(
MergePull(
owner="octo",
repo="demo",
pull_number=42,
merge_method=MergeMethod.SQUASH,
sha=pull.head.sha, # only merge what was reviewed
)
)
print(result.merged, result.sha) # True, the merge commit
Reviews:
ListPullReviews lists
them, CreatePullReview
approves, requests changes or comments (the wire values are uppercase
here — GitHub’s one all-caps vocabulary), and
ListReviewComments
lists the line-anchored review comments. The conversation thread is
not here — that is ListIssueComments, because a pull request is an
issue:
from action0.github import CreatePullReview, ListPullReviews, ReviewEvent
reviews = client.send(ListPullReviews(owner="octo", repo="demo", pull_number=42))
review = client.send(
CreatePullReview(owner="octo", repo="demo", pull_number=42, event=ReviewEvent.APPROVE)
)
print(review.state) # ReviewState.APPROVED
Line comments come in two shapes:
CreateReviewComment for
one standalone comment, or a batch of
DraftReviewComment
entries submitted with a review — one review, one notification,
however many comments (side says which half of the diff:
RIGHT = the new code):
from action0.github import DraftReviewComment, ReviewSide
review = client.send(
CreatePullReview(
owner="octo",
repo="demo",
pull_number=42,
event=ReviewEvent.REQUEST_CHANGES,
body="Two problems inline.",
commit_id=pull.head.sha,
comments=[
DraftReviewComment(path="src/app.py", body="Off by one.", line=28),
DraftReviewComment(
path="src/app.py", body="Reads twice.", line=40, side=ReviewSide.RIGHT
),
],
)
)
And the review requests:
RequestReviewers asks
users or teams for a review,
RemoveRequestedReviewers
withdraws the ask — both answer with the pull request and its updated
requested_reviewers:
from action0.github import RequestReviewers
pull = client.send(
RequestReviewers(owner="octo", repo="demo", pull_number=42, reviewers=["octocat"])
)
print([user.login for user in pull.requested_reviewers])
Commits¶
ListCommits lists a
repository’s commits, newest first — filterable by starting ref, author,
time window and touched path (the latter is file_path here because
GitHub’s parameter name, path, is the operation’s path template; it is
sent as path on the wire). Each commit carries both the git-level
identities (git_author/git_committer — name, email, date as recorded
in the commit) and the GitHub accounts matched to them (author/
committer — None when the email maps to no account):
from datetime import datetime, timezone
from action0.github import ListCommits
commits = client.send(
ListCommits(
owner="python",
repo="peps",
file_path="pep-0008.txt",
since=datetime(2026, 1, 1, tzinfo=timezone.utc),
)
)
for c in commits: # one Page — follow .next for more
print(c.sha[:7], c.author.login if c.author else c.git_author.name if c.git_author else "?")
GetCommit fetches one
commit — by sha, branch or tag name — including the diff statistics and
files the listings omit:
from action0.github import GetCommit
commit = client.send(GetCommit(owner="python", repo="peps", ref="main"))
print(commit.additions, commit.deletions) # only GetCommit carries these
print([(f.status, f.filename) for f in commit.files or []])
CompareCommits compares
two refs — GitHub’s three-dot comparison, measuring head against the
merge base like git log base...head. The endpoint’s combined
{base}...{head} path segment stays two typed fields here, joined by
the path template:
from action0.github import CompareCommits
diff = client.send(CompareCommits(owner="octo", repo="demo", base="main", head="topic"))
print(diff.status, diff.ahead_by, diff.behind_by) # e.g. ComparisonStatus.AHEAD 2 0
print([f.filename for f in diff.files])
diff.commits is capped at 250 (total_commits has the real count) and
diff.files at 300 — for bigger ranges, list commits page by page via
ListCommits(sha="topic").
Repository contents¶
GetContent fetches a
file or lists a directory. GitHub answers with an object for a file
and an array for a directory, so the result is the union of the two —
dispatch with isinstance:
from action0.github import ContentFile, GetContent
content = client.send(GetContent(owner="octo", repo="demo", file_path="src/app.py"))
if isinstance(content, ContentFile):
print(content.text) # bytes arrive base64-encoded; .decoded / .text decode them
else:
print([entry.path for entry in content]) # a directory listing (no content inlined)
The field is file_path because GitHub’s parameter name, path, is
the operation’s own path template attribute; "" lists the repository
root, and ref reads from any branch, tag or sha. One size limit to
know: files between 1 and 100 MB arrive with encoding: "none" and no
inlined content — decoded raises ValueError then; fetch the bytes
via the entry’s download_url instead.
GetReadme finds the
README whatever it is called (README.md, README.rst, …):
from action0.github import GetReadme
readme = client.send(GetReadme(owner="python", repo="peps"))
print(readme.name, len(readme.text))
Writing goes through the same API — every call is one commit.
CreateOrUpdateFile
takes raw bytes; the base64 transport encoding is applied on
serialization. Create and update are the same endpoint, told apart by
sha: None creates (422 if the file exists), the file’s current blob
sha updates (409 if someone else wrote in between — optimistic locking
for free). DeleteFile
deletes, also as a commit:
from action0.github import CreateOrUpdateFile, DeleteFile
written = client.send(
CreateOrUpdateFile(
owner="octo",
repo="demo",
file_path="docs/note.md",
message="Add a note",
content=b"# Note\n", # raw bytes in, base64 on the wire
)
)
print(written.commit.sha, written.content.sha if written.content else None)
client.send(
DeleteFile(
owner="octo",
repo="demo",
file_path="docs/note.md",
message="Remove the note",
sha=written.content.sha if written.content else "",
)
)
Is this commit green?¶
Two APIs answer that, and both matter:
GetCombinedStatus for
the classic commit statuses (one rolled-up state over all contexts)
and ListCheckRunsForRef
for the Checks API — GitHub Actions reports there, not in the
statuses. The check-runs listing is the one whose payload is not a
bare array; GitHub’s {total_count, check_runs} envelope is unwrapped
into the usual page:
from action0.github import GetCombinedStatus, ListCheckRunsForRef, StatusState
combined = client.send(GetCombinedStatus(owner="octo", repo="demo", ref=pull.head.sha))
checks = client.send(ListCheckRunsForRef(owner="octo", repo="demo", ref=pull.head.sha))
green = combined.state == StatusState.SUCCESS and all(
run.conclusion == "success" for run in checks
)
Organizations¶
GetOrg fetches an
organization’s profile,
ListOrgMembers pages
through its members — the public ones, unless the token belongs to a
member:
from action0.github import GetOrg, ListOrgMembers, OrgMemberRole
org = client.send(GetOrg(org="python"))
admins = client.send(ListOrgMembers(org="python", role=OrgMemberRole.ADMIN))
print(org.name, org.public_repos, [member.login for member in admins])
Releases and asset downloads¶
ListReleases lists a
repository’s releases (drafts and prereleases included, as far as the
token may see them);
GetLatestRelease
fetches the most recently published full release (drafts and
prereleases never qualify) and
GetReleaseByTag the
release for a git tag:
from action0.github import GetLatestRelease, GetReleaseByTag, ListReleases
latest = client.send(GetLatestRelease(owner="octo", repo="demo"))
print(latest.tag_name, [a.name for a in latest.assets])
release = client.send(GetReleaseByTag(owner="octo", repo="demo", tag="v1.0.0"))
DownloadReleaseAsset
downloads an asset’s binary content — the one operation that is not
JSON: it returns the response body as a
BodyProducer. On a backend opened with
stream=True the body is never held in memory; each chunk is written
out as it arrives:
from action0.client.backends.requests import RequestsBackend
from action0.github import DownloadReleaseAsset, GitHubClient
with RequestsBackend(stream=True) as backend: # a second backend, just for downloads
download_client = GitHubClient(backend, token="ghp_...")
asset = latest.assets[0]
producer = download_client.send(
DownloadReleaseAsset(owner="octo", repo="demo", asset_id=asset.id)
)
with open(asset.name, "wb") as file:
for chunk in producer.chunks():
file.write(chunk)
On an async backend, iterate producer.achunks() with async for
instead. Two things to know: GitHub answers the download with a 302
redirect to a short-lived CDN URL, so the backend must follow
redirects (requests, aiohttp and urllib do by default, httpx needs
follow_redirects=True) — and keep the stream=True backend separate
from the one running JSON operations, whose load() reads the whole
body anyway (see the
action0-client streaming guide).
The write side:
CreateRelease creates
(a draft, if asked — and can have GitHub generate the notes),
UpdateRelease edits
with PATCH semantics — publishing a draft is just draft=False —
DeleteRelease deletes
(a 204 no-content operation; the git tag survives), and
GenerateReleaseNotes
returns the merged-PRs changelog without creating anything, for
editing before publication:
from action0.github import CreateRelease, GenerateReleaseNotes, UpdateRelease
notes = client.send(GenerateReleaseNotes(owner="octo", repo="demo", tag_name="v1.1.0"))
release = client.send(
CreateRelease(
owner="octo", repo="demo", tag_name="v1.1.0", name=notes.name, body=notes.body, draft=True
)
)
release = client.send(UpdateRelease(owner="octo", repo="demo", release_id=release.id, draft=False))
print(release.html_url)
UploadReleaseAsset
attaches a file to a release — the one operation that does not go to
api.github.com: GitHub takes uploads on a separate host, so send it
through a client pointed at
GITHUB_UPLOADS_URL
(same token, same backend if you like). The raw bytes are the request
body; a FileBody streams them from disk
instead of holding the file in memory:
from action0.github import GITHUB_UPLOADS_URL, GitHubClient, UploadReleaseAsset
from action0.req import FileBody
upload_client = GitHubClient(backend, token="ghp_...", base_url=GITHUB_UPLOADS_URL)
asset = upload_client.send(
UploadReleaseAsset(
owner="octo",
repo="demo",
release_id=release.id,
name="demo-1.1.0.tar.gz",
content_type="application/gzip",
data=FileBody("dist/demo-1.1.0.tar.gz"), # or plain bytes
)
)
print(asset.id, asset.browser_download_url)
Users¶
GetUser fetches a public
profile; GetAuthenticatedUser
the one behind the client’s token — both as the full
User model (a SimpleUser
subclass, so it fits wherever an embedded user is expected):
from action0.github import GetAuthenticatedUser, GetUser
user = client.send(GetUser(username="gvanrossum"))
me = client.send(GetAuthenticatedUser()) # requires a token
print(user.name, user.followers, me.login)
ListFollowers pages
through a user’s followers, and
ListUserOrgs through
their public organization memberships — the entries are
SimpleOrganization (the
membership payloads carry no profile fields, not even a web URL), so
follow up with GetOrg for the full profile:
from action0.github import ListFollowers, ListUserOrgs
followers = client.send(ListFollowers(username="gvanrossum"))
orgs = client.send(ListUserOrgs(username="gvanrossum"))
print(len(followers), [org.login for org in orgs])
Authentication¶
Pass a token — classic, fine-grained or app installation — and it is sent
as Authorization: Bearer on every request:
client = GitHubClient(backend, token="ghp_...")
For GitHub Enterprise Server, point base_url at the instance:
client = GitHubClient(backend, token="...", base_url="https://ghe.example.com/api/v3")
Every request also carries GitHub’s recommended headers by default:
Accept: application/vnd.github+json, X-GitHub-Api-Version: 2022-11-28
and a User-Agent (required by GitHub). They are gap-filling defaults —
anything the request sets itself wins.
Errors¶
Non-2xx responses raise APIError (with
.request and .response attached), transport failures raise
TransportError — see the
action0-client error guide.
Retries and rate limits¶
Wrap the backend in action0-client’s retrying wrapper (the variant matching your execution model) and hand it the GitHub-tuned policy:
from action0.client import RetryingSyncBackend
from action0.client.backends.requests import RequestsBackend
from action0.github import GitHubClient, GitHubRetryPolicy
backend = RetryingSyncBackend(RequestsBackend(), GitHubRetryPolicy())
client = GitHubClient(backend, token="ghp_...")
GitHubRetryPolicy keeps the base
behavior (transient 5xx/429, Retry-After honored, idempotent methods
only — a CreateIssue is never blindly repeated) and adds GitHub’s
rate-limit signals: a 403 is retried only when it actually is a rate
limit (Retry-After or x-ratelimit-remaining: 0), and an exhausted
primary window is waited out until x-ratelimit-reset — capped at
max_backoff (default 120s; raise it to sit out whole windows). Use
RetryingAsyncBackend / RetryingDeferredBackend for the other
execution models — the policy is the same.
The proactive complement is
GetRateLimit: the
current status of every rate limit window, and the call itself does
not count against any limit — check before a burst instead of
reacting to the 403:
from action0.github import GetRateLimit
limits = client.send(GetRateLimit())
print(limits.core.remaining, limits.core.reset) # e.g. 4999 2026-08-15 13:20:00+00:00
print(limits.search.remaining) # search has its own, much smaller window
Conditional requests — free revalidation¶
GitHub answers most GETs with an ETag; repeat the request with
If-None-Match and an unchanged resource comes back as 304 Not Modified — without counting against the primary rate limit.
ConditionalRequestsHook does
this transparently: it stores ETagged responses, attaches the validators,
and fills a 304 from the store, so your operations only ever see the 200.
It is a hook, not a backend wrapper — one instance drives sync, async and
Twisted backends alike:
from action0.client.backends.requests import RequestsBackend
from action0.github import ConditionalRequestsHook, GitHubClient
backend = RequestsBackend(hooks=[ConditionalRequestsHook()])
client = GitHubClient(backend, token="ghp_...")
repo = client.send(GetRepo(owner="python", repo="cpython")) # 200, stored
repo = client.send(GetRepo(owner="python", repo="cpython")) # 304 → served from the store
Entries vary on Accept and Authorization (a different token is a
different entry) and live in an in-memory LRU by default — pass any
CacheStore to share or persist them.
Combine with CachingSyncBackend and
hot data skips even the revalidation for the cache’s TTL.
Testing your code¶
No network needed: the stub backends of
action0.client.testing answer with canned responses and record
the requests — exact output shown:
from action0.client.testing import StubBackend
from action0.github import GetRepo, GitHubClient
from action0.req import Response
backend = StubBackend(
Response(
200,
body='{"id": 1, "name": "demo", '
'"full_name": "octo/demo", "owner": {"login": "octo", "id": 2, '
'"html_url": "https://github.com/octo", "type": "User"}, "private": false, '
'"html_url": "https://github.com/octo/demo", "default_branch": "main"}',
)
)
client = GitHubClient(backend, token="ghp_secret")
print(client.send(GetRepo(owner="octo", repo="demo")).full_name)
# octo/demo
print(backend.requests[0].url.as_str())
# https://api.github.com/repos/octo/demo