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 RuntimeError is annotated with the current githubstatus.com summary so operators are not sent chasing PAT scopes during an upstream incident.

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 claude.md § “A cooldown is not a hash” states 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 RuntimeError 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:

RuntimeError – 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_api_json(args, *, strict=False)[source]

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

The two ways a JSON-producing gh call can fail are indistinguishable to a caller that just wants the payload: the command may not run at all (network, auth, a 404 on an endpoint the repository has not enabled) or it may return something that is not JSON. Both collapse to None here, so a caller reports one “could not read it” outcome instead of two it cannot act on differently.

Reserved for calls whose failure is a tolerable outcome, which is what every repomatic.lint_repo check wants: a probe that cannot run reports itself as skipped rather than failing the lint. A caller for whom a failed command is fatal while unparsable output stays a soft miss passes strict (ci-status reads runs this way); one treating every failure as fatal keeps using run_gh_command() and handles RuntimeError itself.

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

  • strict (bool) – Re-raise the RuntimeError of a command that could not run, instead of collapsing it to 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, **variables)[source]

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

The paginated sibling of iter_graphql_nodes(), for the queries that read a handful of fields off a single object rather than walking a connection. The query travels as a raw field, since --field reads a value looking like a number or a boolean as one, which would corrupt a query string that happens to start with a digit.

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

  • variables (str) – Query variables, all passed as strings.

Return type:

Any

Returns:

The response’s data object, unwrapped.

Raises:

RuntimeError – When the gh invocation fails (see run_gh_command()).

repomatic.github.gh.iter_graphql_nodes(query, connection_path, variables=None, *, page_size_var='', page_size=0, max_nodes=None)[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. Strings are passed with -f; ints and bools with -F, so they keep their GraphQL type.

  • 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. The last page shrinks to the max_nodes remainder so the budget is never over-fetched.

  • max_nodes (int | None) – Stop after yielding this many nodes. None means every node in the connection.

Yields:

Each node dict, in API order.

Raises:

RuntimeError – When a gh invocation fails (see run_gh_command()).