treewide: implement authorized users for polling; cleanup
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0c72309281e8c67e4ee4333c4c3bc1fe6a6a6964
This commit is contained in:
parent
ad491d69e8
commit
3eb5ccf61c
6 changed files with 279 additions and 40 deletions
131
src/polling.ts
131
src/polling.ts
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue