mission-control/src/lib/github.ts

348 lines
8.1 KiB
TypeScript

/**
* GitHub API client for Mission Control issue sync.
* Uses GITHUB_TOKEN from env (integration key, not core config).
*/
export interface GitHubLabel {
name: string
color?: string
}
export interface GitHubUser {
login: string
avatar_url?: string
}
export interface GitHubIssue {
number: number
title: string
body: string | null
state: 'open' | 'closed'
labels: GitHubLabel[]
assignee: GitHubUser | null
html_url: string
created_at: string
updated_at: string
}
export function getGitHubToken(): string | null {
return process.env.GITHUB_TOKEN || null
}
/**
* Authenticated fetch wrapper for GitHub API.
*/
export async function githubFetch(
path: string,
options: RequestInit = {}
): Promise<Response> {
const token = getGitHubToken()
if (!token) {
throw new Error('GITHUB_TOKEN not configured')
}
const url = path.startsWith('https://')
? path
: `https://api.github.com${path.startsWith('/') ? '' : '/'}${path}`
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github.v3+json',
'User-Agent': 'MissionControl/1.0',
...(options.headers as Record<string, string> || {}),
}
if (options.body) {
headers['Content-Type'] = 'application/json'
}
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15000)
try {
const res = await fetch(url, {
...options,
headers,
signal: controller.signal,
})
return res
} finally {
clearTimeout(timeout)
}
}
/**
* Fetch issues from a GitHub repo.
*/
export async function fetchIssues(
repo: string,
params?: {
state?: 'open' | 'closed' | 'all'
labels?: string
since?: string
per_page?: number
page?: number
}
): Promise<GitHubIssue[]> {
const searchParams = new URLSearchParams()
if (params?.state) searchParams.set('state', params.state)
if (params?.labels) searchParams.set('labels', params.labels)
if (params?.since) searchParams.set('since', params.since)
searchParams.set('per_page', String(params?.per_page ?? 30))
searchParams.set('page', String(params?.page ?? 1))
const qs = searchParams.toString()
const res = await githubFetch(`/repos/${repo}/issues?${qs}`)
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
const data = await res.json()
// Filter out pull requests (GitHub API returns PRs in issues endpoint)
return (data as any[]).filter((item: any) => !item.pull_request)
}
/**
* Fetch a single issue.
*/
export async function fetchIssue(
repo: string,
issueNumber: number
): Promise<GitHubIssue> {
const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}`)
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
return res.json()
}
/**
* Post a comment on a GitHub issue.
*/
export async function createIssueComment(
repo: string,
issueNumber: number,
body: string
): Promise<void> {
const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}/comments`, {
method: 'POST',
body: JSON.stringify({ body }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
}
/**
* Update an issue's state (open/closed).
*/
export async function updateIssueState(
repo: string,
issueNumber: number,
state: 'open' | 'closed'
): Promise<void> {
const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}`, {
method: 'PATCH',
body: JSON.stringify({ state }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
}
/**
* Update an issue (title, body, state, labels, assignees).
*/
export async function updateIssue(
repo: string,
issueNumber: number,
updates: {
title?: string
body?: string
state?: 'open' | 'closed'
labels?: string[]
assignees?: string[]
}
): Promise<GitHubIssue> {
const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}`, {
method: 'PATCH',
body: JSON.stringify(updates),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
return res.json()
}
/**
* Create a new issue on GitHub.
*/
export async function createIssue(
repo: string,
issue: {
title: string
body?: string
labels?: string[]
assignees?: string[]
}
): Promise<GitHubIssue> {
const res = await githubFetch(`/repos/${repo}/issues`, {
method: 'POST',
body: JSON.stringify(issue),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
return res.json()
}
/**
* Create a label on a GitHub repo (ignores 422 = already exists).
*/
export async function createLabel(
repo: string,
label: { name: string; color: string; description?: string }
): Promise<void> {
const res = await githubFetch(`/repos/${repo}/labels`, {
method: 'POST',
body: JSON.stringify(label),
})
// 422 = label already exists, that's fine
if (!res.ok && res.status !== 422) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
}
/**
* Idempotently ensure all specified labels exist on the repo.
*/
export async function ensureLabels(
repo: string,
labels: Array<{ name: string; color: string; description?: string }>
): Promise<void> {
for (const label of labels) {
await createLabel(repo, label)
}
}
/**
* Set the labels on an issue (replaces all existing labels).
*/
export async function updateIssueLabels(
repo: string,
issueNumber: number,
labels: string[]
): Promise<void> {
const res = await githubFetch(`/repos/${repo}/issues/${issueNumber}/labels`, {
method: 'PUT',
body: JSON.stringify({ labels }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
}
/**
* Create a git ref (branch).
*/
export async function createRef(
repo: string,
ref: string,
sha: string
): Promise<void> {
const res = await githubFetch(`/repos/${repo}/git/refs`, {
method: 'POST',
body: JSON.stringify({ ref, sha }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
}
/**
* Get a git ref SHA.
*/
export async function getRef(
repo: string,
ref: string
): Promise<{ sha: string }> {
const res = await githubFetch(`/repos/${repo}/git/refs/${ref}`)
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
const data = await res.json() as { object: { sha: string } }
return { sha: data.object.sha }
}
export interface GitHubPullRequest {
number: number
title: string
body: string | null
state: 'open' | 'closed'
merged: boolean
head: { ref: string; sha: string }
base: { ref: string }
html_url: string
created_at: string
updated_at: string
}
/**
* Fetch pull requests from a GitHub repo.
*/
export async function fetchPullRequests(
repo: string,
params?: {
head?: string
state?: 'open' | 'closed' | 'all'
per_page?: number
}
): Promise<GitHubPullRequest[]> {
const searchParams = new URLSearchParams()
if (params?.head) searchParams.set('head', params.head)
if (params?.state) searchParams.set('state', params.state)
searchParams.set('per_page', String(params?.per_page ?? 30))
const qs = searchParams.toString()
const res = await githubFetch(`/repos/${repo}/pulls?${qs}`)
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
return res.json()
}
/**
* Create a pull request.
*/
export async function createPullRequest(
repo: string,
pr: {
title: string
head: string
base: string
body?: string
}
): Promise<GitHubPullRequest> {
const res = await githubFetch(`/repos/${repo}/pulls`, {
method: 'POST',
body: JSON.stringify(pr),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`GitHub API error ${res.status}: ${text}`)
}
return res.json()
}