Sign in

    github

    Thin GitHub API client for MoonBit built on gaato/http and gaato/sdk-runtime: the whole REST API generated, plus a GraphQL passthrough.

    github
    rest
    graphql
    sdk
    openapi
    Download zip
    Author
    Version
    0.17.0
    License
    Apache-2.0
    Last updated
    4 days ago
    Downloads
    141

    #gaato/github

    Unofficial GitHub API client for MoonBit, on gaato/http and gaato/sdk-runtime. The caller supplies a Transport and a Clock, for example gaato/http-async. REST is generated in full; GraphQL is a passthrough.

    Unofficial and experimental. Source, issues and design notes: https://github.com/gaato/mbt-sdk

    All 1,221 operations of the vendored first-party OpenAPI document are generated into gaato/github/gen as sans-IO pairs: <operation>_request(...) builds an @http.Request, and <operation>_decode(response) reads the success payload. See spec provenance and generation overlays.

    Unlike gaato/openai and gaato/anthropic, this module does not restate the generated types behind a hand-written vocabulary — at this surface area that would be a second API to keep in step with the first. The generated package is the public type vocabulary, and the facade only sends, follows, and reads:

    let transport = @http_async.AsyncTransport::new(max_body_bytes=64 * 1024 * 1024)
    let gh = @github.GitHub::new(transport, clock, token="ghp_...")
    let repo = gh.call(@gen.repos_get_request("gaato", "mbt-sdk"), @gen.repos_get_decode)
    let diff = gh.send(
    @gen.repos_compare_commits_request("gaato", "mbt-sdk", "main...topic")
    .header("accept", "application/vnd.github.diff"),
    )

    call decodes; send returns the response for operations with no body to decode (204, 304) or a non-JSON representation such as application/vnd.github.diff. An operation that sets its own accept wins over the client default, as do all request headers.

    Both read the whole body into memory, so give the transport a max_body_bytes; this client cannot set it because the transport is yours. 64 MiB is ample for API JSON, and a longer reply fails with @runtime.Transport(Protocol(..)) rather than growing memory. Files are another matter. The archive, artifact, log and migration downloads (repos_download_tarball_archive, repos_download_zipball_archive, actions_download_artifact, the actions_download_*_logs operations, migrations_download_archive_for_org, migrations_get_archive_for_authenticated_user) answer 302 with a short-lived location, as do repos_get_release_asset with accept: application/octet-stream and code_scanning_get_codeql_database with accept: application/zip some of the time. The native and Wasm transports do not follow redirects, so send raises that 302 as @runtime.Status; read location from its headers and fetch it with transport.send_stream, without the token, writing chunks out as they arrive. @http.follow_redirects_stream(request, r => transport.send_stream(r)) does both for a request carrying the full URL and token: it drops Authorization when the location is on another host and closes the 302 unread. On JS, fetch follows the redirect and the file arrives as a buffered body, as do raw content (repos_get_content with accept: application/vnd.github.raw, up to 100 MB) and large diffs on every target; send those through a second GitHub client whose transport has a limit sized for the largest file you accept.

    GitHub paginates with RFC 8288 Link headers, so paginator follows the rel="next" URL the server wrote. It is an @runtime.Paginator, with each, next_page and collect:

    let repos = gh
    .paginator(@gen.repos_list_for_user_request("gaato", per_page=100), @gen.repos_list_for_user_decode)
    .collect(max=250)

    GraphQL is not generated, because there is nothing to generate from. In OpenAPI the shape of a response belongs to the operation, which is what gen compiles; in GraphQL it belongs to the query document the caller wrote, and the schema only says what is possible. So the endpoint is a passthrough over Json:

    let data = gh.graphql(
    query="query($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { stargazerCount } }",
    variables={ "owner": "gaato", "name": "mbt-sdk" },
    )

    A GraphQL failure does not use the HTTP status: the reply is 200 with an errors array beside a possibly partial data. graphql raises whenever that array is non-empty (@runtime.Decode, or @runtime.RateLimited for a rate limit), so an error cannot pass unnoticed, and graphql_errors reads it back — including type_, which names the condition (NOT_FOUND, FORBIDDEN, RATE_LIMITED). Use graphql_response when the partial data of such a reply is worth keeping. graphql_paginator walks a connection by threading pageInfo.endCursor through a query variable, after unless cursor_variable says otherwise.

    A GraphQL rate limit is a RATE_LIMITED entry in that array rather than a 429. It, a secondary limit reported in a 200, and "Something went wrong while executing your query" are recognised inside the retry loop, so they are retried like an error status (see below); a document holding a mutation is not retried on "Something went wrong", since the mutation may have been applied. A rate limit that persists is raised as @runtime.RateLimited, with the reply's body for graphql_errors.

    Search and GraphQL are each metered separately from the rest of the API, so requests to /search/* and to /graphql use their own rate limit buckets, and writes (any method but GET and HEAD) use core:write, or core:notifications for the paths that send notifications. The default @runtime.WindowLimiter paces each bucket against x-ratelimit-remaining and x-ratelimit-reset, keyed by x-ratelimit-resource, so a write still spends the core window. Following octokit and docs.github.com, it also spaces writes and every GraphQL request at least 1 s apart, writes that send notifications 3 s (which also holds the next write), and searches 2 s, so consecutive writes are slower than before. docs.github.com also asks for requests to be made "serially instead of concurrently"; this module has no async runtime to provide that, so pass a gate:

    let gh = @github.GitHub::new(transport, clock, token="ghp_...", gate=() => @http_async.SemaphoreGate::new())

    The gate serialises each group (writes and GraphQL, searches, reads). timeouts takes a @runtime.Timeouts for per-attempt and per-call limits. Pass limiter=@runtime.NoLimiter::new() to opt out of the limiter, pacing and gate.

    Failures are @runtime.SdkError. api_error reads GitHub's error body (message, documentation_url, status, errors) out of one. is_not_modified recognises the 304 answer to a conditional request. is_secondary_rate_limit recognises the secondary limit, which is a different mechanism from the hourly quota, reported as 403 or 429 with retry-after or a message naming the secondary rate limit.

    Clients created with GitHub::new retry through GitHubRetry around the retry policy, by default @runtime.RetryPolicy::conservative().with_max_retries(3):

    • Three retries, as octokit. A 5xx, 408, 409 or a timeout after sending is retried only for an idempotent request: unlike octokit, a POST is not resent, because GitHub has no idempotency key and a second issue or comment could be created.
    • A secondary limit is retried for any method, after retry-after, or else after 60 s, 120 s, 240 s (docs.github.com: "wait for at least one minute", then "an exponentially increasing amount of time"). The policy's Retry-After cap does not apply; its retry count and a total_ms in timeouts do.
    • A primary limit (x-ratelimit-remaining: 0, or GraphQL RATE_LIMITED) is retried once, one second after x-ratelimit-reset, as octokit does; docs.github.com asks not to retry before the reset. That can be up to an hour, so set a total_ms to bound it.

    For GitHub Enterprise Server, point base_url at the REST mount: @github.GitHub::new(transport, clock, token~, base_url="https://ghe.example.com/api/v3"). GraphQL is not under that mount — Enterprise Server serves it from /api/graphql — so the GraphQL endpoint is derived from base_url rather than appended to it; pass graphql_url for a deployment that spells it differently. from_client takes a preconfigured @runtime.Client for anything further from the default deployment, and takes graphql_url too, because a runtime client keeps its base_url to itself.

    Not every field is typed. At 43 places in the specification the generator could not resolve a schema into a single MoonBit type — unions whose branches carry no tag of their own, unions whose tag sits on a sibling property, objects mixing named and typed additional properties — and those are carried as raw Json. The census lists every one with the reason. They can be typed individually later without changing the operation signatures.

    ApiErrorBody

    pub(all) struct ApiErrorBody {
    message : String
    documentation_url : String?
    status : String?
    errors : Array[Json]?
    } derive(Eq,
    Debug
    )

    The error body returned by the GitHub REST API.

    status is a string because that is how GitHub writes it ("404"); a numeric value is accepted and rendered the same way. errors keeps the raw elements: a 422 reports objects with resource, field, and code, while a few endpoints report plain strings.

    test {
    let body : @github.ApiErrorBody = {
    message: "Not Found",
    documentation_url: Some("https://docs.github.com/rest"),
    status: Some("404"),
    errors: None,
    }
    assert_eq(body.message, "Not Found")
    }

    ApiErrorBody::equal

    Compares API error bodies field by field.

    ApiErrorBody::not_equal

    fn ApiErrorBody::not_equal(x : ApiErrorBody, y : ApiErrorBody) -> Bool

    Compares API error bodies field by field.

    ApiErrorBody::to_repr

    Debug representation of an API error body.

    GitHub

    pub struct GitHub {
    // private fields
    }

    A GitHub API client backed by the transport-independent SDK runtime.

    REST request and response shapes come from the generated gen package, which is produced from the vendored first-party OpenAPI document. This facade does not restate them: it only sends a generated request, follows Link pages, and reads GitHub's error bodies.

    GraphQL has no such generated vocabulary — a reply's shape belongs to the caller's query rather than to the schema — so it is served by the graphql passthrough over Json.
    impl Debug for GitHub

    GitHub::call

    Sends a generated request and decodes the successful response.

    The two arguments are the matched halves of one generated operation, so a call reads as a single line:

    let repo = gh.call( @gen.repos_get_request("gaato", "mbt-sdk"), @gen.repos_get_decode, )

    Failures arrive as @runtime.SdkError; api_error reads GitHub's error body out of one.

    GitHub::from_client

    fn GitHub::from_client(client :
    Client
    , graphql_url? : String) -> GitHub

    Wraps a preconfigured runtime client.

    This is the escape hatch for deployments this facade does not model — a proxy that authenticates differently, a GitHub Enterprise Server instance with extra headers — and for tests that build the client themselves.

    A runtime client keeps its base_url to itself, so the GraphQL endpoint cannot be derived from it here and defaults to GitHub's own. Pass graphql_url for anything else.

    GitHub::graphql

    async fn GitHub::graphql(self : GitHub, query~ : String, variables? : Json, operation_name? : String) -> Json raise
    SdkError

    Sends a GraphQL query and returns its data.

    GitHub's GraphQL API is not generated. In OpenAPI the shape of a response belongs to the operation, which is what gaato/github/gen compiles; in GraphQL it belongs to the query document the caller wrote, and the schema only says what is possible. There is no operation-to-type table to generate, so the payload is Json and the caller decodes the shape it asked for.

    let data = gh.graphql( query=( #|query($owner: String!, $name: String!) { #| repository(owner: $owner, name: $name) { stargazerCount } #|} ), variables={ "owner": "gaato", "name": "mbt-sdk" }, )

    A GraphQL failure does not use the HTTP status: the reply is 200 with an errors array beside a possibly partial data. This raises whenever that array is present and non-empty, so an error cannot pass unnoticed; read the array back with graphql_errors. Use graphql_response instead when the partial data of such a reply is worth keeping.

    Three kinds of error are recognised inside the retry loop, before the reply is returned (see GitHubRetry):

    • a RATE_LIMITED error (the point budget is spent) is raised as @runtime.RateLimited with the reply's headers and body, and retried once, one second after x-ratelimit-reset;
    • an error naming the secondary rate limit is raised as @runtime.RateLimited as well, and retried after retry-after or at least a minute;
    • "Something went wrong while executing your query" is raised as @runtime.Decode and retried with the policy's backoff, but only when the document holds no mutation, since a mutation may have been applied.

    Requests are metered and paced in the "graphql" bucket, or "graphql:mutation" for a document holding a mutation.

    GitHub::graphql_paginator

    fn[T] GitHub::graphql_paginator(self : GitHub, page : (Json) -> (Array[T], String?) raise
    SdkError
    , query~ : String, variables? : Json, cursor_variable? : String, operation_name? : String) ->
    Paginator
    [T,
    SdkError
    ]

    Walks a GraphQL connection, threading its cursor through the variables.

    GraphQL paginates in the body rather than in a Link header, and a connection's pageInfo sits wherever the query put it — data.repository .issues.pageInfo for one query, somewhere else for the next. So this takes no path: page receives the data of each reply and returns that page's items together with the cursor for the following one, which is pageInfo.endCursor while pageInfo.hasNextPage is true and None once it is false. Returning a cursor when hasNextPage is false walks off the end of the connection; returning None too early stops silently.

    The query takes the cursor as a variable, named after unless cursor_variable says otherwise, and must declare it nullable so the first page — which is fetched without it — is valid:

    let issues = gh .graphql_paginator( data => { guard data is Object(root) else { ... } ... (nodes, if has_next_page { end_cursor } else { None }) }, query=( #|query($cursor: String) { #| repository(owner: "gaato", name: "mbt-sdk") { #| issues(first: 100, after: $cursor) { #| nodes { number title } #| pageInfo { hasNextPage endCursor } #| } #| } #|} ), cursor_variable="cursor", ) .collect(max=500)

    Each page raises on a non-empty errors array, and is retried, exactly as graphql does.

    GitHub::graphql_response

    async fn GitHub::graphql_response(self : GitHub, query~ : String, variables? : Json, operation_name? : String) -> Json raise
    SdkError

    Sends a GraphQL query and returns the whole {data, errors, extensions} envelope.

    This raises for the failures the HTTP status reports — an unauthorized request, a transport error, a body that is not JSON — and for the errors graphql retries once their retries run out: a rate limit, and a transient failure of a document without a mutation. Any other GraphQL reply, such as one that nulls one field and explains it in errors, is returned intact, which is the point of this entry point: graphql would raise and discard the rest.

    GitHub::new

    fn GitHub::new(transport : &
    Transport
    , clock : &
    Clock
    , token? : String, base_url? : String, api_version? : String, user_agent? : String, retry? :
    RetryPolicy
    , limiter? : &
    RateLimiter
    , graphql_url? : String, gate? : () -> &
    Gate
    , timeouts? :
    Timeouts
    ) -> GitHub

    Creates a GitHub client.

    token is optional: GitHub serves public resources unauthenticated, so an absent token means NoAuth rather than a configuration error. A present token is sent as Authorization: Bearer, which covers personal access tokens, installation tokens, and GITHUB_TOKEN.

    base_url is the API root. GitHub Enterprise Server mounts the REST API under /api/v3, so pass https://ghe.example.com/api/v3 there.

    The default headers are accept: application/vnd.github+json, x-github-api-version, and user-agent, which GitHub requires. A generated operation that needs a different representation — application/vnd.github.diff, .patch, .sarif — sets accept on its own request, and the runtime keeps request headers ahead of client defaults.

    retry defaults to RetryPolicy::conservative().with_max_retries(3) and is wrapped in GitHubRetry, which also waits out GitHub's primary and secondary rate limits and retries a transient GraphQL failure of a query.

    Unless limiter says otherwise, responses feed a WindowLimiter reading x-ratelimit-remaining and x-ratelimit-reset, keyed by x-ratelimit-resource so that a write shares the core window. It spaces writes and GraphQL requests at least 1 s apart, writes that send notifications 3 s and searches 2 s, as octokit does. gate makes the requests of each of those groups serial, as docs.github.com asks ("make requests serially instead of concurrently"): pass gate=() => @http_async.SemaphoreGate::new() from gaato/http-async. Without it concurrent requests are only spaced, not serialised. gate is ignored when limiter is given. Pass limiter=@runtime.NoLimiter::new() to opt out of both.

    timeouts limits each attempt and each call (@runtime.Timeouts); without it only the transport's own timeouts apply.

    graphql_url is the GraphQL endpoint, which is not under the REST root: GitHub Enterprise Server serves REST from /api/v3 and GraphQL from /api/graphql. It is derived from base_url and only needs passing for a deployment that puts the two somewhere else.

    GitHub::paginator

    Walks a Link-paginated collection starting from a generated request.

    GitHub paginates with RFC 8288 Link headers rather than a cursor in the body, so the paginator's cursor is the rel="next" URL exactly as the server wrote it: absolute, and already carrying the page size and every filter of the first request. @runtime.Client resolves an absolute request URL by passing it through, so the following pages need no rebuilding here.

    The decoder is the generated <operation>_decode of an operation whose success type is an array:

    let repos = gh .paginator( @gen.repos_list_for_user_request("gaato", per_page=100), @gen.repos_list_for_user_decode, ) .collect(max=250)

    A search operation does not answer this shape: its payload is an envelope with items and total_count, so wrap search_*_decode in a decoder that returns the items field.

    GitHub::send

    Sends a generated request and returns the successful response undecoded.

    This is for the operations whose body a decoder cannot describe: 204 and 304 replies, and the representations requested through a non-JSON accept such as application/vnd.github.diff.

    GitHub::to_repr

    Redacted debug representation of a GitHub client.

    GitHubRetry

    pub(all) struct GitHubRetry {
    policy :
    RetryPolicy

    }

    GitHub's retry rule: the rate limits GitHub documents are waited out as docs.github.com asks, a transient GraphQL failure of a query is retried, and everything else follows policy.

    GitHub::new wraps its retry policy in this, by default RetryPolicy::conservative().with_max_retries(3): three retries, as octokit's plugin-retry, but a 5xx or a timeout after sending is retried only for an idempotent request, because GitHub has no idempotency key and a resent POST can open a second issue or comment.

    GitHubRetry::decide

    Exposes the retry decision as a method.

    GraphqlError

    pub(all) struct GraphqlError {
    message : String
    type_ : String?
    path : Array[Json]?
    locations : Array[Json]?
    extensions : Json?
    } derive(Eq,
    Debug
    )

    One entry of a GraphQL errors array.

    Only message is required by the GraphQL specification. type_ is GitHub's own addition and names the condition — NOT_FOUND, FORBIDDEN, RATE_LIMITED — but it is absent from the errors the query parser raises, and the set of values it can take is not specified, so it stays a String.

    path and locations keep their raw elements: a path mixes field names with list indices, so it is a Json array of strings and numbers.

    test {
    let error : @github.GraphqlError = {
    message: "Could not resolve to a Repository with the name 'o/r'.",
    type_: Some("NOT_FOUND"),
    path: Some(["repository"]),
    locations: None,
    extensions: None,
    }
    assert_eq(error.type_, Some("NOT_FOUND"))
    }

    GraphqlError::equal

    Compares GraphQL errors field by field.

    GraphqlError::not_equal

    fn GraphqlError::not_equal(x : GraphqlError, y : GraphqlError) -> Bool

    Compares GraphQL errors field by field.

    GraphqlError::to_repr

    Debug representation of a GraphQL error.

    api_error

    Extracts GitHub's error body from an HTTP status failure.

    Returns None for transport, decode, and configuration failures, and for a body that is not a GitHub error object — an HTML page from an intermediate proxy, or an empty body.

    graphql_errors

    Extracts the GraphQL errors array from a failure.

    This is the counterpart of api_error. graphql reports a non-empty errors array as @runtime.Decode — the response decoded, it simply did not carry the value that was asked for — or, for a rate limit, as @runtime.RateLimited, and attaches the body to either, which this reads back:

    if @github.graphql_errors(error) is Some(errors) { for error in errors { if error.type_ is Some("RATE_LIMITED") { ... } } }

    Returns None for a body that is not a GraphQL envelope with errors, which includes every REST-shaped failure of the endpoint: an unauthenticated request is answered 403 with a message body that api_error reads.

    is_not_modified

    fn is_not_modified(error :
    SdkError
    ) -> Bool

    Reports the 304 answer to a conditional request.

    A request carrying if-none-match or if-modified-since is answered with 304 Not Modified and an empty body, which the runtime classifies as a failure like any other non-2xx status. A cached copy is still valid, and a 304 is not charged against the rate limit.

    is_secondary_rate_limit

    fn is_secondary_rate_limit(error :
    SdkError
    ) -> Bool

    Reports a secondary rate limit, which is not the documented hourly quota.

    GitHub answers the hourly quota with 403 or 429 and x-ratelimit-remaining: 0; WindowLimiter paces against those headers and GitHubRetry waits for the reset once. A secondary rate limit — too many concurrent requests, too many points in a minute, too much content creation — is a separate mechanism, reported with 403 or 429 plus a retry-after header, or a message naming it (matched as octokit does, "secondary rate" in any case), in a REST error body or in the errors of a GraphQL reply. It leaves x-ratelimit-remaining untouched, so no limiter can anticipate it: GitHubRetry waits and retries after it occurs.

    The runtime's taxonomy classifies only 429 as RateLimited, so a secondary limit delivered as 403 arrives as Status; one delivered in a GraphQL 200 is raised by graphql as RateLimited. This predicate spans all three.