Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: Ifab58fcb523549ca9cb83dc8467be51e6a6a6964
251 lines
5.9 KiB
TypeScript
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;
|
|
}
|