Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,12 @@ export const ALLOWED_EVENT_TYPES_SET: ReadonlySet<EventType> = new Set(
);

/**
* Event types that support branch filtering.
* These events have branch context and can be filtered by --branches flag.
* Other events (issues, releases, comments, forks, stars) are not branch-specific.
* Event types that MUST have branch context for filtering.
* These events always have a branch and can be filtered by --branches flag.
*
* Note: "comments" is intentionally excluded. It covers both issue comments
* (no branch context) and PR comments (branch resolved via cache/API).
* PR comments pass branch optionally for filtering; issue comments pass undefined.
*/
export const BRANCH_FILTERABLE_EVENTS: readonly EventType[] = [
"pr",
Expand Down
8 changes: 8 additions & 0 deletions src/github-app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,12 @@ export class GitHubApp {
getOAuth() {
return this.app.oauth;
}

/**
* Get installation-authenticated Octokit instance
* Used for API calls on behalf of a specific installation
*/
getInstallationOctokit(installationId: number): Promise<Octokit> {
return this.app.getInstallationOctokit(installationId);
}
}
130 changes: 129 additions & 1 deletion src/github-app/event-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ import type {
WatchPayload,
WorkflowRunPayload,
} from "../types/webhooks";
import type { GitHubApp } from "./app";

/** Cache entry for PR base branch */
interface PrBranchCacheEntry {
branch: string;
updatedAt: Date;
}

/** Lazy TTL for cache entries (24 hours) */
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;

/**
* EventProcessor - Routes webhook events to formatters and sends to subscribed channels
Expand All @@ -46,7 +56,11 @@ import type {
* Delegates message delivery (threading, editing, deleting) to MessageDeliveryService.
*/
export class EventProcessor {
/** Cache for PR base branches: "repo#prNumber" maps to branch and updatedAt */
private prBranchCache = new Map<string, PrBranchCacheEntry>();

constructor(
private githubApp: GitHubApp,
private subscriptionService: SubscriptionService,
private messageDeliveryService: MessageDeliveryService
) {}
Expand Down Expand Up @@ -225,6 +239,19 @@ export class EventProcessor {
*/
async onPullRequest(event: PullRequestPayload) {
const { pull_request, repository, action } = event;

// Update branch cache (or delete on close)
if (action === "closed") {
this.deleteBranchCache(repository.full_name, pull_request.number);
} else {
this.updateBranchCache(
repository.full_name,
pull_request.number,
pull_request.base.ref,
new Date(pull_request.updated_at)
);
}

await this.handleAnchorEvent({
event,
action,
Expand Down Expand Up @@ -309,6 +336,7 @@ export class EventProcessor {
* Process an issue comment webhook event
* Threading: comments thread to parent PR or issue
* Note: GitHub fires issue_comment for both issues AND PRs
* Branch filtering: PR comments are filtered by PR's base branch
*/
async onIssueComment(event: IssueCommentPayload) {
const { action, issue, comment, repository } = event;
Expand All @@ -318,13 +346,33 @@ export class EventProcessor {
const deliveryAction = toDeliveryAction(action);
if (!deliveryAction) return;

// For PR comments, get branch for filtering (cache → API fallback)
let branch: string | undefined;
if (isPrComment) {
branch =
this.getCachedBranch(repository.full_name, issue.number) ?? undefined;
if (!branch && event.installation?.id) {
branch =
(await this.fetchPrBranch(
repository.full_name,
issue.number,
event.installation.id
)) ?? undefined;
}
if (!branch) {
console.log(
`Branch not resolved for PR comment ${repository.full_name}#${issue.number}, skipping branch filter`
);
}
}

await this.processEvent(
event,
"comments",
deliveryAction,
formatIssueComment,
`issue comment event: ${action} - ${repository.full_name}#${issue.number}`,
undefined,
branch,
{
githubEntityType: "comment",
githubEntityId: String(comment.id),
Expand All @@ -344,6 +392,14 @@ export class EventProcessor {
async onPullRequestReview(event: PullRequestReviewPayload) {
const { action, review, pull_request, repository } = event;

// Update branch cache
this.updateBranchCache(
repository.full_name,
pull_request.number,
pull_request.base.ref,
new Date(pull_request.updated_at)
);

const mappingAction =
action === "submitted" ? "create" : action === "edited" ? "edit" : null;

Expand Down Expand Up @@ -373,6 +429,15 @@ export class EventProcessor {
*/
async onPullRequestReviewComment(event: PullRequestReviewCommentPayload) {
const { action, comment, pull_request, repository } = event;

// Update branch cache
this.updateBranchCache(
repository.full_name,
pull_request.number,
pull_request.base.ref,
new Date(pull_request.updated_at)
);

const deliveryAction = toDeliveryAction(action);
if (!deliveryAction) return;

Expand Down Expand Up @@ -443,6 +508,69 @@ export class EventProcessor {
`watch event: ${event.repository.full_name}`
);
}

/** Update cache if incoming timestamp is newer or equal */
private updateBranchCache(
repo: string,
prNumber: number,
branch: string,
updatedAt: Date
): void {
const key = `${repo}#${prNumber}`;
const existing = this.prBranchCache.get(key);
if (!existing || updatedAt >= existing.updatedAt) {
this.prBranchCache.set(key, { branch, updatedAt });
}
}

/** Delete cache entry (called on PR close) */
private deleteBranchCache(repo: string, prNumber: number): void {
this.prBranchCache.delete(`${repo}#${prNumber}`);
}

/** Get cached branch, or null if miss/stale */
private getCachedBranch(repo: string, prNumber: number): string | null {
const key = `${repo}#${prNumber}`;
const entry = this.prBranchCache.get(key);
if (!entry) return null;
// Lazy TTL: treat as miss if >24h old
if (Date.now() - entry.updatedAt.getTime() > CACHE_TTL_MS) {
this.prBranchCache.delete(key);
return null;
}
return entry.branch;
}

/** Fetch PR branch from API (fallback on cache miss) */
private async fetchPrBranch(
repo: string,
prNumber: number,
installationId: number
): Promise<string | null> {
try {
const octokit =
await this.githubApp.getInstallationOctokit(installationId);
const [owner, repoName] = repo.split("/");
const { data: pr } = await octokit.request(
"GET /repos/{owner}/{repo}/pulls/{pull_number}",
{ owner, repo: repoName, pull_number: prNumber }
);
// Cache the result
this.updateBranchCache(
repo,
prNumber,
pr.base.ref,
new Date(pr.updated_at)
);
return pr.base.ref;
} catch (error) {
console.warn(
`Failed to fetch PR #${prNumber} branch from ${repo}:`,
error
);
return null;
}
}
}

/**
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ const messageDeliveryService = new MessageDeliveryService(bot);

// Event processing service
const eventProcessor = new EventProcessor(
githubApp,
subscriptionService,
messageDeliveryService
);
Expand Down
16 changes: 16 additions & 0 deletions src/services/message-delivery-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,22 @@ export class MessageDeliveryService {
);

if (!existingMessageId) {
// Retroactive anchor: if this is an anchor edit but no anchor exists,
// create it (happens when PR base branch changes to match filter)
if (entityContext.isAnchor) {
console.log(
`Creating retroactive anchor for ${githubEntityType}:${githubEntityId}`
);
await this.handleCreate(
spaceId,
channelId,
repoFullName,
entityContext,
undefined,
message
);
return;
}
console.log(
`No existing message to edit for ${githubEntityType}:${githubEntityId}`
);
Expand Down