sema-github
0.1.0GitHub REST API v3 client with pagination and typed errors
sema-github
GitHub REST API v3 client with pagination and typed errors.
Install
sema pkg add sema-github
Authentication
The client needs a GitHub token. Create a fine-grained personal access token at
https://github.com/settings/tokens (read-only scopes are enough for the GET
wrappers), then export it:
export GITHUB_TOKEN=github_pat_...
(github/client) picks up GITHUB_TOKEN first, then GH_TOKEN (the variable
the gh CLI uses), and errors with a clear message if neither is set. You can
also pass a token explicitly — but keep tokens out of source files; read them
from the environment:
(github/client {:token (env "MY_CI_TOKEN")})
Quick start
(import "sema-github")
(define gh (github/client)) ; token from GITHUB_TOKEN / GH_TOKEN
(:login (github/user gh))
; => "octocat"
(define repo (github/repo gh "octocat/Hello-World"))
(:full_name repo) ; => "octocat/Hello-World"
(:stargazers_count repo) ; => 2711
(map :title (github/issues gh "octocat/Hello-World"
{:state "open" :per_page 5 :max-pages 1}))
; => ("Issue title one" "Issue title two" ...)
Repositories are always addressed as a single "owner/name" string. Anything
else (no slash, empty owner/name, extra segments) raises an error before any
network call.
API
| Function | Description |
|---|---|
(github/client opts?) |
Build a client map; token from opts or env |
(github/request client method path opts?) |
Raw request escape hatch |
(github/paginate client path opts?) |
GET all pages of a list endpoint |
(github/query-string params) |
Map → percent-encoded query string |
(github/parse-link-header header) |
Link header → {:next ... :last ...} |
(github/user client) |
The authenticated user |
(github/get-user client username) |
A user by username |
(github/repo client repo) |
A repository ("owner/name") |
(github/repos client owner opts?) |
An owner's repositories (paginated) |
(github/issues client repo opts?) |
A repository's issues (paginated) |
(github/issue client repo n) |
One issue by number |
(github/create-issue client repo fields) |
Create an issue (:title required) |
(github/comment! client repo n body) |
Comment on an issue or PR |
(github/close-issue! client repo n) |
Close an issue |
(github/pulls client repo opts?) |
A repository's pull requests (paginated) |
(github/pull client repo n) |
One pull request by number |
(github/releases client repo opts?) |
A repository's releases (paginated) |
(github/latest-release client repo) |
The latest published release |
(github/search-repos client q opts?) |
Search repositories (full result map) |
(github/search-issues client q opts?) |
Search issues/PRs (full result map) |
For the paginated wrappers, every key in opts becomes a query parameter
(e.g. :state, :labels, :per_page), except :max-pages, which caps how
many pages are fetched (default 10).
github/client
(github/client)
(github/client {:token "..." :base-url "https://api.github.com" :timeout 30000})
Builds a client — a plain map passed as the first argument to every other function. Options:
:token— API token; defaults to(env "GITHUB_TOKEN"), then(env "GH_TOKEN"). Errors mentioning both variables if none is found.:base-url— default"https://api.github.com"; set this for GitHub Enterprise ("https://ghe.example.com/api/v3"). A trailing slash is stripped.:timeout— request timeout in milliseconds, default30000.
Requests send Authorization: Bearer <token>,
Accept: application/vnd.github+json, X-GitHub-Api-Version: 2022-11-28, and
User-Agent: sema-github.
github/request
(github/request client "GET" "/repos/octocat/Hello-World/topics")
(github/request client "POST" "/repos/octocat/Hello-World/labels"
{:body {:name "bug" :color "d73a4a"}})
(github/request client "GET" "/repos/octocat/Hello-World/issues"
{:query {:state "closed" :per_page 50}})
The escape hatch for any endpoint the wrappers don't cover. path is an API
path or a full URL (as found in pagination links). Options: :query (map,
percent-encoded into the URL) and :body (map, JSON-encoded). Returns the
decoded JSON as keyword-keyed maps; an empty response body (e.g. 204 No Content) returns nil. Non-2xx responses raise — see
Error handling.
github/paginate
(github/paginate client "/repos/octocat/Hello-World/issues"
{:query {:per_page 100 :state "all"} :max-pages 5})
GETs a list endpoint and follows the Link header's rel="next" URL,
concatenating all pages into a single list. :max-pages (default 10) bounds
the walk so a huge resource can't loop forever; raise it deliberately when you
really want everything. The paginated wrappers (github/issues,
github/repos, github/pulls, github/releases) are built on this.
github/query-string / github/parse-link-header
The two pure building blocks are exported because they're useful on their own:
(github/query-string {:state "open" :per_page 30})
; => "per_page=30&state=open"
(github/query-string {:q "language:lisp stars:>10"})
; => "q=language%3Alisp%20stars%3A%3E10"
(github/parse-link-header
"<https://api.github.com/repositories/1/issues?page=2>; rel=\"next\"")
; => {:next "https://api.github.com/repositories/1/issues?page=2"}
github/query-string percent-encodes keys and values (keyword or string keys,
#t/#f become "true"/"false"), returns "" for nil/empty, and emits
keys in sorted order. github/parse-link-header returns {} for nil, empty,
or unparseable input.
Users
(github/user gh) ; => {:login "octocat" :id 583231 ...}
(github/get-user gh "torvalds") ; => {:login "torvalds" :name "Linus Torvalds" ...}
Repositories
(github/repo gh "octocat/Hello-World")
; => {:full_name "octocat/Hello-World" :stargazers_count 2711 ...}
(map :name (github/repos gh "octocat" {:sort "updated" :per_page 10 :max-pages 1}))
; => ("Hello-World" "Spoon-Knife" ...)
github/repos lists via /users/{owner}/repos, which returns a user's
repositories or an organization's public repositories.
Issues
;; List (paginated; query passthrough)
(github/issues gh "octocat/Hello-World" {:state "open" :labels "bug" :per_page 50})
;; One issue
(:title (github/issue gh "octocat/Hello-World" 42))
;; Create
(define created
(github/create-issue gh "my-org/my-repo"
{:title "Flaky test in CI"
:body "Seen on main since 2026-07-01."
:labels ["bug" "ci"]}))
(:number created) ; => 137
;; Comment and close
(github/comment! gh "my-org/my-repo" 137 "Fixed in #138.")
(github/close-issue! gh "my-org/my-repo" 137)
Note: GitHub's issues endpoints include pull requests; PRs carry a
:pull_request key you can filter on.
Pull requests
(github/pulls gh "octocat/Hello-World" {:state "open" :base "master" :per_page 20})
(:title (github/pull gh "octocat/Hello-World" 1))
Releases
(github/releases gh "sema-lisp/sema" {:per_page 10 :max-pages 1})
(:tag_name (github/latest-release gh "sema-lisp/sema")) ; => "v1.28.1"
Search
(define found (github/search-repos gh "lisp in:name language:rust" {:sort "stars" :per_page 5}))
(:total_count found) ; => 42
(map :full_name (:items found))
(github/search-issues gh "repo:octocat/Hello-World is:open label:bug")
Search endpoints return the full result map — {:total_count N :incomplete_results bool :items (...)} — rather than just the items, so you
keep the count. Page manually with :page/:per_page opts (GitHub caps search
at 1000 results).
Pagination
List endpoints return at most per_page items (GitHub default 30, max 100)
plus a Link response header pointing at the next page. The paginated
functions handle that for you:
;; Up to 10 pages (the default) of 100 issues each:
(github/issues gh "big-org/busy-repo" {:state "all" :per_page 100})
;; Everything, no matter how many pages:
(github/issues gh "big-org/busy-repo" {:per_page 100 :max-pages 1000000})
;; Exactly one page:
(github/issues gh "big-org/busy-repo" {:per_page 30 :max-pages 1})
The :max-pages 10 default exists so an innocent call against a huge resource
doesn't turn into hundreds of requests; set it explicitly when you need more.
Error handling
Any non-2xx response raises an error whose message contains the method, path,
status, and the API's own message field (or a body snippet if the body isn't
JSON):
github: GET /repos/octocat/nope → 404: Not Found
github: POST /repos/o/r/issues → 422: Validation Failed
Catch them with try/catch; network-level failures (DNS, refused
connections, timeouts) raise too:
(try
(github/repo gh "octocat/does-not-exist")
(catch e
(println "lookup failed:" (:message e))
nil))
Caller mistakes fail fast, before any network I/O: a malformed repo string
("noslash", "a/b/c", "owner/") or a github/create-issue call without
:title raises immediately.
Testing
sema pkg add sema-test # once
sema tests.sema
The suite runs fully offline. When GITHUB_TOKEN is set it additionally runs
live tests against api.github.com (authenticated user, public repo reads,
two-page pagination, 404 error shape).
License
MIT
| Version | Size | Published |
|---|---|---|
| 0.1.0 | 9 KB | 2026-07-07 21:28:03 |