treewide: implement authorized users for polling; cleanup

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0c72309281e8c67e4ee4333c4c3bc1fe6a6a6964
This commit is contained in:
raf 2026-02-01 17:32:07 +03:00
commit 3eb5ccf61c
Signed by: NotAShelf
GPG key ID: 29D95B64378DB4BF
6 changed files with 279 additions and 40 deletions

View file

@ -1,4 +1,4 @@
import type { Config, WebhookEvent } from './types.js';
import type { Config, WebhookEvent, RepoConfig } from './types.js';
import {
listRecentComments,
fetchPR,
@ -7,20 +7,75 @@ import {
postComment,
updateComment,
formatComment,
createReaction,
type RecentComment,
} from './github.js';
import { createEngine } from './engine/index.js';
import { getLogger } from './logger.js';
import { recordEvent } from './events.js';
import { readFileSync, writeFileSync, existsSync } from 'fs';
interface ProcessedComment {
id: number;
timestamp: number;
}
interface PollingState {
lastProcessedAt: Record<string, string>;
}
const processedComments: Map<string, ProcessedComment> = new Map();
const MAX_PROCESSED_CACHE = 1000;
let pollingState: PollingState = { lastProcessedAt: {} };
function loadPollingState(stateFile: string): void {
if (existsSync(stateFile)) {
try {
const data = readFileSync(stateFile, 'utf-8');
const parsed = JSON.parse(data);
// Validate that parsed data has expected structure
if (
parsed &&
typeof parsed === 'object' &&
parsed.lastProcessedAt &&
typeof parsed.lastProcessedAt === 'object'
) {
pollingState = parsed;
} else {
getLogger().warn('Invalid polling state format, resetting to empty state');
pollingState = { lastProcessedAt: {} };
}
} catch {
// Ignore parse errors, use empty state
pollingState = { lastProcessedAt: {} };
}
}
}
function savePollingState(stateFile: string): void {
try {
writeFileSync(stateFile, JSON.stringify(pollingState, null, 2));
} catch (err) {
getLogger().warn('Failed to save polling state', err);
}
}
function getRepoKey(owner: string, repo: string): string {
return `${owner}/${repo}`;
}
function getLastProcessedAt(owner: string, repo: string): Date | undefined {
const key = getRepoKey(owner, repo);
const timestamp = pollingState.lastProcessedAt[key];
return timestamp ? new Date(timestamp) : undefined;
}
function setLastProcessedAt(owner: string, repo: string, date: Date): void {
const key = getRepoKey(owner, repo);
pollingState.lastProcessedAt[key] = date.toISOString();
}
function getCacheKey(owner: string, repo: string, commentId: number): string {
return `${owner}/${repo}#${commentId}`;
}
@ -92,6 +147,21 @@ async function analyzeAndComment(
return result;
}
function isAuthorized(username: string, authorizedUsers?: string[]): boolean {
if (!authorizedUsers || authorizedUsers.length === 0) {
return true; // No restrictions
}
const normalizedUsername = username.toLowerCase();
return authorizedUsers.some((u) => u.toLowerCase() === normalizedUsername);
}
function isRepoAuthorized(owner: string, repo: string, pollingRepos?: RepoConfig[]): boolean {
if (!pollingRepos || pollingRepos.length === 0) {
return true; // No restrictions, use global repos
}
return pollingRepos.some((r) => r.owner === owner && r.repo === repo);
}
async function processComment(
comment: RecentComment,
owner: string,
@ -109,6 +179,27 @@ async function processComment(
return;
}
// Check if repo is authorized for polling
const pollingRepos = config.polling?.repositories;
if (!isRepoAuthorized(owner, repo, pollingRepos)) {
logger.info(
`Unauthorized repo ${owner}/${repo} for polling, ignoring mention from ${comment.author}`
);
await createReaction(owner, repo, comment.id, 'thumbs_down');
markProcessed(owner, repo, comment.id);
return;
}
// Check if user is authorized
const authorizedUsers = config.polling?.authorizedUsers;
if (!isAuthorized(comment.author, authorizedUsers)) {
logger.info(
`Unauthorized user ${comment.author} attempted on-demand analysis in ${owner}/${repo}#${comment.issueNumber}`
);
markProcessed(owner, repo, comment.id);
return;
}
logger.info(`Found @troutbot mention in ${owner}/${repo}#${comment.issueNumber}`);
try {
@ -168,7 +259,8 @@ async function pollRepository(
owner: string,
repo: string,
config: Config,
since: Date
since: Date,
stateFile?: string
): Promise<void> {
const logger = getLogger();
@ -176,8 +268,20 @@ async function pollRepository(
const comments = await listRecentComments(owner, repo, since);
logger.debug(`Fetched ${comments.length} recent comments from ${owner}/${repo}`);
let latestCommentDate = since;
for (const comment of comments) {
await processComment(comment, owner, repo, config);
const commentDate = new Date(comment.createdAt);
if (commentDate > latestCommentDate) {
latestCommentDate = commentDate;
}
}
// Update last processed timestamp
setLastProcessedAt(owner, repo, latestCommentDate);
if (stateFile) {
savePollingState(stateFile);
}
} catch (err) {
logger.error(`Failed to poll ${owner}/${repo}`, err);
@ -206,10 +310,25 @@ export async function startPolling(config: Config): Promise<void> {
`Poll interval: ${pollingConfig.intervalMinutes} minutes, lookback: ${pollingConfig.lookbackMinutes} minutes`
);
// Do an initial poll
const initialSince = new Date(Date.now() - lookbackMs);
// Load persisted state if backfill is enabled
const stateFile = pollingConfig.backfill
? pollingConfig.stateFile || '.troutbot-polling-state.json'
: undefined;
if (stateFile) {
loadPollingState(stateFile);
logger.info(`Polling state file: ${stateFile}`);
}
// Do an initial poll - use persisted timestamp if available, otherwise use lookback
for (const repo of config.repositories) {
await pollRepository(repo.owner, repo.repo, config, initialSince);
const lastProcessed = getLastProcessedAt(repo.owner, repo.repo);
const initialSince = lastProcessed || new Date(Date.now() - lookbackMs);
if (lastProcessed) {
logger.info(
`Resuming polling for ${repo.owner}/${repo.repo} from ${lastProcessed.toISOString()}`
);
}
await pollRepository(repo.owner, repo.repo, config, initialSince, stateFile);
}
// Set up recurring polling
@ -217,7 +336,7 @@ export async function startPolling(config: Config): Promise<void> {
const since = new Date(Date.now() - lookbackMs);
for (const repo of config.repositories) {
await pollRepository(repo.owner, repo.repo, config, since);
await pollRepository(repo.owner, repo.repo, config, since, stateFile);
}
}, intervalMs);
}