import { Octokit } from '@octokit/rest'; import { getLogger } from './logger.js'; import type { CheckRun, PRFile, ResponseConfig } from './types.js'; let octokit: Octokit | null = null; export function initGitHub(token?: string): void { if (!token) { getLogger().warn('No GITHUB_TOKEN set - running in dry-run mode, comments will not be posted'); return; } octokit = new Octokit({ auth: token }); } export function isDryRun(): boolean { return octokit === null; } // Comment operations export async function postComment( owner: string, repo: string, issueNumber: number, body: string ): Promise { if (!octokit) { getLogger().info(`[dry-run] Would post comment on ${owner}/${repo}#${issueNumber}:\n${body}`); return; } await octokit.issues.createComment({ owner, repo, issue_number: issueNumber, body }); getLogger().info(`Posted comment on ${owner}/${repo}#${issueNumber}`); } export async function hasExistingComment( owner: string, repo: string, issueNumber: number, marker: string ): Promise<{ exists: boolean; commentId?: number }> { if (!octokit) { return { exists: false }; } const comments = await octokit.paginate(octokit.issues.listComments, { owner, repo, issue_number: issueNumber, per_page: 100, }); const existing = comments.find((c) => c.body?.includes(marker)); if (existing) { return { exists: true, commentId: existing.id }; } return { exists: false }; } export async function updateComment( owner: string, repo: string, commentId: number, body: string ): Promise { if (!octokit) { getLogger().info(`[dry-run] Would update comment ${commentId}:\n${body}`); return; } await octokit.issues.updateComment({ owner, repo, comment_id: commentId, body }); getLogger().info(`Updated comment ${commentId} on ${owner}/${repo}`); } // Data fetching for engine backends export async function fetchCheckRuns( owner: string, repo: string, ref: string ): Promise { if (!octokit) { getLogger().debug('[dry-run] Cannot fetch check runs without a token'); return []; } const response = await octokit.checks.listForRef({ owner, repo, ref, per_page: 100, }); return response.data.check_runs.map((run) => ({ name: run.name, status: run.status, conclusion: run.conclusion, })); } export async function fetchPRFiles( owner: string, repo: string, prNumber: number ): Promise { if (!octokit) { getLogger().debug('[dry-run] Cannot fetch PR files without a token'); return []; } const files = await octokit.paginate(octokit.pulls.listFiles, { owner, repo, pull_number: prNumber, per_page: 100, }); return files.map((f) => ({ filename: f.filename, additions: f.additions, deletions: f.deletions, changes: f.changes, })); } export async function fetchPR( owner: string, repo: string, prNumber: number ): Promise<{ title: string; body: string; author: string; labels: string[]; branch: string; sha: string; } | null> { if (!octokit) return null; const { data } = await octokit.pulls.get({ owner, repo, pull_number: prNumber }); return { title: data.title, body: data.body || '', author: data.user?.login || '', labels: (data.labels || []).map((l) => (typeof l === 'string' ? l : l.name || '')), branch: data.head.ref, sha: data.head.sha, }; } export async function fetchIssue( owner: string, repo: string, issueNumber: number ): Promise<{ title: string; body: string; author: string; labels: string[]; } | null> { if (!octokit) return null; const { data } = await octokit.issues.get({ owner, repo, issue_number: issueNumber }); return { title: data.title, body: data.body || '', author: data.user?.login || '', labels: (data.labels || []).map((l) => (typeof l === 'string' ? l : l.name || '')), }; } export interface RecentComment { id: number; body: string; author: string; createdAt: string; issueNumber: number; isPullRequest: boolean; } export async function listRecentComments( owner: string, repo: string, since: Date ): Promise { if (!octokit) { getLogger().debug('[dry-run] Cannot fetch comments without a token'); return []; } const sinceIso = since.toISOString(); const comments: RecentComment[] = []; // Fetch recent issue comments const issueComments = await octokit.paginate(octokit.issues.listCommentsForRepo, { owner, repo, since: sinceIso, per_page: 100, }); for (const comment of issueComments) { if (!comment.body) continue; comments.push({ id: comment.id, body: comment.body, author: comment.user?.login || '', createdAt: comment.created_at, issueNumber: comment.issue_url ? parseInt(comment.issue_url.split('/').pop() || '0', 10) : 0, isPullRequest: false, // we'll determine this by fetching the issue }); } return comments; } // Comment formatting function pickRandom(list: string[]): string { return list[Math.floor(Math.random() * list.length)]; } export function formatComment( responseConfig: ResponseConfig, type: 'issue' | 'pull_request', impact: string, confidence: number, reasoning: string ): string { const typeLabel = type === 'pull_request' ? 'pull request' : 'issue'; const { messages } = responseConfig; let messageList: string[]; if (impact === 'positive') { messageList = messages.positive; } else if (impact === 'negative') { messageList = messages.negative; } else { messageList = messages.neutral; } const template = pickRandom(messageList); let body = responseConfig.commentMarker + '\n\n'; body += template.replace(/\{type\}/g, typeLabel).replace(/\{impact\}/g, impact); if (responseConfig.includeConfidence) { body += `\n\n**Confidence:** ${(confidence * 100).toFixed(0)}%`; } if (responseConfig.includeReasoning) { body += `\n\n**Analysis:** ${reasoning}`; } return body; }