/** * 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 { 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 = { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.v3+json', 'User-Agent': 'MissionControl/1.0', ...(options.headers as Record || {}), } 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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() }