troutbot/src/github.ts
NotAShelf 374408834b
treewide: make less webhook-centric
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ifab58fcb523549ca9cb83dc8467be51e6a6a6964
2026-02-01 15:36:56 +03:00

251 lines
5.9 KiB
TypeScript

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<void> {
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<void> {
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<CheckRun[]> {
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<PRFile[]> {
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<RecentComment[]> {
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;
}