Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/github

src/core.ts

403 lines · 12.3 KB · TypeScript
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
}