← 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 · TypeScriptimport { 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)
}