@osdlc/typed-gh
v0.17.1
Published
A typed GitHub client for TypeScript over the gh CLI. A total PullRequest/Review/CheckRun model, pure argv builders and parsers, and a tri-modal client — queries return parsed values, mutations throw, raw() is the escape hatch. Uses gh's own credentials;
Maintainers
Readme
@osdlc/typed-gh
gh --json returns loose JSON, and a failed call returns an exit code. Code that reads a field it
never asked for, or treats a failed call as "nothing found", goes wrong without a sound. This
package wraps gh in typed calls. They return a complete model and tell "we could not ask" apart
from "there is nothing".
It is for TypeScript code that runs where gh is installed and signed in: developer tools, CI
scripts, release bots and coding agents. It is the GitHub-side companion of
@osdlc/typed-git.
Install
npm install @osdlc/typed-ghpnpm add @osdlc/typed-gh and yarn add @osdlc/typed-gh work too.
It needs Node ^22.22.2 || ^24.15.0 || >=26.0.0, the range of its dependency @osdlc/typed-git. Bun also works, through the compiled dist/. It also needs the gh CLI on PATH, signed in with gh auth login.
It is supported on macOS and Linux. Windows is not supported yet. It will be once a Windows CI job
runs the tests.
import { Gh, type PrListOptions, type PullRequest } from '@osdlc/typed-gh'
const gh = new Gh(process.cwd())
const filter: PrListOptions = { state: 'open', base: 'main' }
const prs: PullRequest[] = gh.prList(filter)?.rows ?? []The examples folder in the repository runs the same calls as tests.
When not to use it
ghis not installed. In a browser or a serverless function, use an HTTP client such as Octokit.- You sign in with a token, a GitHub App or OAuth. This package reads no token.
- You make many calls or poll in a loop. Each call starts a
ghprocess. - You need the whole API typed. This package covers the calls listed below.
api()is a GET andgraphql()is a query. Anything else goes throughraw.
How it compares
Checked against the npm registry on 2026-10-19.
| Package | Version | What it is, per the registry | Pick it instead when |
| --- | --- | --- | --- |
| @octokit/rest | 22.0.1 | GitHub REST API client for Node.js | you have a token, or no gh |
| octokit | 5.0.5 | GitHub SDK for browsers, Node.js and Deno | your code runs in a browser |
| @octokit/graphql | 9.0.5 | GitHub GraphQL API client | you write your own queries |
| @actions/github | 9.1.1 | Actions github lib | the code runs in a workflow |
| @procedures/gh | 0.13.2 | The GitHub CLI as procedures, no shell | you use @procedures |
| @timmo001/effect-gh | 0.7.0 | An Effect v4 SDK for the GitHub CLI | you build on Effect |
The last two also wrap gh. @procedures/gh depends on zod and two @procedures packages.
@timmo001/effect-gh needs effect as a peer. This package depends on @osdlc/command-seam
and @osdlc/typed-git.
Authentication is gh's
No token is read, stored, or forwarded. That is why this package runs the CLI
instead of calling the REST API. The credential is already in the user's
keychain. A library that reads GITHUB_TOKEN can leak it.
A total model
gh --json returns only the fields you asked for. So every field on
PullRequest has a "not known" value, and none of them is undefined:
const pr = parsePullRequest({ number: 7 })
pr?.mergeable // 'UNKNOWN', not undefined
pr?.reviewDecision // null
pr?.checks // []A caller cannot tell "absent" from "unknowable", so both get one value. A field you never requested is never read as a fact.
Most enums are open. They are unions ending in (string & {}). GitHub adds
members over time. A client that crashes on a new one is worse than a client
that passes it through.
Two enums are closed: MergeableState (MERGEABLE, CONFLICTING, UNKNOWN)
and ReviewDecision (APPROVED, CHANGES_REQUESTED, REVIEW_REQUIRED,
null). A value GitHub adds to either reads as UNKNOWN or null. A check
that decides whether to merge can then match every member.
Three ways to call, as in typed-git, so a failure is never mistaken for an answer
| | a value | on failure |
| --- | --- | --- |
| gh.query() (the method that throws) | the value | throws a FailureError carrying the classified GhFailure (kind unreadable when gh succeeded but its output cannot be read) |
| gh.try.query() | { ok: true, value } | { ok: false, error: GhFailure } |
| gh.soft.query() | the value | null (queries only. A mutation has no soft form.) |
| raw(argv) | the raw CommandResult. You decide. | the same |
A query returns a list or a value, never an empty list on failure.
prList() returning { rows: [], truncated: false } means this repository
has no open PRs. A failed call throws, or is { ok: false } through try, or
null through soft. That means we could not ask. If the two were the same, an
expired credential would read as "nothing to do".
A missing item is an answer, not a failure. A query for one thing (prView, issueView,
repoView, and prFind and openPrFor, which use them) returns null in all
three call styles when GitHub says there is no such thing: "Could not resolve to a
PullRequest" or "no pull requests found for branch" for prView, "Could not
resolve to an Issue" for issueView, "Could not resolve to a Repository" for
repoView. gh.try.prView(99) is { ok: true, value: null }. Every other
failure is a failure, including a wrong repository. prView(7) against a
mistyped --repo is a not-found failure, not an absent PR. The check reads
stderr and stdout, as the classifier does.
Setup checks return their failure as a value. installed(), authenticated(),
authStatus(), ready() and api() return their failure as part of their
value, from the plain method and from try. They have no soft form:
gh.soft.api(...) throws a TypeError. For them, "gh is not installed" is the
answer to the question they ask.
import { describe } from '@osdlc/result'
const result = gh.try.prList({ limit: 50 })
if (!result.ok) {
console.error(describe(result.error)) // e.g. "gh is not signed in to GitHub. Run `gh auth login`."
}GhFailure
The error of try is a GhFailure, a Failure from @osdlc/result. It has a kind
to branch on, a one-line message and a hint for a person (describe(error)
joins them), gh's own output in detail, the argv and exitCode, and the
CommandFailed it was read from in cause. The methods that throw throw the same
failure, wrapped in a FailureError (error.failure). So a CLI that reports a
thrown failure, such as runMain from @osdlc/cli-tool, shows "gh is not
signed in" for a write and exits with the same code as for a read.
| kind | When (gh's stderr) | message / hint |
| --- | --- | --- |
| not-installed | gh is not on PATH | `gh` is not installed or not on PATH. / Install it from https://cli.github.com, then run `gh auth login`. |
| not-authenticated | gh auth login, HTTP 401, HTTP 403 (unless a rate limit), Bad credentials, not logged in | gh is not signed in to GitHub. / Run `gh auth login`. |
| network | error connecting to, Could not resolve host, no such host, dial tcp, i/o timeout | GitHub could not be reached. / Check your network connection. |
| not-found | Could not resolve to a …, HTTP 404, no pull requests found for branch | GitHub has no such pull request, issue or repository, or you cannot see it. / Check the number or name, and that your account can see the repository. |
| rate-limited | API rate limit exceeded, secondary rate limit, HTTP 429 | GitHub's API rate limit was reached. / Wait a few minutes and try again. |
| unreadable | gh exited 0 but its output could not be read completely | gh answered, but its output could not be read completely. (no hint, no cause) |
| not-executable, bad-cwd, timeout, output-cap, signal, aborted, spawn | as in @osdlc/command-seam | command-seam's message, no hint |
| exit | any other non-zero exit | gh's first stderr line |
The patterns are gh's English messages. Anything else is a plain exit,
never a wrong kind. Rate limiting is checked before authentication, then
network, then not-found. classifyGhFailure(commandFailed) makes the same
classification for a CommandFailed you caught yourself.
A list says when its limit cut it short
gh --limit cuts a list without saying so. Every list query here asks for
one row more than limit and returns { rows, truncated }. That is at most limit
rows, and truncated: true when there were more.
const page: ListResult<PullRequest> = gh.prList({ limit: 50 })
if (page.truncated) console.warn('more than 50 open PRs; raise the limit')prList, issueList, labelList, repoList and searchPrs all work this
way. A limit that is not a positive integer throws RangeError.
GitHub search returns at most 1000 results, whatever the limit. gh uses
search for searchPrs, for prList with a non-empty search or an author,
and for issueList with a label. Those report truncated: true when 1000
rows come back: there may be more that search would not return. Narrow the
query to see them. searchPrs asks gh for at most 1000 rows, since gh refuses
a larger search limit.
A repository without a clone
By default a client talks to the repository of the clone at its directory. Name a repository, a host, or both, and every call targets them instead. No clone is needed:
const acme = new Gh({ repo: 'acme/widgets', hostname: 'github.acme.com' })
acme.prView(42)
acme.withRepo('acme/gadgets').prList()
acme.api('repos/{owner}/{repo}/branches')A client has two forms. new Gh(cwd, options?) runs every call in cwd, and
without repo gh reads the repository from the clone there.
new Gh({ repo, hostname?, runner? }) names a repository and no directory.
repo is required in that form: new Gh({}) and new Gh({ hostname }) do
not typecheck and throw TypeError, so a client that reads a clone always
says which one. Its calls run in process.cwd(), read when each call is made.
No typed verb depends on that directory, but raw and rawChecked add no
--repo, and gh fills an api endpoint's {branch} from that directory's
checkout, so add --repo to a raw call or use .at(dir). gh.cwd is that
directory, a string in both forms. AsyncGh takes the same two forms.
const local = new Gh('/path/to/clone') // reads acme/widgets from the clone's remote
const remote = new Gh({ repo: 'acme/widgets' }) // needs no clone
remote.at('/path/to/clone') // a client rooted at the clone, still naming acme/widgets
local.searchPrs('is:open author:@me') // a host-only call works on either formThe target goes into the argv, so a failure's argv shows which repository a
call hit. pr, issue and label calls get --repo github.acme.com/acme/widgets.
api calls get --hostname and a filled {owner}/{repo}. authStatus
asks about that host. repoList and searchPrs have no host flag in gh, so
they get GH_HOST in the environment. withRepo and at keep the runner and
the rest of the target. at on a client with no directory gives one rooted
at that directory. withRepo keeps the client's form.
hostname is any GitHub host: github.com, a GitHub Enterprise Server host,
or a <tenant>.ghe.com host. An empty string means no host, so a value read
from config can be passed as it is. github.com is not dropped: it goes to gh
as --repo github.com/owner/name, --hostname or GH_HOST, which keeps the
call on github.com even when the environment sets another default host. gh
supports only GitHub's API, so Forgejo, Gitea and GitLab are not supported. A repo that is not owner/name, or a
hostname that is not a bare host, throws RangeError.
Finding a pull request
prView takes a number, a branch or a URL. A URL names its own repository
and host, so the client's target is not added to it.
gh.prView(42)
gh.prView('feature/login')
gh.prView('https://github.acme.com/acme/widgets/pull/42')prFind takes what a person typed and runs the lookup it needs:
const pr: PullRequest | null = gh.prFind(process.argv[2] ?? '')It reads the text with parsePrRef, which starts no process. 42 and #42
are numbers, owner/name#42 is a number in that repository, a URL is a URL,
and any other valid branch name is a branch. Bare digits are tried as a PR
number first and then as a branch, since a branch can be named 42. A branch
gives its open PR, or else its newest closed or merged one, so a tool run
after the merge still finds it.
prList({ head }) matches the head branch exactly: task/foo never returns
task/foo-2. Two forks can share a branch name. Ask for
headRepositoryOwner to tell them apart.
prList({ headPrefix: 'task/' }) lists every PR whose head branch starts
with task/. It sends a head:task/ search term, with your own search
grouped after it in parentheses, and filters the rows on headRefName. It
cannot be combined with head. Because it goes through GitHub search, a
just-pushed branch can take a moment to show up, and limit is at most 999,
as search returns no more than 1000 results.
Two payload differences it handles
A bot's login is spelled three ways. GraphQL, and comment and review authors
in gh pr view --json, give the bare login (github-actions). gh's --json
export of a pull request's author prefixes it (app/github-actions). REST
(gh api) and gh search suffix it (github-actions[bot]). Comparing one with another
never matches, and no error shows it. Then a bot's comments are treated as
human review feedback. Logins are cut to the bare form once, when a payload is
parsed, and the prefix or suffix marks the author as a bot.
gh's --author filter does not take the bare form of a bot's login: pass
app/<login> or <login>[bot] to prList({ author }) to list a bot's pull
requests.
statusCheckRollup holds two node types. A modern CheckRun carries
name/status/conclusion. A legacy StatusContext carries
context/state. Both map onto one verdict axis:
const checks: CheckRun[] = parsePullRequest(payload)?.checks ?? []
checks.every((c) => c.verdict === 'success')
anyCheckFailed(checks)A check still running is pending, never success. If "no failure yet" read
as green, a red build could merge.
The merge state is not stable on a cold query
GitHub computes mergeability only when asked. The first query starts the
computation and answers UNKNOWN until it finishes. A caller that trusts that
first answer misjudges every PR whose computation is slow.
const pr = gh.prViewSettled(7, { attempts: 3 }) // 3 is the defaultUNKNOWN can still be the final answer, so callers must handle it.
mergeStateStatus: "BEHIND" appears only when branch protection requires an
up-to-date branch. Without that rule, a branch a hundred commits behind still
reports CLEAN. So BEHIND is a hint that a branch is out of date, not a
measure of it. Compute that from the commit graph (typed-git's aheadBehind).
Changing pull requests
Mutations throw the classified failure (a FailureError) when gh fails. gh.try.editPr(…) returns the failure as a Result instead; a mutation has no soft form.
const url = gh.createPr(options)
gh.editPr(9, { title: 'Add retries to fetch' })
gh.editPrBody(9, 'Retries the fetch three times.')
const comment = gh.prComment(9, 'Rebased onto main.')
gh.prUpdateBranch(9, 'rebase')
gh.prSetDraft(9, false)createPr and prComment return the URL gh printed, or '' when it printed none.
prSetDraft(n, false) marks a draft PR ready for review. prSetDraft(n, true) turns a PR back into
a draft.
Merging
prMerge takes the head commit you checked. GitHub refuses the merge if the head has moved since.
So a push that lands between your check and the merge cannot be merged without review. The branch
is not deleted. That is up to you.
const method: PrMergeMethod = 'squash'
gh.prMerge(9, { method, headSha: 'abc1234' })prReviewThreads(n) reads the review threads, so you can look for open ones before you merge. It
throws when it could not read all of them (a failed call, an unexpected payload, or more
than 100 threads; soft.prReviewThreads is null). That is not the same as "no threads".
const threads: ReviewThread[] = gh.prReviewThreads(9)
threads.filter((t) => !t.isResolved)Issues and labels
const issue: IssueRow | null = gh.issueView(7) // null: there is no issue 7
const triage: ListResult<IssueRow> = gh.issueList({ label: 'status:triage', limit: 50 })
gh.setIssueLabel(7, 'status:triage', 'status:define')
const labels: ListResult<Label> = gh.labelList({ limit: 100 })
gh.createLabel({ name: 'status:triage', color: '1f6feb', description: 'Triage stage' })
gh.editLabel('status:triage', { description: 'Waiting for triage' })issueList returns open issues, up to limit (default 200). labelList returns up to limit
labels (default 1000. gh's own default is 30). setIssueLabel removes one label and adds
another in one gh issue edit call. createLabel passes --force, so it is safe to repeat. An
existing label gets the color and description you pass.
Diffs, reviews and comments
const diff = gh.prDiff(9)
const review = gh.createReview(9, {
commitId: 'abc1234',
body: 'One question.',
comments: [{ path: 'src/fetch.ts', line: 12, body: 'Retry here too?' }],
})
gh.issueCommentEdit('4204154439', 'Updated: CI is green.')prDiff returns the unified diff and throws the classified failure when gh cannot produce it.
createReview is experimental. It posts a review of kind COMMENT. It cannot approve or request
changes. Each inline comment sits on one line of the new side of the diff. It returns the review's
URL.
issueCommentEdit is experimental. It takes the comment's numeric REST id, as text. That is the id from
gh api repos/{owner}/{repo}/issues/<n>/comments. The Comment.id that prView returns is a
GraphQL node id, which this REST path does not accept. An id that is not all digits throws
RangeError before gh runs (AsyncGh returns a rejected Promise).
Verbs and parsers, for running calls yourself
The verb table and every parser have no side effects. So code that runs many
calls at once can run them through its own AsyncCommandRunner and reuse the
parsing:
import { prList } from '@osdlc/typed-gh'
const verb = prList({ state: 'open', limit: 10 })
const result = await runner.run(verb.argv, { cwd: '/repo', timeoutMs: 5_000 })
const prs = verb.parse(result)That is only safe for a verb with no env or input. repoList and searchPrs send the target
host as GH_HOST (env), and createReview sends its payload on stdin (input). A runner call
that passes only verb.argv drops both, with no error. The query goes to github.com, and the review
is never sent. runVerb (and runVerbAsync, for an AsyncCommandRunner) run a verb the way Gh and
AsyncGh do, with cwd, env, input, timeoutMs and the checked contract applied, and return
the parsed value or throw the classified failure:
import { repoList, runVerbAsync } from '@osdlc/typed-gh'
const repos = await runVerbAsync(repoList('acme', {}, { hostname: 'ghe.example.com' }), runner, {
cwd: '/repo',
timeoutMs: 5_000,
})runVerb takes a CommandRunner and runVerbAsync an AsyncCommandRunner. A plain
(binary, argv, opts) => result function is not a runner; wrap it yourself as
{ run: (argv, opts) => fn('gh', argv, opts) }.
raw(argv) and rawChecked(argv) are Gh.raw and Gh.rawChecked as verbs, for a call the verb
table does not have. Through runVerb, raw returns the CommandResult whatever the exit code,
and rawChecked returns it when gh exits 0 with its whole output and throws the classified
failure otherwise. Like the methods, they add no --repo and no host.
Keeping a bundle small
A tool that is bundled and makes a few gh calls should import the verbs it calls and run them
with runVerb, and not construct a Gh. A bundler drops a function that nothing imports, but it
cannot drop a method a class never calls. So one new Gh() keeps every verb and parser in the
bundle, and the verb path keeps only the verbs you import:
import { defaultRunnerFactory, prDiff, rawChecked, runVerb } from '@osdlc/typed-gh'
const runner = defaultRunnerFactory('gh')
const opts = { cwd: '/repo' }
const diff = runVerb(prDiff(42), runner, opts)
const files = runVerb(rawChecked(['api', 'repos/{owner}/{repo}/pulls/42/files']), runner, opts)Built with bun build --minify, those two calls take 14.4 KB through runVerb and 68.0 KB through
Gh (7.6 KB of each is command-seam's runner). The verb path gives up try, soft and the
helpers that make several calls (prFind, openPrFor, prViewSettled): catch the FailureError
instead. A tool that already uses many methods loses little by constructing Gh. The measurements
and the reasons Gh is not split into entry points are in
docs/planning/decisions/bundle-size.md.
AsyncGh
The awaitable form of Gh, with the same methods, for code that runs many calls at once and must
not block the event loop. It has the same three call styles, as Promises. The plain method rejects
with the classified failure. try resolves to a Result<T, GhFailure>. soft resolves null for
a failed query. raw and rawChecked run any gh call. Both clients use the same verb table, so
they cannot drift apart. It takes an AsyncCommandRunner and defaults to
spawnRunnerAsync("gh", { absolutePathOnly: true }).
import { AsyncGh } from '@osdlc/typed-gh'
const gh = new AsyncGh('/repo')
const prs = await Promise.all(branches.map((b) => gh.openPrFor(b)))
const body = await gh.prBody(42) // string; rejects when gh fails
await gh.editPrBody(42, `${body}\n\nfooter`)
await gh.prComment(42, 'rebased') // the comment's URL, or ''
await gh.prUpdateBranch(42, 'rebase')These methods take AsyncCallOptions: raw, rawChecked, prList, prView, prFind,
openPrFor, prViewSettled, prBody, api, graphql, labelList, issueList, issueView,
repoList, repoView and searchPrs. The options are timeoutMs and an AbortSignal in
signal.
api(endpoint, opts) (on Gh and AsyncGh) is a REST GET through gh api.
It never throws. The result is { ok, status, etag, body, message, malformed, stderr, error? }.
On failure body still carries GitHub's error object and message its text. status is read only when headers were requested (etag or includeHeaders).
stderr is gh's own. error holds the runner's message when gh never ran or
was stopped (a missing binary, a timeout).
Pass etag for a conditional request (If-None-Match. A 304 comes back as
status: 304, body: null). Or pass includeHeaders: true to read status and ETag
without one. If gh prints more than one header block, the last one is read. {owner} and
{repo} in the endpoint come from the client's repo, or are filled in by gh from the clone.
Reading every page
api with paginate: true fetches every page and, when each page is an array, joins them into one
body:
const branches = gh.api('repos/{owner}/{repo}/branches', { paginate: true }).bodygraphql (experimental) runs a query and returns each page's data. It throws when the
call failed or any page has errors or no data (soft.graphql is null). A partial answer counts as a failure, unless you pass partial: true (see Partial answers). nodesAt joins
one connection's nodes across the pages, and is null when any page lacks them.
fromTarget('owner') and fromTarget('repo') fill a variable from the client's repo, or let gh
fill it from the clone. A plain string is always sent as written, so a real value of {owner} stays
{owner}.
const query = `query($owner: String!, $name: String!, $endCursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(first: 100, after: $endCursor) {
pageInfo { hasNextPage endCursor }
nodes { number title }
}
}
}`
const pages = gh.graphql(
query,
{ owner: fromTarget('owner'), name: fromTarget('repo') },
{ paginate: true },
)
const prs = nodesAt(pages, ['repository', 'pullRequests'])With paginate, the query must take $endCursor and page one connection. gh follows only that
one. graphql refuses a document holding a mutation or subscription with RangeError. Send one through raw.
Partial answers
When part of a query fails, GitHub still answers the rest: an aliased pull request that was
deleted is null in data, and an entry in errors names its path. gh prints that response and
exits 1. Pass partial: true to read it. Each page comes back as a GraphqlPage, its data with
its errors (each a GraphqlError of message, type and path). errors is empty for a page
that came back whole.
const [page] = gh.graphql(
`query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
a: pullRequest(number: 1) { title }
b: pullRequest(number: 2) { title }
}
}`,
{ owner: fromTarget('owner'), name: fromTarget('repo') },
{ partial: true },
)
const missing = page?.errors.map((e) => e.path.join('.')) // ['repository.b'] when #2 is goneIt still throws (soft.graphql is null) when there is no data to read: a failed call, a
response with no data or data: null (a rate limit, a query that does not validate), or output
that does not parse. With paginate, gh stops at the first page with errors. When that page still has a next
page, the pages after it were never fetched, so that throws too, rather than return fewer rows.
nodesAt(pages, path, { partial: true }) joins one connection across partial pages and returns
PartialNodes: the nodes, where a node GitHub could not resolve is null, and every page's
errors.
Stopping early
paginate has no page cap: gh follows the connection to its end. To read at most a few pages, or
to carry on past a page with errors (add partial: true and read each page's data), leave
paginate off and pass the cursor yourself:
const query = `query($endCursor: String) {
repository(owner: "acme", name: "widgets") {
pullRequests(first: 100, after: $endCursor) {
pageInfo { hasNextPage endCursor }
nodes { number }
}
}
}`
interface PageInfo {
hasNextPage: boolean
endCursor: string | null
}
type Page = { repository: { pullRequests: { pageInfo: PageInfo } } }
const prs: unknown[] = []
let endCursor: string | null = null
for (let n = 0; n < 3; n += 1) {
const [data]: unknown[] = gh.graphql(query, { endCursor })
prs.push(...(nodesAt([data], ['repository', 'pullRequests']) ?? []))
const info: PageInfo = (data as Page).repository.pullRequests.pageInfo
if (!info.hasNextPage) break
endCursor = info.endCursor
}The decision is in docs/planning/decisions/graphql-partial-and-page-cap.md.
Repositories and search
const repos: ListResult<Repository> = gh.repoList('acme', { archived: false, limit: 200 })
const repo: Repository | null = gh.repoView('acme/widgets') // null: no such repository
const hits: ListResult<PrSearchHit> = gh.searchPrs('TICKET-123', { owner: 'acme' })A Repository carries nameWithOwner, owner, name, description, defaultBranch,
visibility, isArchived and url. owner and name are cut from nameWithOwner, so they
come back in GitHub's casing, not as you wrote them: repoList('ACME') gives owner: 'acme' when
the account is acme, and repoView works the same way. To build paths that match your own
config, use your own value.
gh search prs has its own field set. A PrSearchHit names its repository, also in GitHub's
casing, but has no head or base branch, so it is not a PullRequest. Its state is upper-cased to
match. The query takes GitHub's search syntax and may start with -.
Passing your own runner
Gh takes a CommandRunner from
@osdlc/command-seam, so
tests can run offline:
const empty: CommandRunner = { run: () => ({ exitCode: 0, stdout: '[]', stderr: '' }) }
new Gh('/repo', { runner: empty }).prList() // { rows: [], truncated: false }This package re-exports CommandFailed and the runner and result types (CommandRunner,
AsyncCommandRunner, CommandResult, CommandRunOptions), since they appear in its signatures.
Import the runners themselves, such as spawnRunner and spawnRunnerAsync, from
@osdlc/command-seam. The default runners find gh on absolute PATH entries only, so a file
named gh in the working directory cannot run in its place. Pass { absolutePathOnly: true } when
you build a real runner yourself.
Testing
@osdlc/typed-gh/testing builds the rows a fake runner answers with. Each builder returns gh's raw
--json shape, so the real parsers read it. Its defaults fill every field a parser requires, such
as a pull request's number, a repository's nameWithOwner and the headRefName that
prList({ head }) filters on. Without them a row is dropped with no error.
prRow,issueRow,repoRow,searchPrRowandlabelRow: one row ofgh pr,gh issue,gh repo,gh search prsandgh labeloutput.checkRunRow,statusContextRow,commentRowandreviewRow: entries of a pull request'sstatusCheckRollup,commentsandreviews.ghResult(payload): aCommandResultwith exit code 0 and the payload as JSON on stdout.- Row types:
GhPrJson,GhIssueJson,GhRepoJson,GhSearchPrJson,GhLabelJson,GhCheckRunJson,GhStatusContextJson,GhCommentJson,GhReviewJsonandGhActorJson, andRowOverrides<T>, what a builder takes.
Each builder takes typed overrides, merged shallowly. Pass a field as undefined to leave it out.
A pull request's url follows its number, and a repository's its nameWithOwner. Assert on what
you set: a default value may change in a minor release.
import { ghResult, prRow } from '@osdlc/typed-gh/testing'
const runner: CommandRunner = {
run: () => ghResult([prRow({ number: 7, headRefName: 'task/foo' }), prRow({ number: 8 })]),
}
new Gh('/repo', { runner }).openPrFor('task/foo')?.number // 7API
Everything below is exported from @osdlc/typed-gh. The Stability section says which items are
experimental.
Clients
Gh: the synchronous client, bound to one working directory or one repository, and optionally a host.new Gh(cwd, options?)ornew Gh({ repo, hostname?, runner? }).AsyncGh: the awaitable client, with the same methods.GhOptionsandAsyncGhOptions: the runner,repoandhostnamea client is built with.GhRepoOptionsandAsyncGhRepoOptions: the same withreporequired, for a client built without a directory.AsyncCallOptions:timeoutMsandsignal, accepted by sixteen methods ofAsyncGh.defaultRunnerFactoryandRunnerFactory: how a client makes its default runner forgh.runVerbandrunVerbAsync, withRunVerbOptionsandRunVerbAsyncOptions: run one verb through a runner you own, with itsenv,input, timeout and checked contract applied. With the verb functions, they are the way to callghwithout aGh, which keeps a bundle small (see "Keeping a bundle small").
Methods on both clients:
- Direct calls:
rawruns anyghcall and returns the result.rawCheckedthrows on a failed exit.atreturns the same client rooted at another directory, andwithRepothe same client aimed at another repository. - Pull request queries:
prList,prView,prFind,openPrFor,prViewSettled,prBody,prReviewThreadsandsearchPrs. - Pull request changes:
createPr,editPr,editPrBody,prComment,prUpdateBranch,prSetDraft,prMerge,prDiffandcreateReview. - Issue and label queries:
issueList,issueViewandlabelList. - Repository queries:
repoListandrepoView. - Issue and label changes:
setIssueLabel,createLabel,editLabelandissueCommentEdit. - REST and GraphQL:
apiis a GET throughgh apiand never throws (failure isok: false);graphqlis a query and throws on a failed or partial answer, or returns a partial answer as{ data, errors }pages withpartial: true. - Setup checks:
installed,authenticated,authStatusandready. ownerReporeads the owner and repository from a remote URL. It starts no process.tryandsoft: the whole surface again as aResult<T, GhFailure>, and the queries again withnullfor a failure.TriedSync,TriedAsyncandSoftare their types.
Failures
GhFailureandGhFailureKind: the error oftry, and its kinds (see "GhFailure" above).classifyGhFailure(commandFailed): theGhFailurefor aCommandFailed.
Verbs
A verb is plain data: argv, a checked flag, a readOnly flag, optional stdin and environment,
and a parse function. readOnly is true for every query and prDiff, and false for every
mutation and for raw and rawChecked, so a caller that keeps an allowlist of safe calls can check
a verb before running it. Each verb takes an optional GhTarget last, except raw and
rawChecked, which take only argv. Each client method has a verb of the same name, except at,
withRepo, prViewSettled, openPrFor, prFind, authenticated, ready and ownerRepo.
openPrsForBranch is a verb with no method.
GhVerb: the shape of a verb.GhTarget: therepoandhostnamea verb talks to.- Verb builders:
prList,prView,prBody,openPrsForBranch,prReviewThreads,prDiff,createPr,editPr,editPrBody,prComment,prUpdateBranch,prSetDraft,prMerge,createReview,issueList,issueView,labelList,setIssueLabel,createLabel,editLabel,issueCommentEdit,repoList,repoView,searchPrs,api,graphql,installedandauthStatus. rawandrawChecked: any argv as a verb whose value is theCommandResult.rawreturns it whatever the exit code;rawCheckedthrows the classified failure unlessghexits 0 with its whole output.pickPrForBranch: the first row whose head branch is exactly the one you name.prList({ head })already filters exactly. This is for rows from elsewhere, such as ahead:search. It takes plain rows or aListResult, soopenPrsForBranch's orprList's parsed answer passes straight in.nodesAt: one connection's nodes, joined acrossgraphqlpages. With{ partial: true }, across partial pages, with their errors.- Field lists:
PR_REF_FIELDS(enough to identify a PR),PR_LIST_FIELDS(for triage),PR_VIEW_FIELDS(the full set),REPO_FIELDSandSEARCH_PR_FIELDS. - Option and result types:
ListResult,PrListOptions,IssueListOptions,RepoListOptions,SearchPrsOptions,CreatePrOptions,CreateReviewOptions,ReviewComment,CreateLabelOptions,ApiOptions,ApiResponse,GraphqlOptions,GraphqlPartialOptions,GraphqlPage,GraphqlError,PartialNodes,GraphqlVariable,AuthStatus,PrMergeMethodandReviewThread.
Parsers
parsePullRequest: reads agh --jsonpayload into aPullRequest, ornull.anyCheckFailed: true if any check in a list failed.normalizeAuthorLogin: drops a[bot]suffix or anapp/prefix from a login.parseOwnerRepo: reads a remote URL into{ owner, repo }, ornull. It is@osdlc/typed-git'sparseRemoteRepowithout the host, so any host's remote reads, not only GitHub's. UseparseRemoteRepowhen you need the host too.ownerRepoSlug: writes{ owner, repo }asowner/repo.parsePrUrl: reads a pull request URL on any GitHub host into its host, owner, repository and number, ornull.parsePrRef: reads what a person typed to name a pull request into aPrRef, ornull.
Types
- Model:
PullRequest,Review,Comment,CheckRun,Actor,Label,IssueRow,RepositoryandPrSearchHit. - Enums:
PullRequestState,MergeableState,MergeStateStatus,ReviewDecision,ReviewState,CheckConclusionandRepositoryVisibility. OwnerRepo,PullRequestRefandPrRef: a repository, a pull request in one, and what a person typed to name a pull request.
From the command seam
CommandFailed: re-exported from@osdlc/command-seam. A failed call throws aFailureErrorwhosefailure.causeis theCommandFailed, which carries the argv and the result.CommandRunner,AsyncCommandRunner,CommandResultandCommandRunOptions: the runner types that appear in signatures.
Stability
The package is at 0.x. Each item is stable or experimental.
- Stable items change only with a minor release, and a breaking change is marked Breaking in the CHANGELOG.
- Experimental items may change or go in any release. Each has an
@experimentaltag in its TSDoc.
The experimental items:
issueCommentEdit, the verb and the method on both clientscreateReview, the verb and the method on both clientsCreateReviewOptionsReviewCommentgraphql, the verb and the method on both clientsnodesAtGraphqlOptionsandGraphqlPartialOptionsGraphqlPage,GraphqlErrorandPartialNodesGraphqlVariablefromTargetandFromTarget
Every other export and method is stable, prDiff included. So is @osdlc/typed-gh/testing: its
builders and their row types. Their default values may change in any minor release.
Platforms
Supported platforms are listed under Install. CI runs the tests on macOS and Linux. The package builds gh argv as arrays and starts gh with no
shell, so nothing in it is specific to one shell.
What is not here
- Closing a PR and deleting a branch.
prMergemerges and leaves the branch alone. Closing and deleting belong in the caller's own code, which keeps its own record of what it did. - A full REST or GraphQL client.
api()is a GET andgraphql()a query. Anything else goes throughraw(["api", …]). - Anything that reads a token.
Licence
MIT OR Apache-2.0, at your option. See LICENSE-MIT and LICENSE-APACHE.
