Skip to content

Add a minimal GitHub client for workflow scripts - #14806

Draft
ArmandPhilippot wants to merge 22 commits into
mainfrom
armand/github-client
Draft

ArmandPhilippot wants to merge 22 commits into
mainfrom
armand/github-client

Conversation

@ArmandPhilippot

@ArmandPhilippot ArmandPhilippot commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

The "umbrella PR assistant" idea is bigger than I thought and folding everything in a single PR is hard to review. So, I have this PR to create a minimal GitHub client, and the release workflow is built on that branch. The diff is still "big" but there are a lot of tests.

Description (required)

Adds a small GitHub REST client in scripts/lib/github/ for our workflow scripts:

  • this is useful for the release queue, which reads and changes PRs on withastro/docs and reads withastro/astro (and every methods was designed for this workflow)
  • even though this is more limited, weekly-contributors.ts could use it to avoid repeating the fetch, and to validate the sape.

Why not Octokit:

  • No new dependency. the package would have access to write tokens, and this means one more package to keep updated. Node's built-in fetch already covers what we need.
  • Size. we don't need the whole API (octokit is ~11MB; @octokit/rest is ~7MB).
  • Responses are checked. Octokit types responses but doesn't check them at run time. Here, each method checks the fields it returns and drops the others, so a response with an unexpected shape fails right away, naming the endpoint and the field (Unexpected response from GET …/pulls/7: `head.repo.full_name` should be a string), instead of failing later.

The client can create two different instances:

  • repository.ts: a GitHubRepository with the read and write methods.
  • read-only-repository.ts: sends reads and logs writes. This is useful for local runs (e.g. a dry run mode).

There is one module that is not directly related and could be extracted from lib/github/: shape.ts. A minimal "validator". I hesitated to use Zod, but Florian has a PR to decouple Astro from it, and it seemed a bit "heavy" for the use case.

This includes tests (and that's why the diff might look big): they're meant for local debugging, so CI doesn't run them. I added a new script in package.json to run them easily: pnpm test:scripts.

References

Add `GitHubRepository`, a client for the GitHub REST API without
Octokit. Each instance acts on one repository: it reads reviews, checks,
open pull requests, comparisons, and permissions, and it creates, labels,
updates, merges, comments on, and requests changes on pull requests.

Read methods check the fields they return with small type guards, and
return only those fields. Branch names are encoded in URLs. Each write
sends a single request, so a script can override the writes for a dry
run.
`head.repo` tells a branch of the repository apart from a fork branch
with the same name. It is `null` when the fork was deleted.
`ReadOnlyRepository` sends reads and logs writes instead of sending
them. Its test fails when the client gets a method that is neither
listed as a read nor overridden, so a new write cannot reach GitHub
during a local run.
Only the endpoint for one pull request sends `mergeable`, so
`getPullRequest` returns `PullRequestDetails`, and the list keeps
`PullRequest`. `mergeable` is `null` until GitHub has checked for
conflicts.
`getFileContent` reads the text of a file on a ref. It returns `null`
when GitHub answers "Not Found", which it does for a missing file, ref,
or repository. It throws for a directory, and for a file over 1 MB,
which GitHub sends without its content.
`getBranchSha` reads the commit at the head of a branch, or returns
`null` when GitHub answers "Not Found". `createBranch` creates a branch
at a commit.
GitHub filters the open pull requests by owner and head branch, so a
script finds the pull request of a branch in one request, and a fork's
branch with the same name is left out.
`updateReview` changes the text of a review and keeps its state, so a
script can keep its own review current instead of submitting a new one.
`Review` gains `body`, so a script can compare the current text first.
`PullRequestDetails` gains `merged`. `updatePullRequest` can change the
title, and `mergePullRequest` can set the title of the merge commit.
A shape checks a response and returns a copy with only the fields it
lists, so each field is written once: the types come from the shapes,
and the functions that copied each response are gone. Errors now name
the field that does not match.
`findPullRequest` returns `null` when GitHub answers "Not Found", like
`getFileContent` and `getBranchSha`. The three methods and
`hasWriteAccess` now share the handling of that answer.
`Repository` is the public shape of `GitHubRepository`, so a test fake
can implement it. `hasLabel` tells whether a pull request has a label,
without mapping its labels to names first.
GitHub checks a pull request for conflicts in the background, so
`mergeable` is `null` until it has. `waitForMergeability` asks again a
few times before giving up. Tests pass their own `wait`, so they do not
sleep.
A reviewer's state on a pull request is their latest approval, change
request, or dismissal: comments and pending reviews change nothing.
`getLatestReviewStates` applies that rule to every reviewer.
`updateReview(prNumber, { reviewId, body })` matches `updatePullRequest`
and `mergePullRequest`, which take the pull request number and an
object.
`toChecks` only reduces the check runs and commit statuses to outcomes.
`getChecks` checks that each list fits in one page, since it knows the
ref the error names.
`sendRequest` and `getAllPages` take the shape of the response, so the
request line for errors is built in one place, from the URL, and always
without its query. Writes reuse the same request method.
Editors and type errors showed a derived type by the alias that builds
it, with the shape of each field. They now show the fields and their
types, without `readonly`, like the types written by hand.
The path only names fields in errors, so reading a whole body no longer
passes an empty string.
The shapes of the check runs and of the commit statuses refuse a list
with more items than one page, so the error names the request, ref
included, and `getChecks` no longer checks the lists itself.
`shape.matches` tells whether a value matches a shape without throwing,
so error bodies, where any field can be missing, are read with shapes
too. `isRecord` is no longer exported.
This was referenced Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Preview deployment

✅ Deployment complete!

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant