Skip to content
← Public packages

@kody/github

Call GitHub REST, GraphQL, and pull requests with a GitHub OAuth App or a personal access token.

src/core.ts

621 lines · 20.1 KB · TypeScript
import { createAuthenticatedFetch } from "kody:runtime"
import type {
  GitHubAuthInput,
  GitHubAuthLaneInfo,
  GitHubGraphqlError,
  GitHubGraphqlOptions,
  GitHubGraphqlResponse,
  GitHubHeaders,
  GitHubPaginateOptions,
  GitHubPaginationResult,
  GitHubQuery,
  GitHubRequestOptions,
  GitHubResolvedAuth,
  GitHubResponse,
  GitHubViewer,
} from "./types"

export const GITHUB_API_BASE_URL = "https://api.github.com"
export const GITHUB_API_HOST = "api.github.com"
export const GITHUB_UPLOADS_HOST = "uploads.github.com"
export const DEFAULT_GITHUB_INTEGRATION_NAME = "github"
export const DEFAULT_GITHUB_SECRET_NAME = "githubAccessToken"

export const GITHUB_OAUTH_CONNECT_URL =
  "https://kody.codes/connect/oauth?provider=github"

export const GITHUB_PAT_SETUP_URL =
  "https://kody.codes/account/secrets/new?name=githubAccessToken&description=GitHub%20fine-grained%20personal%20access%20token&allowedHosts=api.github.com%2Cuploads.github.com&scope=user"

export const GITHUB_BYO_OAUTH_CONNECT_URL =
  "https://kody.codes/connect/oauth?provider=github&authorizeUrl=https%3A%2F%2Fgithub.com%2Flogin%2Foauth%2Fauthorize&tokenUrl=https%3A%2F%2Fgithub.com%2Flogin%2Foauth%2Faccess_token&flow=confidential&scopes=read%3Auser%20repo%20user%3Aemail&allowedHosts=api.github.com%2Cuploads.github.com"

export const BUILTIN_GITHUB_DEFAULT_SCOPES = ["read:user", "repo", "user:email"] as const

export const BUILTIN_GITHUB_ALLOWED_SCOPES = [
  "gist",
  "notifications",
  "read:org",
  "read:user",
  "repo",
  "user:email",
  "workflow",
] as const

const RETIRED_ACCOUNT_ALIASES = ["bot"] as const

const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"])

const githubAuthLanes = [
  {
    lane: "oauth",
    default: true,
    integrationName: DEFAULT_GITHUB_INTEGRATION_NAME,
    connectUrl: GITHUB_BYO_OAUTH_CONNECT_URL,
    useWhen:
      "A GitHub OAuth App you register, or an existing `github` connection. Pass a distinct integrationName for each extra connected identity.",
    avoidWhen: "You only have a personal access token and have not connected OAuth.",
    mutationGuidance:
      "Mutating helpers require dryRun: true to preview, then confirm: true to apply.",
  },
  {
    lane: "pat",
    default: false,
    secretName: DEFAULT_GITHUB_SECRET_NAME,
    secretSetupUrl: GITHUB_PAT_SETUP_URL,
    useWhen:
      "Fine-grained or classic personal access tokens. Pass secretName (default githubAccessToken) and do not pass integrationName.",
    avoidWhen: "A saved GitHub OAuth integration already covers the work.",
    mutationGuidance:
      "Mutating helpers require dryRun: true to preview, then confirm: true to apply. Grant only the PAT permissions the mutation needs.",
  },
] as const satisfies readonly GitHubAuthLaneInfo[]

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
  }
}

/**
 * Return the supported GitHub auth lanes and selection guidance.
 */
export function accounts(): readonly GitHubAuthLaneInfo[] {
  return githubAuthLanes
}

/**
 * Resolve OAuth vs PAT credentials from helper input.
 */
export function resolveGithubAuth(input: GitHubAuthInput = {}): GitHubResolvedAuth {
  rejectRetiredAccountAliases(input.account)

  const secretName = trimToUndefined(input.secretName)
  if (secretName) {
    return {
      mode: "pat",
      integrationName: null,
      secretName,
      label: secretName,
    }
  }

  const integrationName =
    trimToUndefined(input.integrationName) ??
    trimToUndefined(input.account) ??
    DEFAULT_GITHUB_INTEGRATION_NAME

  return {
    mode: "oauth",
    integrationName,
    secretName: null,
    label: integrationName,
  }
}

/**
 * Make an authenticated GitHub REST API request.
 *
 * The helper supplies GitHub API headers, parses JSON and text responses,
 * returns selected rate-limit headers, and can throw GitHubRequestError when
 * throwOnError is true. Mutating methods require dryRun or confirm.
 */
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 auth = resolveGithubAuth(options)
  const url = buildUrl(options.path, options.query)
  const method = (options.method ?? (options.body === undefined ? "GET" : "POST")).toUpperCase()
  const graphqlPath = isGraphqlPath(options.path)
  const mutating = isMutatingMethod(method) && !graphqlPath
  assertMutationGuard(mutating, options.dryRun, options.confirm, `${method} ${url}`)

  if (options.dryRun && mutating) {
    return {
      auth,
      url,
      ok: true,
      status: 0,
      statusText: "dry-run",
      headers: {},
      data: {
        dryRun: true,
        method,
        path: options.path,
        wouldRequest: true,
      } as TData,
      text: "",
      dryRun: true,
    }
  }

  const headers = new Headers(options.headers)
  headers.set("Accept", headers.get("Accept") ?? "application/vnd.github+json")
  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 }

  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)
  }

  const fetchResponse = await githubFetch(url, init, auth)
  const text = await fetchResponse.text()
  const data = parseResponseBody<TData>(text, fetchResponse.headers)
  const response: GitHubResponse<TData> = {
    auth,
    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.
 */
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 mutating = isGraphqlMutation(options.query)
  assertMutationGuard(mutating, options.dryRun, options.confirm, "GraphQL mutation")

  if (options.dryRun && mutating) {
    const auth = resolveGithubAuth(options)
    return {
      auth,
      url: GITHUB_API_BASE_URL + "/graphql",
      ok: true,
      status: 0,
      statusText: "dry-run",
      headers: {},
      data: null,
      text: "",
      dryRun: true,
      errors: undefined,
    }
  }

  const response = await request<{ data?: TData | null; errors?: GitHubGraphqlError[] }>({
    integrationName: options.integrationName,
    secretName: options.secretName,
    account: options.account,
    path: "/graphql",
    method: "POST",
    headers: options.headers,
    body: {
      query: options.query,
      variables: options.variables ?? {},
    },
    throwOnError: options.throwOnError,
  })

  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 auth = resolveGithubAuth(options)
  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>({
      integrationName: options.integrationName,
      secretName: options.secretName,
      account: options.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 { auth, items, pages, lastResponse }
}

/**
 * Fetch the authenticated GitHub viewer for the selected OAuth integration or PAT.
 */
export async function getViewer(options: GitHubAuthInput = {}): Promise<GitHubViewer> {
  const auth = resolveGithubAuth(options)
  const response = await request<{
    login: string
    id: number
    type: string
    name: string | null
    email: string | null
    html_url: string
  }>({
    integrationName: options.integrationName,
    secretName: options.secretName,
    account: options.account,
    path: "/user",
    throwOnError: true,
  })

  if (!response.data) {
    throw new Error("GitHub viewer response did not include a JSON body.")
  }

  return {
    auth,
    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,
  }
}

export function pickAuthInput(params: Record<string, unknown>): GitHubAuthInput {
  return {
    integrationName: readOptionalString(params.integrationName),
    secretName: readOptionalString(params.secretName),
    account: readOptionalString(params.account),
  }
}

export function isDryRun(params: Record<string, unknown>): boolean {
  return params.dryRun === true
}

export function isConfirmed(params: Record<string, unknown>): boolean {
  return params.confirm === true
}

async function githubFetch(url: string, init: RequestInit, auth: GitHubResolvedAuth): Promise<Response> {
  switch (auth.mode) {
    case "pat": {
      const headers = new Headers(init.headers)
      headers.set("Authorization", `Bearer {{secret:${auth.secretName}|scope=user}}`)
      try {
        return await fetch(url, { ...init, headers })
      } catch (error) {
        throw wrapPatSetupError(auth.secretName!, error)
      }
    }
    case "oauth": {
      let authedFetch: typeof fetch
      try {
        authedFetch = await createAuthenticatedFetch(auth.integrationName!)
      } catch (error) {
        throw wrapOauthSetupError(auth.integrationName!, error)
      }
      return await authedFetch(url, init)
    }
    default: {
      const exhaustive: never = auth.mode
      throw new Error("Unsupported GitHub auth mode: " + String(exhaustive))
    }
  }
}

function rejectRetiredAccountAliases(account: string | undefined): void {
  if (!account) return
  const retired = RETIRED_ACCOUNT_ALIASES.find((alias) => alias === account)
  if (!retired) return
  throw new Error(
    `GitHub account alias "${retired}" was removed from @kody/github. ` +
      `Pass integrationName for a saved OAuth connection (default "github"). ` +
      `Connect a GitHub OAuth App at ${GITHUB_BYO_OAUTH_CONNECT_URL}. ` +
      `For a personal access token, save ${DEFAULT_GITHUB_SECRET_NAME} at ${GITHUB_PAT_SETUP_URL} ` +
      `and pass secretName: "${DEFAULT_GITHUB_SECRET_NAME}".`,
  )
}

function wrapOauthSetupError(integrationName: string, error: unknown): Error {
  const connectUrl = `https://kody.codes/connect/oauth?provider=${encodeURIComponent(integrationName)}`
  return new Error(
    `GitHub OAuth integration "${integrationName}" is not connected or cannot be used (${stringifyError(error)}). ` +
      `Connect or reconnect at ${connectUrl}. ` +
      `New OAuth setup uses a GitHub OAuth App you register: ${GITHUB_BYO_OAUTH_CONNECT_URL}. ` +
      `For a PAT instead, save ${DEFAULT_GITHUB_SECRET_NAME} at ${GITHUB_PAT_SETUP_URL} and pass secretName: "${DEFAULT_GITHUB_SECRET_NAME}".`,
  )
}

function wrapPatSetupError(secretName: string, error: unknown): Error {
  const setupUrl =
    `https://kody.codes/account/secrets/new?name=${encodeURIComponent(secretName)}` +
    `&description=GitHub%20fine-grained%20personal%20access%20token` +
    `&allowedHosts=${encodeURIComponent(`${GITHUB_API_HOST},${GITHUB_UPLOADS_HOST}`)}` +
    `&scope=user`
  return new Error(
    `GitHub PAT secret "${secretName}" could not be used (${stringifyError(error)}). ` +
      `Save the token at ${setupUrl} and approve hosts ${GITHUB_API_HOST} and ${GITHUB_UPLOADS_HOST}. ` +
      `Never paste the token into chat.`,
  )
}

function assertMutationGuard(
  mutating: boolean,
  dryRun: boolean | undefined,
  confirm: boolean | undefined,
  action: string,
): void {
  if (!mutating) return
  if (dryRun === true) return
  if (confirm === true) return
  throw new Error(
    `GitHub mutation "${action}" requires dryRun: true (preview, no GitHub write) or confirm: true (live write).`,
  )
}

function isMutatingMethod(method: string): boolean {
  return !SAFE_HTTP_METHODS.has(method)
}

function isGraphqlPath(path: string): boolean {
  const normalized = path.split("?")[0] ?? path
  return normalized === "/graphql" || normalized.endsWith("/graphql")
}

function isGraphqlMutation(query: string): boolean {
  return /^\s*mutation\b/i.test(query)
}

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 helpers only accept 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 buildErrorMessage(response: GitHubResponse<unknown>): string {
  const message = extractGitHubMessage(response.data) ?? graphqlInsufficientScope(response.data)
  const accepted = parseScopeHeader(response.headers["x-accepted-oauth-scopes"])
  const granted = parseScopeHeader(response.headers["x-oauth-scopes"])
  const missing = accepted.filter((scope) => !granted.includes(scope))
  const remaining = response.headers["x-ratelimit-remaining"]
  const nextStep = nextSetupStep(response.auth, missing)

  if (response.status === 403 && remaining === "0") {
    return (
      `GitHub rate limit exceeded (X-RateLimit-Remaining: 0). ` +
      `Wait until X-RateLimit-Reset (${response.headers["x-ratelimit-reset"] ?? "unknown"}) ` +
      `or batch with the GraphQL helper.`
    )
  }

  if (response.status === 401 || response.status === 403) {
    if (missing.length > 0) {
      return (
        `GitHub request failed with ${response.status}: insufficient scope. ` +
        `Missing scope(s): ${missing.join(", ")}. ` +
        `Granted: ${granted.length > 0 ? granted.join(", ") : "(none reported)"}. ` +
        nextStep
      )
    }
    return (
      `GitHub request failed with ${response.status}` +
      (message ? `: ${message}` : "") +
      `. If this is an organization that restricts OAuth App access, request org approval ` +
      `or use a fine-grained PAT approved for that org. ` +
      nextStep
    )
  }

  return message
    ? "GitHub request failed with " + response.status + ": " + message
    : "GitHub request failed with " + response.status + " " + response.statusText
}

function nextSetupStep(auth: GitHubResolvedAuth, missingScopes: string[]): string {
  const missingHint =
    missingScopes.length > 0
      ? `Add ${missingScopes.join(", ")}.`
      : `Review granted GitHub OAuth scopes (${BUILTIN_GITHUB_ALLOWED_SCOPES.join(", ")}).`

  switch (auth.mode) {
    case "oauth": {
      const provider = auth.integrationName ?? DEFAULT_GITHUB_INTEGRATION_NAME
      const outsideBuiltin = missingScopes.some(
        (scope) => !BUILTIN_GITHUB_ALLOWED_SCOPES.includes(scope as (typeof BUILTIN_GITHUB_ALLOWED_SCOPES)[number]),
      )
      const connectUrl = `https://kody.codes/connect/oauth?provider=${encodeURIComponent(provider)}`
      if (outsideBuiltin) {
        return (
          `Next setup step: register a GitHub OAuth App with callback https://kody.codes/connect/oauth, then connect with ${GITHUB_BYO_OAUTH_CONNECT_URL} ` +
          `using provider=${encodeURIComponent(provider)}. ${missingHint}`
        )
      }
      return (
        `Next setup step: reconnect at ${connectUrl} and enable the missing scope(s) from the connect-page scope menu. ${missingHint}`
      )
    }
    case "pat": {
      const secretName = auth.secretName ?? DEFAULT_GITHUB_SECRET_NAME
      const setupUrl =
        `https://kody.codes/account/secrets/new?name=${encodeURIComponent(secretName)}` +
        `&description=GitHub%20fine-grained%20personal%20access%20token` +
        `&allowedHosts=${encodeURIComponent(`${GITHUB_API_HOST},${GITHUB_UPLOADS_HOST}`)}` +
        `&scope=user`
      return (
        `Next setup step: grant the missing permission on a fine-grained PAT at ` +
        `https://github.com/settings/personal-access-tokens, then update the secret at ${setupUrl}. ${missingHint}`
      )
    }
    default: {
      const exhaustive: never = auth.mode
      throw new Error("Unsupported GitHub auth mode: " + String(exhaustive))
    }
  }
}

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
}

function graphqlInsufficientScope(data: unknown): string | null {
  if (!data || typeof data !== "object" || !("errors" in data)) return null
  const errors = (data as { errors?: GitHubGraphqlError[] }).errors
  if (!Array.isArray(errors)) return null
  const insufficient = errors.find(
    (error) => error?.type === "INSUFFICIENT_SCOPES" || /insufficient.{0,12}scope/i.test(error?.message ?? ""),
  )
  return insufficient?.message ?? null
}

function parseScopeHeader(value: string | undefined): string[] {
  if (!value) return []
  return value
    .split(",")
    .map((part) => part.trim())
    .filter((part) => part.length > 0)
}

function trimToUndefined(value: string | undefined): string | undefined {
  if (typeof value !== "string") return undefined
  const trimmed = value.trim()
  return trimmed.length > 0 ? trimmed : undefined
}

function readOptionalString(value: unknown): string | undefined {
  return typeof value === "string" ? value : undefined
}

function stringifyError(error: unknown): string {
  if (error instanceof Error) return error.message
  return String(error)
}