import type {
GitHubAccount,
GitHubAccountInfo,
GitHubGraphqlError,
GitHubGraphqlOptions,
GitHubGraphqlResponse,
GitHubHeaders,
GitHubPaginateOptions,
GitHubPaginationResult,
GitHubQuery,
GitHubRequestOptions,
GitHubResponse,
GitHubViewer,
} from "./types"
export const GITHUB_API_BASE_URL = "https://api.github.com"
export const DEFAULT_GITHUB_ACCOUNT: GitHubAccount = "bot"
const githubAccounts = [
{
account: "bot",
integration: "github-bot",
label: "kody-bot GitHub account",
default: true,
useWhen: "Default for every GitHub call. Routine automation, agent PRs, issue comments, repository reads, and normal API work as kody-bot.",
avoidWhen: "Only skip this account when the user explicitly asks to act as Kent C. Dodds / kentcdodds.",
mutationGuidance: "Prefer this account for writes after the user has asked for the GitHub mutation. Do not switch to Kent because a repo is private or because the bot might lack a permission.",
},
{
account: "kent",
integration: "github-kent",
label: "Kent C. Dodds GitHub account",
default: false,
useWhen: "Only when the user explicitly asks to use Kent C. Dodds / kentcdodds. Naming Kent, asking to act as Kent, or passing account: 'kent' counts.",
avoidWhen: "Every implicit, routine, or inferred GitHub task. Private repos, org permissions, or 'the bot might not have access' are not enough.",
mutationGuidance: "Do not choose this account by implication. Require an explicit user instruction to act as Kent C. Dodds.",
},
] as const satisfies readonly GitHubAccountInfo[]
const selectedHeaderNames = [
"link",
"x-github-request-id",
"x-ratelimit-limit",
"x-ratelimit-remaining",
"x-ratelimit-reset",
"x-oauth-scopes",
"x-accepted-oauth-scopes",
] as const
export class GitHubRequestError<TData = unknown> extends Error {
readonly response: GitHubResponse<TData>
constructor(response: GitHubResponse<TData>) {
super(buildErrorMessage(response))
this.name = "GitHubRequestError"
this.response = response
}
}
export class GitHubRequestTimeoutError extends Error {
readonly account: GitHubAccount
readonly url: string
readonly timeoutMs: number | undefined
constructor(options: {
account: GitHubAccount
url: string
timeoutMs?: number
cause?: unknown
}) {
const budget =
typeof options.timeoutMs === "number"
? " after " + options.timeoutMs + "ms"
: ""
super("GitHub request timed out" + budget + ": " + options.url, {
cause: options.cause,
})
this.name = "GitHubRequestTimeoutError"
this.account = options.account
this.url = options.url
this.timeoutMs = options.timeoutMs
}
}
/**
* Return the supported GitHub account aliases and guidance for choosing one.
*/
export function accounts(): readonly GitHubAccountInfo[] {
return githubAccounts
}
/**
* Return documentation for one GitHub account alias.
*/
export function getAccountInfo(account: GitHubAccount = DEFAULT_GITHUB_ACCOUNT): GitHubAccountInfo {
const info = githubAccounts.find((candidate) => candidate.account === account)
if (!info) {
throw new Error("Unsupported GitHub account: " + String(account))
}
return info
}
/**
* Resolve the saved Kody OAuth integration name for an account alias.
*
* - `bot` (default) → `github-bot` (kody-bot)
* - `kent` → `github-kent` (Kent C. Dodds). Require an explicit user request.
*/
export function resolveIntegrationName(
account: GitHubAccount = DEFAULT_GITHUB_ACCOUNT,
): "github-bot" | "github-kent" {
return getAccountInfo(account).integration
}
/**
* Make an authenticated GitHub REST API request with account-aware credentials.
*
* The helper supplies GitHub API headers, parses JSON and text responses,
* returns selected rate-limit headers, and can throw GitHubRequestError when
* throwOnError is true. Pass optional `timeoutMs` or `signal` to bound the
* fetch; there is no default timeout so ordinary reads stay unbounded.
*/
export async function request<TData = unknown>(
options: GitHubRequestOptions,
): Promise<GitHubResponse<TData>> {
if (typeof options?.path !== "string" || options.path.length === 0) {
throw new Error('GitHub request requires a non-empty string "path" param (for example "/repos/owner/repo").')
}
const account = options.account ?? DEFAULT_GITHUB_ACCOUNT
const url = buildUrl(options.path, options.query)
const method = (options.method ?? (options.body === undefined ? "GET" : "POST")).toUpperCase()
const headers = new Headers(options.headers)
headers.set("Accept", headers.get("Accept") ?? "application/vnd.github+json")
headers.set("Authorization", getAuthorizationHeader(account))
headers.set("X-GitHub-Api-Version", headers.get("X-GitHub-Api-Version") ?? "2022-11-28")
headers.set("User-Agent", headers.get("User-Agent") ?? "kody-github")
const init: RequestInit = {
method,
headers,
signal: resolveAbortSignal(options),
}
if (options.body !== undefined) {
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
init.body = typeof options.body === "string" ? options.body : JSON.stringify(options.body)
}
let fetchResponse: Response
try {
fetchResponse = await fetch(url, init)
} catch (error) {
if (isAbortError(error) && options.timeoutMs !== undefined) {
throw new GitHubRequestTimeoutError({
account,
url,
timeoutMs: options.timeoutMs,
cause: error,
})
}
throw error
}
const text = await fetchResponse.text()
const data = parseResponseBody<TData>(text, fetchResponse.headers)
const response: GitHubResponse<TData> = {
account,
url,
ok: fetchResponse.ok,
status: fetchResponse.status,
statusText: fetchResponse.statusText,
headers: collectHeaders(fetchResponse.headers),
data,
text,
}
if (!response.ok && options.throwOnError) {
throw new GitHubRequestError(response)
}
return response
}
/**
* Make an authenticated GitHub GraphQL request with the selected account.
*/
export async function graphql<TData = unknown, TVariables extends Record<string, unknown> = Record<string, unknown>>(
options: GitHubGraphqlOptions<TVariables>,
): Promise<GitHubGraphqlResponse<TData>> {
if (typeof options?.query !== "string" || options.query.trim().length === 0) {
throw new Error('GitHub graphql requires a non-empty string "query" param.')
}
const response = await request<{ data?: TData | null; errors?: GitHubGraphqlError[] }>({
account: options.account,
path: "/graphql",
method: "POST",
headers: options.headers,
body: {
query: options.query,
variables: options.variables ?? {},
},
throwOnError: options.throwOnError,
signal: options.signal,
timeoutMs: options.timeoutMs,
})
const payload = response.data ?? {}
const result: GitHubGraphqlResponse<TData> = {
...response,
data: payload.data ?? null,
errors: payload.errors,
}
if (options.throwOnError && result.errors && result.errors.length > 0) {
throw new GitHubRequestError({
...response,
ok: false,
data: payload,
})
}
return result
}
/**
* Follow GitHub REST Link headers for endpoints that return JSON arrays.
*/
export async function paginate<TItem = unknown>(
options: GitHubPaginateOptions,
): Promise<GitHubPaginationResult<TItem>> {
const account = options.account ?? DEFAULT_GITHUB_ACCOUNT
const maxPages = options.maxPages ?? 20
const items: TItem[] = []
let nextPath: string | null = options.path
let pages = 0
let lastResponse: GitHubResponse<unknown> | null = null
while (nextPath && pages < maxPages) {
const response = await request<unknown>({
account,
path: nextPath,
method: options.method ?? "GET",
query: pages === 0 ? options.query : undefined,
headers: options.headers,
throwOnError: true,
})
if (!Array.isArray(response.data)) {
throw new Error("GitHub pagination requires endpoints that return a JSON array.")
}
items.push(...(response.data as TItem[]))
pages += 1
lastResponse = response
nextPath = getNextLink(response.headers.link)
}
return { account, items, pages, lastResponse }
}
/**
* Fetch the authenticated GitHub viewer for a selected account.
*/
export async function getViewer(options: { account?: GitHubAccount } = {}): Promise<GitHubViewer> {
const account = options.account ?? DEFAULT_GITHUB_ACCOUNT
const response = await request<{
login: string
id: number
type: string
name: string | null
email: string | null
html_url: string
}>({
account,
path: "/user",
throwOnError: true,
})
if (!response.data) {
throw new Error("GitHub viewer response did not include a JSON body.")
}
return {
account,
login: response.data.login,
id: response.data.id,
type: response.data.type,
name: response.data.name,
email: response.data.email,
htmlUrl: response.data.html_url,
}
}
function getAuthorizationHeader(account: GitHubAccount): string {
switch (account) {
case "bot":
return "Bearer {{secret:github-botAccessToken|scope=user}}"
case "kent":
return "Bearer {{secret:github-kentAccessToken|scope=user}}"
default: {
const exhaustive: never = account
throw new Error("Unsupported GitHub account: " + String(exhaustive))
}
}
}
function buildUrl(path: string, query?: GitHubQuery): string {
let url = normalizeApiPath(path)
if (!query) return url
const searchParams = new URLSearchParams()
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) continue
searchParams.set(key, String(value))
}
const queryString = searchParams.toString()
if (!queryString) return url
url += url.includes("?") ? "&" : "?"
return url + queryString
}
function normalizeApiPath(path: string): string {
if (path.startsWith(GITHUB_API_BASE_URL + "/")) {
return path
}
if (path.startsWith("http://") || path.startsWith("https://")) {
throw new Error("GitHub toolkit only accepts api.github.com URLs or API paths.")
}
return GITHUB_API_BASE_URL + (path.startsWith("/") ? path : "/" + path)
}
function parseResponseBody<TData>(text: string, headers: Headers): TData | null {
if (!text) return null
const contentType = headers.get("content-type") ?? ""
if (contentType.includes("json") || text.startsWith("{") || text.startsWith("[")) {
return JSON.parse(text) as TData
}
return text as TData
}
function collectHeaders(headers: Headers): GitHubHeaders {
const collected: GitHubHeaders = {}
for (const name of selectedHeaderNames) {
const value = headers.get(name)
if (value) collected[name] = value
}
return collected
}
function getNextLink(linkHeader: string | undefined): string | null {
if (!linkHeader) return null
for (const part of linkHeader.split(",")) {
const match = part.match(/<([^>]+)>;\s*rel="next"/)
if (match) return match[1] ?? null
}
return null
}
function resolveAbortSignal(options: {
signal?: AbortSignal
timeoutMs?: number
}): AbortSignal | undefined {
const signals: AbortSignal[] = []
if (options.signal) signals.push(options.signal)
if (options.timeoutMs !== undefined) {
if (typeof options.timeoutMs !== "number" || !Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error("timeoutMs must be a positive number of milliseconds")
}
signals.push(AbortSignal.timeout(options.timeoutMs))
}
if (signals.length === 0) return undefined
if (signals.length === 1) return signals[0]
return AbortSignal.any(signals)
}
function isAbortError(error: unknown): boolean {
if (!error || typeof error !== "object") return false
const name = "name" in error ? String((error as { name?: unknown }).name) : ""
return name === "AbortError" || name === "TimeoutError"
}
function buildErrorMessage(response: GitHubResponse<unknown>): string {
const message = extractGitHubMessage(response.data)
return message
? "GitHub request failed with " + response.status + ": " + message
: "GitHub request failed with " + response.status + " " + response.statusText
}
function extractGitHubMessage(data: unknown): string | null {
if (data && typeof data === "object" && "message" in data) {
const message = (data as { message?: unknown }).message
return typeof message === "string" ? message : null
}
return null
}