repomatic.github.gh module

Generic wrapper for the gh CLI.

Note

Workflow steps must set GH_TOKEN explicitly: GITHUB_TOKEN is a secret expression in GitHub Actions, not an automatic environment variable. The standard pattern is GH_TOKEN: ${{ secrets.REPOMATIC_PAT || github.token }} for steps that prefer a PAT, or GH_TOKEN: ${{ github.token }} otherwise.

As defense-in-depth, run_gh_command() promotes REPOMATIC_PAT to GH_TOKEN when set, and promotes GITHUB_TOKEN to GH_TOKEN when GH_TOKEN is absent. A Requires authentication 401 (a GitHub-side auth incident or a fine-grained PAT scope quirk) is first retried with the same token after a short back-off, catching transient flaps that clear on their own. A Bad credentials 401 skips that wait: an expired or revoked PAT never recovers on a retry. Either then falls back to GITHUB_TOKEN if available and different. When every retry path is exhausted, the raised GhCommandError is annotated with the current githubstatus.com summary so operators are not sent chasing PAT scopes during an upstream incident.

exception repomatic.github.gh.GhCommandError(message, *, stdout='', stderr=None)[source]

Bases: RuntimeError

A gh command that still failed after every retry.

The message is the command’s error output, with the githubstatus.com annotation when an incident is active. The attributes keep what a caller branches on, so no caller has to search the message for an HTTP status.

stdout

What the command wrote to its standard output before it failed.

gh api graphql writes the partial data here when a query fails only in part, see gh_graphql().

stderr

The command’s standard error, without the status annotation.

Defaults to message, which is the error output when no annotation was added to it.

status: int | None

The HTTP status of the failed API call.

None when the error output names none: a network error, a gh usage error.

exception repomatic.github.gh.GhOutputError[source]

Bases: RuntimeError

A gh command that ran, but printed something other than the JSON asked for.

Kept apart from GhCommandError: a caller may treat a command that could not run as fatal and an unreadable answer as a soft miss.

repomatic.github.gh.gh_executable() → str[source]

Resolve the gh binary every call in this package shells out to.

Prefers the registry-pinned build over whatever $PATH offers, which is the rule for any tool repomatic shells out to: the registry pin carries a version, a checksum and a cooldown, while $PATH carries none of the three and hands each runner image (and each developer laptop) a different gh.

Caution

Falls back to bare gh on $PATH when the registry build cannot be obtained. Unlike a formatter, gh is on the critical path of jobs that have already done real work (a release publish, an issue upsert), so a download failure must not strand them: a hosted runner ships a usable gh, and degrading to it beats failing the job. The fallback is logged, never silent.

Memoized: the install-and-verify path runs once per process however many of the ~60 call sites fire.

Return type:

str

repomatic.github.gh.resolve_gh_token()[source]

Return the GitHub token from environment variables.

The canonical lookup order for every GitHub API access in the package: REPOMATIC_PAT > GH_TOKEN > GITHUB_TOKEN. Empty string when no variable is set.

Return type:

str

repomatic.github.gh.api_headers()[source]

Build GitHub API request headers, authenticated when a token is present.

The one place a direct HTTP call to the GitHub API gets its headers, so every such call agrees on the Accept media type and the authentication scheme. Bearer is the scheme GitHub documents, and it carries both classic and fine-grained tokens.

A token raises the rate limit from 60 to at least 1,000 requests/hour, which matters when iterating every tool and action in CI. Resolution follows the canonical resolve_gh_token() order, so a repo carrying only REPOMATIC_PAT gets authenticated reads here too, not just through the gh CLI.

Return type:

dict[str, str]

Returns:

Request headers, with Authorization present only when a token is set.

repomatic.github.gh.gh_env(token=None)[source]

Child environment promoting a token to GH_TOKEN, or None for none.

The one spelling of the promotion every gh-reading subprocess gets (gh itself, and labelmaker, which reads the same variable): the canonical resolve_gh_token() winner is injected as GH_TOKEN, a value-preserving no-op when GH_TOKEN itself won the resolution.

Parameters:

token (str | None) – The credential to promote; resolved when omitted. An empty resolution returns None, leaving the child the parent environment.

Return type:

dict[str, str] | None

repomatic.github.gh.run_gh_command(args)[source]

Run a gh CLI command and return stdout.

Token priority: REPOMATIC_PAT > GH_TOKEN > GITHUB_TOKEN. The gh CLI does not recognize REPOMATIC_PAT, so when set it is injected as GH_TOKEN. A Requires authentication 401 from the primary token is first retried with the same token after a short bounded back-off (see _TRANSIENT_AUTH_BACKOFF_SECONDS), absorbing transient GitHub auth flaps that resolve on their own; a Bad credentials 401 skips straight past it, since a revoked or expired token cannot clear on a retry. A secondary rate-limit refusal gets the same treatment on its own, longer schedule (see _TRANSIENT_THROTTLE_BACKOFF_SECONDS), since it lifts on its own within the minute. If 401s persist, the command is then retried with GITHUB_TOKEN if available and different, letting CI jobs degrade gracefully to the standard Actions token instead of failing outright on a stale PAT. When every retry path is exhausted, the raised GhCommandError carries a githubstatus.com annotation when an incident is active.

Parameters:

args (list[str]) – Command arguments to pass to gh.

Return type:

str

Returns:

The stdout output from the command.

Raises:

GhCommandError – If the command fails (after retries and fallback, if attempted).

repomatic.github.gh.parse_create_output(output, kind)[source]

Read the number and URL of a thread out of gh {kind} create output.

gh issue create and gh pr create both print the new thread’s URL last, in the form https://github.com/owner/repo/{issues,pull}/123. Read the last line rather than the whole output: gh prepends advisory lines of its own (a deprecation notice, a “Warning: N uncommitted changes” banner), and parsing the joined output turns one of those into an error that reads as a failed creation when the thread was in fact created.

Parameters:
  • output (str) – The raw gh ... create standard output.

  • kind (str) – The thread kind for the error message, issue or pr.

Return type:

tuple[int, str]

Returns:

The (number, url) pair of the created thread.

Raises:

RuntimeError – When the output carries no parsable thread URL.

repomatic.github.gh.gh_json(args, *, soft=False)[source]

Run a gh command that prints JSON, and return the parsed value.

The one entry point for every gh call whose output is JSON: an API read (gh api) as well as a JSON listing (gh release view --json).

Parameters:
  • args (Sequence[str]) – Command arguments to pass to gh.

  • soft (bool) – Return None when the command fails or prints something that is not JSON, and log the cause at debug level. A caller sets it when a failed read only means “nothing to report”, as a lint probe does.

Return type:

Any | None

Returns:

The parsed JSON value, or None on a failure when soft is set.

Raises:
repomatic.github.gh.gh_api_pages(endpoint, *, key='', soft=False)[source]

Read every page of a paginated REST endpoint, and return their items.

gh api --paginate --slurp follows the Link headers and wraps every page in one JSON array, so the pages parse at once rather than line by line.

Parameters:
  • endpoint (str) – The REST endpoint, query string included. Send per_page in it to keep the page count low.

  • key (str) – The field holding the items, for an endpoint that wraps them in an object, like workflow_runs for the runs endpoints. Empty for an endpoint that answers with an array.

  • soft (bool) – Return an empty list when the command fails or prints something that is not JSON, as gh_json() does.

Return type:

list[Any]

Returns:

The items of every page, in API order.

Raises:
repomatic.github.gh.gh_api_json(args, *, strict=False)[source]

Run a gh command expected to emit JSON, and parse it.

A failed command and an unparsable output both read as None, so a caller reports one “could not read it” outcome instead of two it cannot act on differently. With strict, a failed command raises instead, while an unparsable output stays a soft miss (ci-status reads runs this way).

A thin layer over gh_json(), which new code calls directly.

Parameters:
  • args (Sequence[str]) – Command arguments to pass to gh.

  • strict (bool) – Re-raise the GhCommandError of a failed command, instead of returning None.

Return type:

Any | None

Returns:

The parsed JSON payload, or None when the command failed (unless strict) or its output did not parse.

repomatic.github.gh.gh_graphql(query, *, partial=False, **variables)[source]

Run a one-shot GraphQL query through gh, and return its data envelope.

The one-shot sibling of iter_graphql_nodes(), for the queries that read a handful of fields off a single object rather than walking a connection.

Parameters:
  • query (str) – The GraphQL query string.

  • partial (bool) – Accept a partial answer. GitHub answers a query that fails only in part with the data it could resolve, beside its errors, and gh then exits non-zero. With partial, that data is returned when the failed command printed one: the fields that failed read as null.

  • variables (str | int | bool) – Query variables. See _graphql_args for how each type travels.

Return type:

Any

Returns:

The response’s data object, unwrapped.

Raises:
repomatic.github.gh.iter_graphql_nodes(query, connection_path, variables=None, *, page_size_var='', page_size=0)[source]

Iterate a GraphQL connection’s nodes, following cursor pagination.

The shared gh api graphql pagination loop: run the query, walk the response to the connection object, yield each node, then follow pageInfo.hasNextPage/endCursor until the connection is exhausted. The query must declare a $cursor: String variable and pass it as after: $cursor, and its connection must select pageInfo { hasNextPage endCursor }.

Null nodes (which GitHub’s search connection can emit) are skipped.

Parameters:
  • query (str) – The GraphQL query string.

  • connection_path (Sequence[str]) – Keys from the response’s data object down to the connection (like ("search",) or (“user”, “sponsorshipsAsMaintainer”)).

  • variables (Mapping[str, str | int | bool] | None) – Query variables. See _graphql_args for how each type travels.

  • page_size_var (str) – When set, inject the page size into this query variable on every request (the query then controls first: with it). Leave empty for queries with a hard-coded page size.

  • page_size (int) – Nodes requested per page; only used with page_size_var.

Yields:

Each node dict, in API order.

Raises: