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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ First-time subscriptions open an OAuth window; after authorization the callback

**Event types:** `pr`, `issues`, `commits`, `releases`, `ci`, `comments`, `reviews`, `branches`, `review_comments`, `forks`, `stars`, `all`

> **Comment routing:** `comments` includes both issue comments and PR conversation comments. `review_comments` covers inline code review comments AND also receives PR conversation comments (useful for PR-focused subscriptions without issue noise).

**Branch filter:** `--branches main,develop` or `--branches release/*` or `--branches all` (default: default branch only)

> Branch filtering applies to: `pr`, `commits`, `ci`, `reviews`, `review_comments`, `branches`. Other events (`issues`, `releases`, `comments`, `forks`, `stars`) are not branch-specific.
Expand Down Expand Up @@ -109,9 +111,9 @@ First-time subscriptions open an OAuth window; after authorization the callback
- `push` - Commits to branches
- `release` - Published
- `workflow_run` - CI/CD status
- `issue_comment` - New comments (threaded to PR/issue)
- `issue_comment` - Comments on issues and PR conversations (threaded; routes to `comments`, also `review_comments` for PRs)
- `pull_request_review` - PR reviews (threaded to PR)
- `pull_request_review_comment` - Review comments (threaded to PR)
- `pull_request_review_comment` - Inline code review comments (threaded to PR; routes to `review_comments`)
- `create` / `delete` - Branch/tag creation and deletion
- `fork` - Repository forks
- `watch` - Repository stars
Expand Down
15 changes: 12 additions & 3 deletions src/github-app/event-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,18 @@ export class EventProcessor {
);

// Filter by event preferences
let interestedChannels = channels.filter(ch =>
ch.eventTypes.includes(eventType)
);
let interestedChannels = channels.filter(ch => {
if (ch.eventTypes.includes(eventType)) return true;
// PR conversation comments also notify review_comments subscribers
if (
eventType === "comments" &&
entityContext?.parentType === "pr" &&
ch.eventTypes.includes("review_comments")
) {
return true;
}
return false;
});

// Apply branch filtering for branch-specific events
if (branch) {
Expand Down
125 changes: 122 additions & 3 deletions tests/unit/github-app/event-processor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
import { describe, expect, test } from "bun:test";

import { matchesBranchFilter } from "../../../src/github-app/event-processor";
import { describe, expect, mock, test } from "bun:test";

import type { EventType } from "../../../src/constants";
import type { GitHubApp } from "../../../src/github-app/app";
import {
EventProcessor,
matchesBranchFilter,
} from "../../../src/github-app/event-processor";
import type { MessageDeliveryService } from "../../../src/services/message-delivery-service";
import type { SubscriptionService } from "../../../src/services/subscription-service";
import type { IssueCommentPayload } from "../../../src/types/webhooks";

describe("matchesBranchFilter", () => {
const defaultBranch = "main";
Expand Down Expand Up @@ -133,3 +141,114 @@ describe("matchesBranchFilter", () => {
});
});
});

describe("PR comment routing", () => {
// Helper to create minimal IssueCommentPayload for testing
const createIssueCommentPayload = (
isPrComment: boolean
): IssueCommentPayload =>
({
action: "created",
repository: {
full_name: "owner/repo",
default_branch: "main",
},
issue: {
number: 123,
// GitHub includes pull_request field only for PR comments
...(isPrComment ? { pull_request: { url: "https://..." } } : {}),
},
comment: {
id: 456,
updated_at: new Date().toISOString(),
},
}) as unknown as IssueCommentPayload;

// Helper to create mock services
const createMocks = (channels: Array<{ eventTypes: EventType[] }>) => {
const deliveredChannels: string[] = [];

const mockSubscriptionService = {
getRepoSubscribers: mock(() =>
Promise.resolve(
channels.map((ch, i) => ({
channelId: `channel-${i}`,
spaceId: `space-${i}`,
eventTypes: ch.eventTypes,
branchFilter: "all" as const,
}))
)
),
} as unknown as SubscriptionService;

const mockMessageDeliveryService = {
deliver: mock(({ channelId }: { channelId: string }) => {
deliveredChannels.push(channelId);
return Promise.resolve();
}),
} as unknown as MessageDeliveryService;

const mockGitHubApp = {} as GitHubApp;

const processor = new EventProcessor(
mockGitHubApp,
mockSubscriptionService,
mockMessageDeliveryService
);

return { processor, deliveredChannels };
};

test("channel subscribed to 'comments' receives PR conversation comment", async () => {
const { processor, deliveredChannels } = createMocks([
{ eventTypes: ["comments"] },
]);

await processor.onIssueComment(createIssueCommentPayload(true));

expect(deliveredChannels).toContain("channel-0");
});

test("channel subscribed to 'review_comments' receives PR conversation comment", async () => {
const { processor, deliveredChannels } = createMocks([
{ eventTypes: ["review_comments"] },
]);

await processor.onIssueComment(createIssueCommentPayload(true));

expect(deliveredChannels).toContain("channel-0");
});

test("channel subscribed to 'review_comments' does NOT receive issue comment", async () => {
const { processor, deliveredChannels } = createMocks([
{ eventTypes: ["review_comments"] },
]);

await processor.onIssueComment(createIssueCommentPayload(false));

expect(deliveredChannels).not.toContain("channel-0");
});

test("both subscribers receive PR conversation comment", async () => {
const { processor, deliveredChannels } = createMocks([
{ eventTypes: ["comments"] },
{ eventTypes: ["review_comments"] },
]);

await processor.onIssueComment(createIssueCommentPayload(true));

expect(deliveredChannels).toContain("channel-0");
expect(deliveredChannels).toContain("channel-1");
expect(deliveredChannels.length).toBe(2);
});

test("channel without comment subscriptions does not receive comment", async () => {
const { processor, deliveredChannels } = createMocks([
{ eventTypes: ["pr", "issues"] },
]);

await processor.onIssueComment(createIssueCommentPayload(true));

expect(deliveredChannels.length).toBe(0);
});
});