sema-github

0.1.0

GitHub REST API v3 client with pagination and typed errors

$ sema pkg install sema-github
Readme Versions 1 Dependencies 0

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, default 30000.

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

VersionSizePublished
0.1.0 9 KB 2026-07-07 21:28:03
No dependencies