Octokit REST client with MSW: pagination, early stop and errors: working versions (tested 2026-10-15)
Test an @octokit/rest client offline with MSW handlers for api.github.com: paginate through Link headers, stop paging early with done(), create issues with auth, and map 404 RequestErrors.
passed4 passed · 0 failed2026-10-15 20:09 UTC- Status
- passed
- Last passed
- 2026-10-15 20:09 UTC
- Tests
- 4 / 4
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38799498972
The code
Exactly the files that passed. Your agent can pull them with get_recipe("octokit-rest-msw-pagination") (install), or see them on GitHub.
{
"name": "octokit-rest-msw-pagination",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"@octokit/rest": "22.0.1"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15",
"msw": "3.0.3"
}
}
import { Octokit } from "@octokit/rest";
export function makeClient(token: string, baseUrl = "https://api.github.com") {
return new Octokit({ auth: token, baseUrl, userAgent: "provenpin-recipe/1.0", request: { retries: 0 } });
}
/** All open issues (not PRs) across every page, following the Link header. */
export async function listOpenIssues(gh: Octokit, owner: string, repo: string) {
const items = await gh.paginate(gh.rest.issues.listForRepo, { owner, repo, state: "open", per_page: 2 });
return items.filter((i) => !i.pull_request).map((i) => ({ number: i.number, title: i.title }));
}
/** Stop paging early once we find a label, using paginate's done() callback. */
export async function firstWithLabel(gh: Octokit, owner: string, repo: string, label: string) {
let hit: number | null = null;
await gh.paginate(gh.rest.issues.listForRepo, { owner, repo, per_page: 2 }, (res, done) => {
const f = res.data.find((i) => i.labels.some((l) => (typeof l === "string" ? l : l.name) === label));
if (f) { hit = f.number; done(); }
return [];
});
return hit;
}
export async function openIssue(gh: Octokit, owner: string, repo: string, title: string, labels: string[]) {
const { data, status } = await gh.rest.issues.create({ owner, repo, title, labels });
return { status, number: data.number, url: data.html_url };
}
export async function repoExists(gh: Octokit, owner: string, repo: string) {
try {
await gh.rest.repos.get({ owner, repo });
return true;
} catch (e) {
if ((e as { status?: number }).status === 404) return false;
throw e;
}
}
import { test, before, after, afterEach } from "node:test";
import assert from "node:assert/strict";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import { makeClient, listOpenIssues, firstWithLabel, openIssue, repoExists } from "../src/github.ts";
const API = "https://api.github.com";
const all = [
{ number: 1, title: "one", labels: [] },
{ number: 2, title: "two (pr)", labels: [], pull_request: { url: "x" } },
{ number: 3, title: "three", labels: [{ name: "bug" }] },
{ number: 4, title: "four", labels: [] },
{ number: 5, title: "five", labels: [{ name: "docs" }] },
];
const pagesServed: number[] = [];
const server = setupServer(
http.get(`${API}/repos/acme/app/issues`, ({ request }) => {
const u = new URL(request.url);
const page = Number(u.searchParams.get("page") ?? 1), per = Number(u.searchParams.get("per_page"));
pagesServed.push(page);
const last = Math.ceil(all.length / per);
const link = (p: number, rel: string) => `<${API}/repos/acme/app/issues?per_page=${per}&page=${p}>; rel="${rel}"`;
const links = [page < last && link(page + 1, "next"), link(last, "last")].filter(Boolean).join(", ");
return HttpResponse.json(all.slice((page - 1) * per, page * per), { headers: { link: links } });
}),
http.post(`${API}/repos/acme/app/issues`, async ({ request }) => {
assert.equal(request.headers.get("authorization"), "token test-token");
const body = (await request.json()) as { title: string; labels: string[] };
return HttpResponse.json({ number: 42, title: body.title, labels: body.labels, html_url: "https://github.com/acme/app/issues/42" }, { status: 201 });
}),
http.get(`${API}/repos/acme/:repo`, ({ params }) =>
params.repo === "app" ? HttpResponse.json({ id: 1, name: "app" }) : HttpResponse.json({ message: "Not Found" }, { status: 404 }),
),
);
before(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => { server.resetHandlers(); pagesServed.length = 0; });
after(() => server.close());
test("paginate follows Link headers across all pages", async () => {
const issues = await listOpenIssues(makeClient("test-token"), "acme", "app");
assert.deepEqual(issues.map((i) => i.number), [1, 3, 4, 5]);
assert.deepEqual(pagesServed, [1, 2, 3]);
});
test("done() stops paging early", async () => {
assert.equal(await firstWithLabel(makeClient("t"), "acme", "app", "bug"), 3);
assert.deepEqual(pagesServed, [1, 2]);
});
test("create sends auth and JSON body", async () => {
assert.deepEqual(await openIssue(makeClient("test-token"), "acme", "app", "Broken link", ["bug"]), {
status: 201, number: 42, url: "https://github.com/acme/app/issues/42",
});
});
test("404 surfaces as RequestError status", async () => {
const gh = makeClient("t");
assert.equal(await repoExists(gh, "acme", "app"), true);
assert.equal(await repoExists(gh, "acme", "gone"), false);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
graphql-request client with typed operations, mocked by MSW graphql handlers
Call a GraphQL API with graphql-request (variables, headers, error handling) and test offline with MSW's graphql.link(url).query/mutation handlers (msw 3 API). graphql stays on 16.x: graphql-request 7 peers on graphql 14 - 16.
ky HTTP client with hooks and retries, tested with MSW + Vitest
A ky 2 API client (prefix option, state-object hooks) with auth hooks, retry on 5xx and typed JSON, tested offline with MSW's Node server in Vitest.
ofetch HTTP client mocked with MSW
Call APIs with ofetch against an MSW 2 HTTP worker in Node, covering JSON responses, query params and error status mapping offline.
openapi-fetch typed client with MSW
Call a typed OpenAPI paths client with openapi-fetch against MSW handlers, covering GET/POST and error status offline.