diff --git a/.changeset/rate-limit-on-results.md b/.changeset/rate-limit-on-results.md
new file mode 100644
index 0000000..2d57f66
--- /dev/null
+++ b/.changeset/rate-limit-on-results.md
@@ -0,0 +1,5 @@
+---
+'@github-tools/sdk': minor
+---
+
+Object-shaped tool results now include `rateLimit` (`remaining`, `limit`, `reset`, `resource`) from the last GitHub response. The field is stripped before the model sees the output. Array-shaped list tools are unchanged. 403/429 errors include remaining/reset in the message.
diff --git a/apps/chat/app/components/tool/Github.vue b/apps/chat/app/components/tool/Github.vue
index 04f1912..e3e5af6 100644
--- a/apps/chat/app/components/tool/Github.vue
+++ b/apps/chat/app/components/tool/Github.vue
@@ -1,4 +1,5 @@
@@ -48,6 +81,7 @@ const context = computed(() => {
{{ label }}
{{ context }}
+ {{ rateLimitLabel }}
diff --git a/apps/docs/content/docs/2.frameworks/1.eve-extension.md b/apps/docs/content/docs/2.frameworks/1.eve-extension.md
index 4dd504d..2d87cec 100644
--- a/apps/docs/content/docs/2.frameworks/1.eve-extension.md
+++ b/apps/docs/content/docs/2.frameworks/1.eve-extension.md
@@ -210,6 +210,8 @@ export default githubExtension({
The extension registers each tool with an **authored inline** `execute` and `toModelOutput` that only close over a serializable tool `name`, then rebuilds session options from the extension config on every call via `@github-tools/sdk/eve-runtime`. Tools resolve on `step.started` so registration stays fresh across durable steps. That pattern survives multi-turn eve Workflow replay (see [#51](https://github.com/vercel-labs/github-tools/issues/51), [#99](https://github.com/vercel-labs/github-tools/issues/99)). Prefer this mount over the deprecated [`createGithubTools`](/deprecated/eve) / [`connectGithubTools`](/deprecated/eve) paths for Slack / multi-turn durable agents — those register tools from inside `node_modules` and are skipped on replay. Author `overrides.toModelOutput` inline in the agent; a function imported from a library will not get a durable descriptor.
+Object-shaped execute results include `rateLimit` (`remaining`, `limit`, `reset`, `resource`). `toModelOutput` strips it so the model never sees the remaining count; `toolResultFrom` and channels still do. See [Rate-limit metadata](/api/reference#rate-limit-metadata).
+
## Durable approval, done right
Approval **pauses the session durably** until a human responds, and policies are expressive:
diff --git a/apps/docs/content/docs/5.api/2.reference.md b/apps/docs/content/docs/5.api/2.reference.md
index a056c4e..9529002 100644
--- a/apps/docs/content/docs/5.api/2.reference.md
+++ b/apps/docs/content/docs/5.api/2.reference.md
@@ -156,6 +156,18 @@ Supported override properties:
Core properties (`execute`, `inputSchema`, `outputSchema`) cannot be overridden.
+### Rate-limit metadata
+
+Object-shaped tool results include a `rateLimit` field from the last GitHub response (`x-ratelimit-remaining`, `x-ratelimit-limit`, `x-ratelimit-reset`, `x-ratelimit-resource`, and `retry-after` when present). Array-shaped results (`listIssues`, `listPullRequests`, …) are unchanged. The field is stripped before the model sees the output (`toModelOutput`); hooks, channels, and the chat UI still receive it.
+
+```ts [rate-limit.ts]
+import type { GithubRateLimit } from '@github-tools/sdk'
+
+const remaining = (result as { rateLimit?: GithubRateLimit }).rateLimit?.remaining
+```
+
+`resource` is `core`, `search`, or `graphql` — search is the tightest bucket. On HTTP 403/429 the thrown error message also includes remaining/reset.
+
### Commit attribution
The `author`, `committer`, and `coAuthors` options control how commits are attributed when using `createOrUpdateFile` or `mergePullRequest`:
diff --git a/apps/docs/skills/github-tools-agents/SKILL.md b/apps/docs/skills/github-tools-agents/SKILL.md
index 19561b0..4fd769a 100644
--- a/apps/docs/skills/github-tools-agents/SKILL.md
+++ b/apps/docs/skills/github-tools-agents/SKILL.md
@@ -125,7 +125,7 @@ Array presets merge: `preset: ['code-review', 'issue-triage']`. Start with the s
## Working context
-Pass `context: { owner, repo, pullNumber?, issueNumber?, ref? }` to `createGithubTools` / `createGithubAgent` / `createDurableGithubAgent` to default those fields on tool inputs and inject them into the agent system prompt. Prefer composite tools (`getPullRequestContext`, `getIssueContext`, `getReleaseContext`, `getCiFailureContext`) for multi-part reads — call follow-up reads in the same step when possible. Diff patches are omitted by default — set `includePatch: true` (optionally with `filenames`) when you need specific diffs. Bodies are truncated by default (`detail: 'summary'`). `getIssueContext` returns `labelNames` (strings) rather than full label objects. Prefer `getFileContent` with `startLine`/`endLine` or `maxLines` for large files.
+Pass `context: { owner, repo, pullNumber?, issueNumber?, ref? }` to `createGithubTools` / `createGithubAgent` / `createDurableGithubAgent` to default those fields on tool inputs and inject them into the agent system prompt. Prefer composite tools (`getPullRequestContext`, `getIssueContext`, `getReleaseContext`, `getCiFailureContext`) for multi-part reads — call follow-up reads in the same step when possible. Diff patches are omitted by default — set `includePatch: true` (optionally with `filenames`) when you need specific diffs. Bodies are truncated by default (`detail: 'summary'`). `getIssueContext` returns `labelNames` (strings) rather than full label objects. Prefer `getFileContent` with `startLine`/`endLine` or `maxLines` for large files. Object-shaped execute results include `rateLimit` (`remaining` / `limit` / `reset` / `resource`); it is stripped from the model-facing output. Array-shaped list tools do not carry it. On 403/429 the error text includes remaining/reset.
## Write safety
diff --git a/apps/docs/skills/github-tools-agents/references/eve-extension.md b/apps/docs/skills/github-tools-agents/references/eve-extension.md
index f322bf2..e95309f 100644
--- a/apps/docs/skills/github-tools-agents/references/eve-extension.md
+++ b/apps/docs/skills/github-tools-agents/references/eve-extension.md
@@ -55,7 +55,7 @@ export default githubExtension({
})
```
-Built-in `toModelOutput` formatters are applied via an inline callback that only closes over the tool name. Author `overrides.toModelOutput` inline in the agent — a library function will not get a durable descriptor on eve 0.44+.
+Built-in `toModelOutput` formatters are applied via an inline callback that only closes over the tool name. That callback also strips `rateLimit` from the model-facing payload. Author `overrides.toModelOutput` inline in the agent — a library function will not get a durable descriptor on eve 0.44+.
## Approval
diff --git a/packages/github-tools-eve-extension/README.md b/packages/github-tools-eve-extension/README.md
index bd21b21..45c08b2 100644
--- a/packages/github-tools-eve-extension/README.md
+++ b/packages/github-tools-eve-extension/README.md
@@ -43,7 +43,7 @@ export default githubExtension({
> `code-review` pairs cleanly with a Connect `connector`. `maintainer` and `repo-explorer` include gist tools, and GitHub only grants gist access to user access tokens, never the installation tokens Connect mints, so gist calls 403 over Connect. Write tools already require approval via `always()` by default, so a plain `{ someTool: true }` is a no-op, use a predicate (as above) when you actually want to narrow or loosen the default.
-Tools are registered with **inline** `execute` and `toModelOutput` handlers in the extension package so they survive multi-turn durable eve Workflow replay (see [#51](https://github.com/vercel-labs/github-tools/issues/51), [#99](https://github.com/vercel-labs/github-tools/issues/99)). Do not use the deprecated `@github-tools/sdk/connect/eve` one-liner for durable Slack/multi-turn agents.
+Tools are registered with **inline** `execute` and `toModelOutput` handlers in the extension package so they survive multi-turn durable eve Workflow replay (see [#51](https://github.com/vercel-labs/github-tools/issues/51), [#99](https://github.com/vercel-labs/github-tools/issues/99)). `toModelOutput` strips `rateLimit` from the model-facing payload; the execute result still carries it for hooks and channels. Do not use the deprecated `@github-tools/sdk/connect/eve` one-liner for durable Slack/multi-turn agents.
`connector` also accepts a `() => string | Promise` resolver, so the same config can pick a connector dynamically (e.g. by environment):
diff --git a/packages/github-tools-eve-extension/extension/tools/github.ts b/packages/github-tools-eve-extension/extension/tools/github.ts
index 49edcd9..79c9952 100644
--- a/packages/github-tools-eve-extension/extension/tools/github.ts
+++ b/packages/github-tools-eve-extension/extension/tools/github.ts
@@ -2,7 +2,6 @@ import { connectGithubToken } from '@github-tools/sdk/connect'
import {
executeGithubEveTool,
formatGithubEveToolOutput,
- hasGithubEveToolModelOutput,
isEveApprovalDisabled,
listEveToolDescriptors,
mapEveApprovalValue,
@@ -113,9 +112,7 @@ export default defineDynamic({
}),
...(override?.toModelOutput !== undefined
? { toModelOutput: override.toModelOutput }
- : hasGithubEveToolModelOutput(name)
- ? { toModelOutput: (output: unknown) => formatGithubEveToolOutput(name, output) }
- : {}),
+ : { toModelOutput: (output: unknown) => formatGithubEveToolOutput(name, output) }),
...(override?.outputSchema !== undefined && {
outputSchema: override.outputSchema,
}),
diff --git a/packages/github-tools/README.md b/packages/github-tools/README.md
index 6aeab93..b2c8ec6 100644
--- a/packages/github-tools/README.md
+++ b/packages/github-tools/README.md
@@ -186,6 +186,18 @@ Supported override properties:
Core properties (`execute`, `inputSchema`, `outputSchema`) cannot be overridden.
+## Rate-limit metadata
+
+Object-shaped tool results include a `rateLimit` field from the last GitHub response. Array-shaped results are unchanged. The field is stripped before the model sees the output; hooks, channels, and UIs still receive it.
+
+```ts
+import type { GithubRateLimit } from '@github-tools/sdk'
+
+result.rateLimit?.remaining
+```
+
+`resource` is `core`, `search`, or `graphql`. On HTTP 403/429 the thrown error message also includes remaining/reset.
+
## Commit Attribution
Control how commits are attributed when using `createOrUpdateFile` or `mergePullRequest`:
diff --git a/packages/github-tools/src/client.test.ts b/packages/github-tools/src/client.test.ts
index f1db221..17ef719 100644
--- a/packages/github-tools/src/client.test.ts
+++ b/packages/github-tools/src/client.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { GITHUB_API_VERSION, createOctokit } from './client'
+import { finishGithubResult, peekGithubRateLimit } from './core/rate-limit'
describe('createOctokit', () => {
it('sets X-GitHub-Api-Version on REST requests', async () => {
@@ -27,4 +28,65 @@ describe('createOctokit', () => {
expect(seen.at(-1)).toBe(GITHUB_API_VERSION)
})
+
+ it('records rate-limit headers on a successful request', async () => {
+ const octokit = createOctokit('ghp_test')
+ octokit.request = octokit.request.defaults({
+ request: {
+ fetch: async () => new Response(JSON.stringify({
+ resources: { core: { limit: 5000, remaining: 38, reset: 1774800000, used: 0 } },
+ rate: { limit: 5000, remaining: 38, reset: 1774800000, used: 0 },
+ }), {
+ status: 200,
+ headers: {
+ 'content-type': 'application/json',
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-remaining': '38',
+ 'x-ratelimit-reset': '1774800000',
+ 'x-ratelimit-resource': 'core',
+ },
+ }),
+ },
+ })
+
+ await octokit.rest.rateLimit.get()
+
+ expect(peekGithubRateLimit(octokit)).toEqual({
+ remaining: 38,
+ limit: 5000,
+ reset: 1774800000,
+ resource: 'core',
+ })
+ expect(finishGithubResult(octokit, { ok: true })).toEqual({
+ ok: true,
+ rateLimit: {
+ remaining: 38,
+ limit: 5000,
+ reset: 1774800000,
+ resource: 'core',
+ },
+ })
+ })
+
+ it('appends rate-limit state on a 403', async () => {
+ const octokit = createOctokit('ghp_test')
+ octokit.request = octokit.request.defaults({
+ request: {
+ fetch: async () => new Response(JSON.stringify({ message: 'API rate limit exceeded' }), {
+ status: 403,
+ headers: {
+ 'content-type': 'application/json',
+ 'x-ratelimit-limit': '30',
+ 'x-ratelimit-remaining': '0',
+ 'x-ratelimit-reset': '1774800000',
+ 'x-ratelimit-resource': 'search',
+ },
+ }),
+ },
+ })
+
+ await expect(octokit.rest.search.code({ q: 'test' })).rejects.toThrow(
+ 'GitHub rate limit search: 0/30 remaining, resets at 1774800000',
+ )
+ })
})
diff --git a/packages/github-tools/src/client.ts b/packages/github-tools/src/client.ts
index 1f4e178..2b8833c 100644
--- a/packages/github-tools/src/client.ts
+++ b/packages/github-tools/src/client.ts
@@ -1,8 +1,20 @@
import { Octokit } from 'octokit'
+import {
+ enrichGithubRateLimitError,
+ finishGithubResult,
+ parseGithubRateLimit,
+ recordGithubRateLimit,
+} from './core/rate-limit'
/** @see https://docs.github.com/en/rest/about-the-rest-api/api-versions */
export const GITHUB_API_VERSION = '2026-03-10'
+function errorResponseHeaders(error: unknown): Record | undefined {
+ if (error == null || typeof error !== 'object' || !('response' in error)) return undefined
+ const response = (error as { response?: { headers?: Record } }).response
+ return response?.headers
+}
+
export function createOctokit(token: string): Octokit {
const octokit = new Octokit({ auth: token })
@@ -13,5 +25,22 @@ export function createOctokit(token: string): Octokit {
}
})
+ octokit.hook.after('request', (response) => {
+ recordGithubRateLimit(octokit, response.headers)
+ })
+
+ octokit.hook.error('request', (error) => {
+ const rateLimit = parseGithubRateLimit(errorResponseHeaders(error))
+ if (rateLimit) recordGithubRateLimit(octokit, errorResponseHeaders(error))
+ throw enrichGithubRateLimitError(error, rateLimit)
+ })
+
return octokit
}
+
+/** Run a GitHub `*Core` body and attach `rateLimit` from this Octokit instance. */
+export async function withOctokit(token: string, fn: (octokit: Octokit) => Promise): Promise {
+ const octokit = createOctokit(token)
+ const result = await fn(octokit)
+ return finishGithubResult(octokit, result)
+}
diff --git a/packages/github-tools/src/core/bundles.ts b/packages/github-tools/src/core/bundles.ts
index fe5744d..23dbc6f 100644
--- a/packages/github-tools/src/core/bundles.ts
+++ b/packages/github-tools/src/core/bundles.ts
@@ -1,4 +1,5 @@
import { z } from 'zod'
+import { withComposedRateLimit } from './rate-limit'
import { getCombinedStatusCore, listCheckRunsCore } from './checks'
import { compareCommitsCore } from './commits'
import { detailSchema, type DetailLevel } from './detail'
@@ -77,12 +78,12 @@ export async function getPullRequestContextCore({
: Promise.resolve(undefined),
])
- return {
+ return withComposedRateLimit({
pullRequest,
...files !== undefined ? { files } : {},
...reviews !== undefined ? { reviews } : {},
...checks !== undefined ? { checks } : {},
- }
+ })
}
export const getIssueContextInputSchema = z.object({
@@ -139,12 +140,12 @@ export async function getIssueContextCore({
: Promise.resolve(undefined),
])
- return {
+ return withComposedRateLimit({
issue,
// Names only — full label objects (color/description) dominate triage payloads on large repos
...labels !== undefined ? { labelNames: labels.map(label => label.name) } : {},
...comments !== undefined ? { comments } : {},
- }
+ })
}
export const getReleaseContextInputSchema = z.object({
@@ -204,11 +205,11 @@ export async function getReleaseContextCore({
})
}
- return {
+ return withComposedRateLimit({
release,
...previous !== undefined ? { previousRelease: previous } : {},
...comparison !== undefined ? { comparison } : {},
- }
+ })
}
export const getCiFailureContextInputSchema = z.object({
@@ -311,12 +312,12 @@ export async function getCiFailureContextCore({
latestFailure = { run: latestFailedRun, jobs: failedJobs }
}
- return {
+ return withComposedRateLimit({
ref,
combinedStatus,
failedCheckRuns,
checkRunTotalCount: checkRunsResult.totalCount,
recentFailedRuns: failedRuns.runs,
...latestFailure !== undefined ? { latestFailure } : {},
- }
+ })
}
diff --git a/packages/github-tools/src/core/checks.ts b/packages/github-tools/src/core/checks.ts
index 2ca5fc7..b9a5c74 100644
--- a/packages/github-tools/src/core/checks.ts
+++ b/packages/github-tools/src/core/checks.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { createOctokit } from '../client'
+import { withOctokit } from '../client'
import { fetchAllPages, maxPagesSchema } from './pagination'
export const listCheckRunsInputSchema = z.object({
@@ -13,7 +13,7 @@ export const listCheckRunsInputSchema = z.object({
export const listCheckRunsDescription = 'List check runs (Checks API — GitHub Actions and other CI providers) for a commit, branch, or tag'
export async function listCheckRunsCore({ token, owner, repo, ref, perPage, maxPages }: { token: string, owner: string, repo: string, ref: string, perPage: number, maxPages?: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
let totalCount = 0
const checkRuns = await fetchAllPages(async page => {
const { data } = await octokit.rest.checks.listForRef({ owner, repo, ref, per_page: perPage, page })
@@ -32,6 +32,7 @@ export async function listCheckRunsCore({ token, owner, repo, ref, perPage, maxP
completedAt: run.completed_at,
})),
}
+ })
}
export const getCombinedStatusInputSchema = z.object({
@@ -43,7 +44,7 @@ export const getCombinedStatusInputSchema = z.object({
export const getCombinedStatusDescription = 'Get the combined commit status (Statuses API — legacy CI integrations) for a commit, branch, or tag'
export async function getCombinedStatusCore({ token, owner, repo, ref }: { token: string, owner: string, repo: string, ref: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.repos.getCombinedStatusForRef({ owner, repo, ref })
return {
state: data.state,
@@ -55,4 +56,5 @@ export async function getCombinedStatusCore({ token, owner, repo, ref }: { token
url: status.target_url,
})),
}
+ })
}
diff --git a/packages/github-tools/src/core/commits.ts b/packages/github-tools/src/core/commits.ts
index 309fa5f..a7c399e 100644
--- a/packages/github-tools/src/core/commits.ts
+++ b/packages/github-tools/src/core/commits.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { createOctokit } from '../client'
+import { withOctokit } from '../client'
import { fetchAllPages, maxPagesSchema } from './pagination'
export const BLAME_QUERY = `
@@ -78,7 +78,7 @@ export const listCommitsDescription =
'List commits for a GitHub repository. Filter by file path to see commits that touched a file. For line-by-line attribution at a given ref, use getBlame instead.'
export async function listCommitsCore({ token, owner, repo, path, sha, author, since, until, perPage, maxPages }: { token: string, owner: string, repo: string, path?: string, sha?: string, author?: string, since?: string, until?: string, perPage: number, maxPages?: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const commits = await fetchAllPages(async page => {
const { data } = await octokit.rest.repos.listCommits({
owner,
@@ -101,6 +101,7 @@ export async function listCommitsCore({ token, owner, repo, path, sha, author, s
date: commit.commit.author?.date,
url: commit.html_url,
}))
+ })
}
export const getCommitInputSchema = z.object({
@@ -113,7 +114,7 @@ export const getCommitInputSchema = z.object({
export const getCommitDescription = 'Get detailed information about a specific commit, including the list of files changed with additions and deletions. Patches are omitted by default — set includePatch true to include diffs'
export async function getCommitCore({ token, owner, repo, ref, includePatch }: { token: string, owner: string, repo: string, ref: string, includePatch: boolean }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.repos.getCommit({ owner, repo, ref })
return {
sha: data.sha,
@@ -135,6 +136,7 @@ export async function getCommitCore({ token, owner, repo, ref, includePatch }: {
...includePatch && file.patch != null ? { patch: file.patch } : {},
})),
}
+ })
}
export const getBlameInputSchema = z.object({
@@ -169,7 +171,7 @@ export const getBlameDescription =
'Line-level git blame for a file at a commit-like ref (branch, tag, or SHA). Returns contiguous ranges mapping lines to the commits that last modified them — use this to see who introduced a line and when (GitHub GraphQL API).'
export async function getBlameCore({ token, owner, repo, path, ref, line, lineStart, lineEnd }: { token: string, owner: string, repo: string, path: string, ref?: string, line?: number, lineStart?: number, lineEnd?: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
let expression = ref
if (!expression) {
const { data } = await octokit.rest.repos.get({ owner, repo })
@@ -226,6 +228,7 @@ export async function getBlameCore({ token, owner, repo, path, ref, line, lineSt
rangeCount: ranges.length,
ranges,
}
+ })
}
export const compareCommitsInputSchema = z.object({
@@ -239,7 +242,7 @@ export const compareCommitsInputSchema = z.object({
export const compareCommitsDescription = 'Compare two branches, tags, or commits — shows ahead/behind counts, the commits in between, and the files that differ. Patches are omitted by default — set includePatch true to include diffs'
export async function compareCommitsCore({ token, owner, repo, base, head, includePatch }: { token: string, owner: string, repo: string, base: string, head: string, includePatch: boolean }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.repos.compareCommitsWithBasehead({ owner, repo, basehead: `${base}...${head}` })
return {
status: data.status,
@@ -261,4 +264,5 @@ export async function compareCommitsCore({ token, owner, repo, base, head, inclu
...includePatch && file.patch != null ? { patch: file.patch } : {},
})),
}
+ })
}
diff --git a/packages/github-tools/src/core/discussions.ts b/packages/github-tools/src/core/discussions.ts
index 21d1ee7..1ab2cbc 100644
--- a/packages/github-tools/src/core/discussions.ts
+++ b/packages/github-tools/src/core/discussions.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { createOctokit } from '../client'
+import { withOctokit } from '../client'
import { applyDetailBody, detailSchema, type DetailLevel } from './detail'
import type { Octokit } from '../types'
@@ -163,7 +163,7 @@ export const listDiscussionsInputSchema = z.object({
export const listDiscussionsDescription = 'List discussions in a GitHub repository, most recently updated first, optionally filtered by category (GitHub GraphQL API)'
export async function listDiscussionsCore({ token, owner, repo, category, perPage, after }: { token: string, owner: string, repo: string, category?: string, perPage: number, after?: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
let categoryId: string | undefined
if (category) {
@@ -198,6 +198,7 @@ export async function listDiscussionsCore({ token, owner, repo, category, perPag
updatedAt: node.updatedAt,
})),
}
+ })
}
export const getDiscussionInputSchema = z.object({
@@ -210,7 +211,7 @@ export const getDiscussionInputSchema = z.object({
export const getDiscussionDescription = 'Get a GitHub discussion by number. Body is truncated by default (detail: summary) — set detail full for the complete text (GitHub GraphQL API)'
export async function getDiscussionCore({ token, owner, repo, discussionNumber, detail = 'summary' }: { token: string, owner: string, repo: string, discussionNumber: number, detail?: DetailLevel }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const data = (await octokit.graphql(GET_DISCUSSION_QUERY, {
owner,
name: repo,
@@ -233,6 +234,7 @@ export async function getDiscussionCore({ token, owner, repo, discussionNumber,
createdAt: discussion.createdAt,
updatedAt: discussion.updatedAt,
}
+ })
}
export const addDiscussionCommentInputSchema = z.object({
@@ -246,7 +248,7 @@ export const addDiscussionCommentDescription = 'Add a comment to a GitHub discus
/** Not idempotent — each call adds another comment. */
export async function addDiscussionCommentCore({ token, owner, repo, discussionNumber, body }: { token: string, owner: string, repo: string, discussionNumber: number, body: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const lookup = (await octokit.graphql(DISCUSSION_ID_QUERY, {
owner,
name: repo,
@@ -269,4 +271,5 @@ export async function addDiscussionCommentCore({ token, owner, repo, discussionN
author: comment.author?.login ?? null,
createdAt: comment.createdAt,
}
+ })
}
diff --git a/packages/github-tools/src/core/gists.ts b/packages/github-tools/src/core/gists.ts
index 3e20d0c..669e21c 100644
--- a/packages/github-tools/src/core/gists.ts
+++ b/packages/github-tools/src/core/gists.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { createOctokit } from '../client'
+import { withOctokit } from '../client'
export const listGistsInputSchema = z.object({
username: z.string().optional().describe('GitHub username — omit to list your own gists'),
@@ -10,7 +10,7 @@ export const listGistsInputSchema = z.object({
export const listGistsDescription = 'List gists for the authenticated user or a specific user'
export async function listGistsCore({ token, username, perPage, page }: { token: string, username?: string, perPage: number, page: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = username
? await octokit.rest.gists.listForUser({ username, per_page: perPage, page })
: await octokit.rest.gists.list({ per_page: perPage, page })
@@ -25,6 +25,7 @@ export async function listGistsCore({ token, username, perPage, page }: { token:
createdAt: gist.created_at,
updatedAt: gist.updated_at,
}))
+ })
}
export const getGistInputSchema = z.object({
@@ -34,7 +35,7 @@ export const getGistInputSchema = z.object({
export const getGistDescription = 'Get a gist by ID, including file contents'
export async function getGistCore({ token, gistId }: { token: string, gistId: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.gists.get({ gist_id: gistId })
return {
id: data.id,
@@ -52,6 +53,7 @@ export async function getGistCore({ token, gistId }: { token: string, gistId: st
createdAt: data.created_at,
updatedAt: data.updated_at,
}
+ })
}
export const listGistCommentsInputSchema = z.object({
@@ -63,7 +65,7 @@ export const listGistCommentsInputSchema = z.object({
export const listGistCommentsDescription = 'List comments on a gist'
export async function listGistCommentsCore({ token, gistId, perPage, page }: { token: string, gistId: string, perPage: number, page: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.gists.listComments({ gist_id: gistId, per_page: perPage, page })
return data.map(comment => ({
id: comment.id,
@@ -73,6 +75,7 @@ export async function listGistCommentsCore({ token, gistId, perPage, page }: { t
createdAt: comment.created_at,
updatedAt: comment.updated_at,
}))
+ })
}
export const createGistInputSchema = z.object({
@@ -86,7 +89,7 @@ export const createGistDescription = 'Create a new gist with one or more files'
/** Not idempotent — each call creates a new gist. */
export async function createGistCore({ token, description, files, isPublic }: { token: string, description?: string, files: Record, isPublic: boolean }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.gists.create({
description,
files,
@@ -100,6 +103,7 @@ export async function createGistCore({ token, description, files, isPublic }: {
files: Object.keys(data.files ?? {}),
owner: data.owner?.login,
}
+ })
}
export const updateGistInputSchema = z.object({
@@ -114,7 +118,7 @@ export const updateGistDescription = 'Update an existing gist — edit descripti
/** Not idempotent — each call applies a new revision. */
export async function updateGistCore({ token, gistId, description, files, filesToDelete }: { token: string, gistId: string, description?: string, files?: Record, filesToDelete?: string[] }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const fileUpdates: Record = {}
if (files) Object.assign(fileUpdates, files)
if (filesToDelete) {
@@ -131,6 +135,7 @@ export async function updateGistCore({ token, gistId, description, files, filesT
url: data.html_url,
files: Object.keys(data.files ?? {}),
}
+ })
}
export const deleteGistInputSchema = z.object({
@@ -141,9 +146,10 @@ export const deleteGistDescription = 'Delete a gist permanently'
/** Not idempotent — deleting an already-deleted gist returns 404 from GitHub. */
export async function deleteGistCore({ token, gistId }: { token: string, gistId: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
await octokit.rest.gists.delete({ gist_id: gistId })
return { deleted: true, gistId }
+ })
}
export const createGistCommentInputSchema = z.object({
@@ -155,7 +161,7 @@ export const createGistCommentDescription = 'Add a comment to a gist'
/** Not idempotent — each call adds another comment. */
export async function createGistCommentCore({ token, gistId, body }: { token: string, gistId: string, body: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.gists.createComment({ gist_id: gistId, body })
return {
id: data.id,
@@ -164,4 +170,5 @@ export async function createGistCommentCore({ token, gistId, body }: { token: st
author: data.user?.login,
createdAt: data.created_at,
}
+ })
}
diff --git a/packages/github-tools/src/core/issues.ts b/packages/github-tools/src/core/issues.ts
index d5d520d..893775e 100644
--- a/packages/github-tools/src/core/issues.ts
+++ b/packages/github-tools/src/core/issues.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { createOctokit } from '../client'
+import { withOctokit } from '../client'
import { applyDetailBody, detailSchema, type DetailLevel } from './detail'
import { fetchAllPages, maxPagesSchema } from './pagination'
@@ -15,7 +15,7 @@ export const listIssuesInputSchema = z.object({
export const listIssuesDescription = 'List issues for a GitHub repository (excludes pull requests)'
export async function listIssuesCore({ token, owner, repo, state, labels, perPage, maxPages }: { token: string, owner: string, repo: string, state: 'open' | 'closed' | 'all', labels?: string, perPage: number, maxPages?: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const issues = await fetchAllPages(async page => {
const { data } = await octokit.rest.issues.listForRepo({
owner,
@@ -39,6 +39,7 @@ export async function listIssuesCore({ token, owner, repo, state, labels, perPag
createdAt: issue.created_at,
updatedAt: issue.updated_at,
}))
+ })
}
export const getIssueInputSchema = z.object({
@@ -51,7 +52,7 @@ export const getIssueInputSchema = z.object({
export const getIssueDescription = 'Get detailed information about a specific issue. Body is truncated by default (detail: summary) — set detail full for the complete description'
export async function getIssueCore({ token, owner, repo, issueNumber, detail = 'summary' }: { token: string, owner: string, repo: string, issueNumber: number, detail?: DetailLevel }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber })
return {
number: data.number,
@@ -67,6 +68,7 @@ export async function getIssueCore({ token, owner, repo, issueNumber, detail = '
updatedAt: data.updated_at,
closedAt: data.closed_at,
}
+ })
}
export const listIssueCommentsInputSchema = z.object({
@@ -81,7 +83,7 @@ export const listIssueCommentsInputSchema = z.object({
export const listIssueCommentsDescription = 'List comments on a GitHub issue. Bodies are truncated by default (detail: summary)'
export async function listIssueCommentsCore({ token, owner, repo, issueNumber, perPage, page, detail = 'summary' }: { token: string, owner: string, repo: string, issueNumber: number, perPage: number, page: number, detail?: DetailLevel }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page: perPage, page })
return data.map(comment => ({
id: comment.id,
@@ -91,6 +93,7 @@ export async function listIssueCommentsCore({ token, owner, repo, issueNumber, p
createdAt: comment.created_at,
updatedAt: comment.updated_at,
}))
+ })
}
export const createIssueInputSchema = z.object({
@@ -106,7 +109,7 @@ export const createIssueDescription = 'Create a new issue in a GitHub repository
/** Not idempotent — each call creates a new issue. */
export async function createIssueCore({ token, owner, repo, title, body, labels, assignees }: { token: string, owner: string, repo: string, title: string, body?: string, labels?: string[], assignees?: string[] }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.create({ owner, repo, title, body, labels, assignees })
return {
number: data.number,
@@ -115,6 +118,7 @@ export async function createIssueCore({ token, owner, repo, title, body, labels,
state: data.state,
labels: data.labels.map(l => (typeof l === 'string' ? l : l.name)),
}
+ })
}
export const addIssueCommentInputSchema = z.object({
@@ -128,7 +132,7 @@ export const addIssueCommentDescription = 'Add a comment to a GitHub issue'
/** Not idempotent — each call adds another comment. */
export async function addIssueCommentCore({ token, owner, repo, issueNumber, body }: { token: string, owner: string, repo: string, issueNumber: number, body: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body })
return {
id: data.id,
@@ -137,6 +141,7 @@ export async function addIssueCommentCore({ token, owner, repo, issueNumber, bod
author: data.user?.login,
createdAt: data.created_at,
}
+ })
}
export const closeIssueInputSchema = z.object({
@@ -150,7 +155,7 @@ export const closeIssueDescription = 'Close an open GitHub issue'
/** Idempotent when the issue is already closed. */
export async function closeIssueCore({ token, owner, repo, issueNumber, stateReason }: { token: string, owner: string, repo: string, issueNumber: number, stateReason: 'completed' | 'not_planned' }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data: existing } = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber })
if (existing.state === 'closed') {
return {
@@ -176,6 +181,7 @@ export async function closeIssueCore({ token, owner, repo, issueNumber, stateRea
url: data.html_url,
closedAt: data.closed_at,
}
+ })
}
export const updateIssueInputSchema = z.object({
@@ -195,7 +201,7 @@ export const updateIssueDescription = 'Update a GitHub issue — title, body, la
/** Not idempotent — each call applies a new revision. */
export async function updateIssueCore({ token, owner, repo, issueNumber, title, body, state, stateReason, labels, milestone, assignees }: { token: string, owner: string, repo: string, issueNumber: number, title?: string, body?: string, state?: 'open' | 'closed', stateReason?: 'completed' | 'not_planned' | 'reopened', labels?: string[], milestone?: number | null, assignees?: string[] }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.update({
owner,
repo,
@@ -219,6 +225,7 @@ export async function updateIssueCore({ token, owner, repo, issueNumber, title,
closedAt: data.closed_at,
updatedAt: data.updated_at,
}
+ })
}
export const updateIssueCommentInputSchema = z.object({
@@ -232,7 +239,7 @@ export const updateIssueCommentDescription = 'Update the body of a comment on a
/** Not idempotent — each call applies a new revision. */
export async function updateIssueCommentCore({ token, owner, repo, commentId, body }: { token: string, owner: string, repo: string, commentId: number, body: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.updateComment({ owner, repo, comment_id: commentId, body })
return {
id: data.id,
@@ -240,6 +247,7 @@ export async function updateIssueCommentCore({ token, owner, repo, commentId, bo
body: data.body,
updatedAt: data.updated_at,
}
+ })
}
export const deleteIssueCommentInputSchema = z.object({
@@ -252,9 +260,10 @@ export const deleteIssueCommentDescription = 'Delete a comment from a GitHub iss
/** Not idempotent — deleting an already-deleted comment returns 404 from GitHub. */
export async function deleteIssueCommentCore({ token, owner, repo, commentId }: { token: string, owner: string, repo: string, commentId: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
await octokit.rest.issues.deleteComment({ owner, repo, comment_id: commentId })
return { deleted: true, commentId }
+ })
}
export const listLabelsInputSchema = z.object({
@@ -267,13 +276,14 @@ export const listLabelsInputSchema = z.object({
export const listLabelsDescription = 'List labels available in a GitHub repository'
export async function listLabelsCore({ token, owner, repo, perPage, page }: { token: string, owner: string, repo: string, perPage: number, page: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.listLabelsForRepo({ owner, repo, per_page: perPage, page })
return data.map(label => ({
name: label.name,
color: label.color,
description: label.description,
}))
+ })
}
export const addLabelsInputSchema = z.object({
@@ -287,13 +297,14 @@ export const addLabelsDescription = 'Add labels to an issue or pull request'
/** Not idempotent — re-adding labels is a no-op on GitHub but still mutates. */
export async function addLabelsCore({ token, owner, repo, issueNumber, labels }: { token: string, owner: string, repo: string, issueNumber: number, labels: string[] }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels })
return data.map(label => ({
name: label.name,
color: label.color,
description: label.description,
}))
+ })
}
export const removeLabelInputSchema = z.object({
@@ -307,9 +318,10 @@ export const removeLabelDescription = 'Remove a label from an issue or pull requ
/** Not idempotent — removing a missing label returns 404 from GitHub. */
export async function removeLabelCore({ token, owner, repo, issueNumber, label }: { token: string, owner: string, repo: string, issueNumber: number, label: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
await octokit.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name: label })
return { removed: true, label, issueNumber }
+ })
}
export const createLabelInputSchema = z.object({
@@ -324,13 +336,14 @@ export const createLabelDescription = 'Create a label in a GitHub repository'
/** Not idempotent — creating a label that already exists returns 422 from GitHub. */
export async function createLabelCore({ token, owner, repo, name, color, description }: { token: string, owner: string, repo: string, name: string, color: string, description?: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.createLabel({ owner, repo, name, color, description })
return {
name: data.name,
color: data.color,
description: data.description,
}
+ })
}
export const updateLabelInputSchema = z.object({
@@ -346,7 +359,7 @@ export const updateLabelDescription = 'Update a label in a GitHub repository —
/** Not idempotent — each call applies a new revision. */
export async function updateLabelCore({ token, owner, repo, name, newName, color, description }: { token: string, owner: string, repo: string, name: string, newName?: string, color?: string, description?: string | null }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.updateLabel({
owner,
repo,
@@ -360,6 +373,7 @@ export async function updateLabelCore({ token, owner, repo, name, newName, color
color: data.color,
description: data.description,
}
+ })
}
export const deleteLabelInputSchema = z.object({
@@ -372,9 +386,10 @@ export const deleteLabelDescription = 'Delete a label from a GitHub repository p
/** Not idempotent — deleting a missing label returns 404 from GitHub. */
export async function deleteLabelCore({ token, owner, repo, name }: { token: string, owner: string, repo: string, name: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
await octokit.rest.issues.deleteLabel({ owner, repo, name })
return { deleted: true, name }
+ })
}
export const addAssigneesInputSchema = z.object({
@@ -388,12 +403,13 @@ export const addAssigneesDescription = 'Assign users to an issue or pull request
/** Not idempotent — re-adding an existing assignee is a no-op on GitHub but still mutates. */
export async function addAssigneesCore({ token, owner, repo, issueNumber, assignees }: { token: string, owner: string, repo: string, issueNumber: number, assignees: string[] }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.addAssignees({ owner, repo, issue_number: issueNumber, assignees })
return {
number: data.number,
assignees: data.assignees?.map(a => a.login),
}
+ })
}
export const removeAssigneesInputSchema = z.object({
@@ -407,10 +423,11 @@ export const removeAssigneesDescription = 'Remove assignees from an issue or pul
/** Idempotent — removing an assignee that is not assigned is a no-op on GitHub. */
export async function removeAssigneesCore({ token, owner, repo, issueNumber, assignees }: { token: string, owner: string, repo: string, issueNumber: number, assignees: string[] }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.removeAssignees({ owner, repo, issue_number: issueNumber, assignees })
return {
number: data.number,
assignees: data.assignees?.map(a => a.login),
}
+ })
}
diff --git a/packages/github-tools/src/core/notifications.ts b/packages/github-tools/src/core/notifications.ts
index 89fb06a..5ddb925 100644
--- a/packages/github-tools/src/core/notifications.ts
+++ b/packages/github-tools/src/core/notifications.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { createOctokit } from '../client'
+import { withOctokit } from '../client'
export const listNotificationsInputSchema = z.object({
all: z.boolean().optional().default(false).describe('Include notifications already marked as read'),
@@ -11,7 +11,7 @@ export const listNotificationsInputSchema = z.object({
export const listNotificationsDescription = 'List notification threads for the authenticated user. Unread only by default — set all true to include read threads. Requires a token with notifications access'
export async function listNotificationsCore({ token, all, participating, perPage, page }: { token: string, all: boolean, participating: boolean, perPage: number, page: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.activity.listNotificationsForAuthenticatedUser({
all,
participating,
@@ -30,6 +30,7 @@ export async function listNotificationsCore({ token, all, participating, perPage
unread: thread.unread,
updatedAt: thread.updated_at,
}))
+ })
}
export const markNotificationReadInputSchema = z.object({
@@ -40,7 +41,8 @@ export const markNotificationReadDescription = 'Mark a single notification threa
/** Idempotent — marking an already-read thread is a no-op on GitHub. */
export async function markNotificationReadCore({ token, threadId }: { token: string, threadId: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
await octokit.rest.activity.markThreadAsRead({ thread_id: Number(threadId) })
return { marked: true, threadId }
+ })
}
diff --git a/packages/github-tools/src/core/pull-requests.ts b/packages/github-tools/src/core/pull-requests.ts
index 8bcb1f6..f3195f5 100644
--- a/packages/github-tools/src/core/pull-requests.ts
+++ b/packages/github-tools/src/core/pull-requests.ts
@@ -1,5 +1,5 @@
import { z } from 'zod'
-import { createOctokit } from '../client'
+import { withOctokit } from '../client'
import type { CommitIdentity } from '../types'
import { applyDetailBody, detailSchema, type DetailLevel } from './detail'
import { fetchAllPages, maxPagesSchema } from './pagination'
@@ -16,7 +16,7 @@ export const listPullRequestsInputSchema = z.object({
export const listPullRequestsDescription = 'List pull requests for a GitHub repository'
export async function listPullRequestsCore({ token, owner, repo, state, perPage, maxPages }: { token: string, owner: string, repo: string, state: 'open' | 'closed' | 'all', perPage: number, maxPages?: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const pullRequests = await fetchAllPages(async page => {
const { data } = await octokit.rest.pulls.list({ owner, repo, state, per_page: perPage, page })
return data
@@ -33,6 +33,7 @@ export async function listPullRequestsCore({ token, owner, repo, state, perPage,
createdAt: pr.created_at,
updatedAt: pr.updated_at,
}))
+ })
}
export const getPullRequestInputSchema = z.object({
@@ -45,7 +46,7 @@ export const getPullRequestInputSchema = z.object({
export const getPullRequestDescription = 'Get detailed information about a specific pull request. Body is truncated by default (detail: summary) — set detail full for the complete description'
export async function getPullRequestCore({ token, owner, repo, pullNumber, detail = 'summary' }: { token: string, owner: string, repo: string, pullNumber: number, detail?: DetailLevel }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.pulls.get({ owner, repo, pull_number: pullNumber })
return {
number: data.number,
@@ -67,6 +68,7 @@ export async function getPullRequestCore({ token, owner, repo, pullNumber, detai
updatedAt: data.updated_at,
mergedAt: data.merged_at,
}
+ })
}
export const createPullRequestInputSchema = z.object({
@@ -83,7 +85,7 @@ export const createPullRequestDescription = 'Create a new pull request in a GitH
/** Not idempotent — each call creates a new pull request. */
export async function createPullRequestCore({ token, owner, repo, title, body, head, base, draft }: { token: string, owner: string, repo: string, title: string, body?: string, head: string, base: string, draft: boolean }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.pulls.create({ owner, repo, title, body, head, base, draft })
return {
number: data.number,
@@ -94,6 +96,7 @@ export async function createPullRequestCore({ token, owner, repo, title, body, h
branch: data.head.ref,
base: data.base.ref,
}
+ })
}
const MARK_READY_FOR_REVIEW_MUTATION = `
@@ -127,7 +130,7 @@ export const updatePullRequestDescription = 'Update a pull request — title, bo
/** Not idempotent — each call applies a new revision. */
export async function updatePullRequestCore({ token, owner, repo, pullNumber, title, body, state, base, draft }: { token: string, owner: string, repo: string, pullNumber: number, title?: string, body?: string, state?: 'open' | 'closed', base?: string, draft?: boolean }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const hasRestUpdate = title !== undefined || body !== undefined || state !== undefined || base !== undefined
const { data } = hasRestUpdate
? await octokit.rest.pulls.update({ owner, repo, pull_number: pullNumber, title, body, state, base })
@@ -146,6 +149,7 @@ export async function updatePullRequestCore({ token, owner, repo, pullNumber, ti
base: data.base.ref,
updatedAt: data.updated_at,
}
+ })
}
export const mergePullRequestInputSchema = z.object({
@@ -179,7 +183,7 @@ export async function mergePullRequestCore({
mergeMethod: 'merge' | 'squash' | 'rebase'
coAuthors?: CommitIdentity[]
}) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const finalMessage = composeCommitMessage(commitMessage ?? '', coAuthors) || undefined
const { data } = await octokit.rest.pulls.merge({
owner,
@@ -194,6 +198,7 @@ export async function mergePullRequestCore({
message: data.message,
sha: data.sha,
}
+ })
}
export const addPullRequestCommentInputSchema = z.object({
@@ -207,7 +212,7 @@ export const addPullRequestCommentDescription = 'Add a comment to a pull request
/** Not idempotent — each call adds another comment. */
export async function addPullRequestCommentCore({ token, owner, repo, pullNumber, body }: { token: string, owner: string, repo: string, pullNumber: number, body: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.createComment({ owner, repo, issue_number: pullNumber, body })
return {
id: data.id,
@@ -216,6 +221,7 @@ export async function addPullRequestCommentCore({ token, owner, repo, pullNumber
author: data.user?.login,
createdAt: data.created_at,
}
+ })
}
export const updatePullRequestCommentInputSchema = z.object({
@@ -229,7 +235,7 @@ export const updatePullRequestCommentDescription = 'Update the body of a comment
/** Not idempotent — each call applies a new revision. */
export async function updatePullRequestCommentCore({ token, owner, repo, commentId, body }: { token: string, owner: string, repo: string, commentId: number, body: string }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.issues.updateComment({ owner, repo, comment_id: commentId, body })
return {
id: data.id,
@@ -237,6 +243,7 @@ export async function updatePullRequestCommentCore({ token, owner, repo, comment
body: data.body,
updatedAt: data.updated_at,
}
+ })
}
export const deletePullRequestCommentInputSchema = z.object({
@@ -249,9 +256,10 @@ export const deletePullRequestCommentDescription = 'Delete a comment from a pull
/** Not idempotent — deleting an already-deleted comment returns 404 from GitHub. */
export async function deletePullRequestCommentCore({ token, owner, repo, commentId }: { token: string, owner: string, repo: string, commentId: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
await octokit.rest.issues.deleteComment({ owner, repo, comment_id: commentId })
return { deleted: true, commentId }
+ })
}
export const listPullRequestFilesInputSchema = z.object({
@@ -267,7 +275,7 @@ export const listPullRequestFilesInputSchema = z.object({
export const listPullRequestFilesDescription = 'List files changed in a pull request with status and stats. Patches are omitted by default — set includePatch true (optionally with filenames) to fetch diffs'
export async function listPullRequestFilesCore({ token, owner, repo, pullNumber, includePatch, filenames, perPage, page }: { token: string, owner: string, repo: string, pullNumber: number, includePatch: boolean, filenames?: string[], perPage: number, page: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.pulls.listFiles({ owner, repo, pull_number: pullNumber, per_page: perPage, page })
const filenameSet = filenames?.length ? new Set(filenames) : null
return data
@@ -280,6 +288,7 @@ export async function listPullRequestFilesCore({ token, owner, repo, pullNumber,
changes: file.changes,
...includePatch && file.patch != null ? { patch: file.patch } : {},
}))
+ })
}
export const listPullRequestReviewsInputSchema = z.object({
@@ -293,7 +302,7 @@ export const listPullRequestReviewsInputSchema = z.object({
export const listPullRequestReviewsDescription = 'List reviews on a pull request (approvals, change requests, and comments)'
export async function listPullRequestReviewsCore({ token, owner, repo, pullNumber, perPage, page }: { token: string, owner: string, repo: string, pullNumber: number, perPage: number, page: number }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.pulls.listReviews({ owner, repo, pull_number: pullNumber, per_page: perPage, page })
return data.map(review => ({
id: review.id,
@@ -303,6 +312,7 @@ export async function listPullRequestReviewsCore({ token, owner, repo, pullNumbe
url: review.html_url,
submittedAt: review.submitted_at,
}))
+ })
}
export const createPullRequestReviewInputSchema = z.object({
@@ -323,7 +333,7 @@ export const createPullRequestReviewDescription = 'Submit a pull request review
/** Not idempotent — each call submits a new review. */
export async function createPullRequestReviewCore({ token, owner, repo, pullNumber, body, event, comments }: { token: string, owner: string, repo: string, pullNumber: number, body?: string, event: 'APPROVE' | 'REQUEST_CHANGES' | 'COMMENT', comments?: Array<{ path: string, body: string, line?: number, side?: 'LEFT' | 'RIGHT' }> }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.pulls.createReview({
owner,
repo,
@@ -340,6 +350,7 @@ export async function createPullRequestReviewCore({ token, owner, repo, pullNumb
author: data.user?.login,
submittedAt: data.submitted_at,
}
+ })
}
export const requestReviewersInputSchema = z.object({
@@ -354,7 +365,7 @@ export const requestReviewersDescription = 'Request reviews from users or teams
/** Not idempotent — re-requesting an existing reviewer is a no-op on GitHub but still mutates. */
export async function requestReviewersCore({ token, owner, repo, pullNumber, reviewers, teamReviewers }: { token: string, owner: string, repo: string, pullNumber: number, reviewers?: string[], teamReviewers?: string[] }) {
- const octokit = createOctokit(token)
+ return withOctokit(token, async (octokit) => {
const { data } = await octokit.rest.pulls.requestReviewers({
owner,
repo,
@@ -368,4 +379,5 @@ export async function requestReviewersCore({ token, owner, repo, pullNumber, rev
requestedReviewers: data.requested_reviewers?.map(r => r.login),
requestedTeams: data.requested_teams?.map(t => t.slug),
}
+ })
}
diff --git a/packages/github-tools/src/core/rate-limit.test.ts b/packages/github-tools/src/core/rate-limit.test.ts
new file mode 100644
index 0000000..8ba802c
--- /dev/null
+++ b/packages/github-tools/src/core/rate-limit.test.ts
@@ -0,0 +1,97 @@
+import { describe, expect, it } from 'vitest'
+import {
+ enrichGithubRateLimitError,
+ finishGithubResult,
+ parseGithubRateLimit,
+ recordGithubRateLimit,
+ stripRateLimit,
+ withComposedRateLimit,
+} from './rate-limit'
+
+const headers = {
+ 'x-ratelimit-remaining': '38',
+ 'x-ratelimit-limit': '5000',
+ 'x-ratelimit-reset': '1774800000',
+ 'x-ratelimit-resource': 'core',
+}
+
+const rateLimit = {
+ remaining: 38,
+ limit: 5000,
+ reset: 1774800000,
+ resource: 'core',
+}
+
+describe('parseGithubRateLimit', () => {
+ it('reads remaining, limit, reset, and resource', () => {
+ expect(parseGithubRateLimit(headers)).toEqual(rateLimit)
+ })
+
+ it('includes retry-after when present', () => {
+ expect(parseGithubRateLimit({ ...headers, 'retry-after': '12' })).toEqual({
+ ...rateLimit,
+ retryAfter: 12,
+ })
+ })
+
+ it('returns undefined when the required headers are missing', () => {
+ expect(parseGithubRateLimit({ 'content-type': 'application/json' })).toBeUndefined()
+ })
+})
+
+describe('stripRateLimit', () => {
+ it('drops rateLimit from a plain object', () => {
+ expect(stripRateLimit({ name: 'hello-world', rateLimit })).toEqual({
+ name: 'hello-world',
+ })
+ })
+
+ it('leaves arrays unchanged', () => {
+ const branches = [{ name: 'main' }]
+ expect(stripRateLimit(branches)).toBe(branches)
+ })
+})
+
+describe('finishGithubResult', () => {
+ it('attaches rateLimit on plain objects', () => {
+ const owner = {}
+ recordGithubRateLimit(owner, headers)
+ expect(finishGithubResult(owner, { name: 'hello-world' })).toEqual({
+ name: 'hello-world',
+ rateLimit,
+ })
+ })
+
+ it('does not wrap arrays', () => {
+ const owner = {}
+ recordGithubRateLimit(owner, headers)
+ expect(finishGithubResult(owner, [{ name: 'main' }])).toEqual([{ name: 'main' }])
+ })
+})
+
+describe('enrichGithubRateLimitError', () => {
+ it('appends remaining/reset on 403 errors', () => {
+ const error = Object.assign(new Error('API rate limit exceeded'), { status: 403 })
+ const parsed = parseGithubRateLimit({
+ ...headers,
+ 'x-ratelimit-remaining': '0',
+ 'x-ratelimit-resource': 'search',
+ })
+ expect(() => {
+ throw enrichGithubRateLimitError(error, parsed)
+ }).toThrow('GitHub rate limit search: 0/5000 remaining, resets at 1774800000')
+ })
+})
+
+describe('withComposedRateLimit', () => {
+ it('lifts a nested rateLimit onto the composed object and strips children', () => {
+ expect(withComposedRateLimit({
+ pullRequest: { number: 1, rateLimit },
+ files: [{ filename: 'a.ts' }],
+ })).toEqual({
+ pullRequest: { number: 1 },
+ files: [{ filename: 'a.ts' }],
+ rateLimit,
+ })
+ })
+})
diff --git a/packages/github-tools/src/core/rate-limit.ts b/packages/github-tools/src/core/rate-limit.ts
new file mode 100644
index 0000000..b4fa8ad
--- /dev/null
+++ b/packages/github-tools/src/core/rate-limit.ts
@@ -0,0 +1,126 @@
+/**
+ * GitHub REST/GraphQL rate-limit snapshot from the last request in a tool call.
+ * Present on object-shaped execute results; stripped before the model sees the output.
+ */
+export type GithubRateLimit = {
+ remaining: number
+ limit: number
+ reset: number
+ resource?: string
+ retryAfter?: number
+}
+
+const snapshots = new WeakMap