Skip to content

Conversation

@shuhuiluo
Copy link
Collaborator

@shuhuiluo shuhuiluo commented Dec 2, 2025

Group related GitHub events into Towns threads to reduce channel noise:

  • PR opened events start threads, follow-up events (reviews, comments, merged/closed) reply to them
  • Issue opened events start threads, follow-up events (comments, closed/reopened) reply to them

Implementation:

  • Add event_threads table to store thread mappings (space, channel, repo, anchor type/number -> Towns eventId)
  • ThreadService handles thread lookup/storage with 30-day expiry
  • EventProcessor sends anchor events as top-level, follow-ups as replies
  • Daily cleanup job removes expired thread mappings

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • GitHub PRs, issues, comments and reviews now group into persistent per-channel discussion threads and reuse thread links.
    • Subscriber deliveries include space context so messages route correctly per space.
    • Delivery flow improved with per-channel threading and more robust error handling.
  • Chores

    • Persistent thread mappings auto-expire after 30 days and are cleaned up daily by a background service.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link

coderabbitai bot commented Dec 2, 2025

Walkthrough

Adds end-to-end event threading: a new SQL migration and Drizzle snapshot adding table event_threads with constraints and indexes; exports eventThreads in src/db/schema.ts; introduces ThreadService and AnchorType with get/store/cleanup and periodic cleanup; EventProcessor now accepts a ThreadService, builds and passes a threadingContext to handlers, and sends messages per-channel using thread mappings; bootstrap instantiates and starts the ThreadService; SubscriptionService.getRepoSubscribers() now returns spaceId.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Review ThreadService: upsert conflict target, expiresAt computation, timezone/now usage, and periodic cleanup error handling.
  • Verify migration/schema: composite unique index, anchor_type check constraint, and indexes match access patterns.
  • Inspect EventProcessor: constructor injection, threadingContext construction/typing, per-channel send flow, threadId retrieval/storage, and error isolation.
  • Confirm bootstrap and lifecycle: ThreadService instantiation and startPeriodicCleanup integration.
  • Check SubscriptionService API change: all callers must accept added spaceId field.

Possibly related PRs

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add thread-based event grouping for PRs and issues' clearly and specifically summarizes the main objective of the changeset—implementing thread-based event grouping for GitHub PR and issue events.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/thread-event-grouping

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0d5aec5 and a421e99.

📒 Files selected for processing (8)
  • drizzle/0006_certain_micromacro.sql (1 hunks)
  • drizzle/meta/0006_snapshot.json (1 hunks)
  • drizzle/meta/_journal.json (1 hunks)
  • src/db/schema.ts (1 hunks)
  • src/github-app/event-processor.ts (10 hunks)
  • src/index.ts (3 hunks)
  • src/services/subscription-service.ts (3 hunks)
  • src/services/thread-service.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/index.ts
  • src/services/subscription-service.ts
  • drizzle/meta/_journal.json
  • drizzle/0006_certain_micromacro.sql
  • drizzle/meta/0006_snapshot.json
🧰 Additional context used
📓 Path-based instructions (2)
**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

**/*.ts: Store context externally - maintain stateless bot architecture with no message history, thread context, or conversation memory
Use <@{userId}> for mentions in messages AND add mentions in sendMessage options - do not use @username format
Implement event handlers for onMessage, onSlashCommand, onReaction, onTip, and onInteractionResponse to respond to Towns Protocol events
Define slash commands in src/commands.ts as a const array with name and description properties, then register handlers using bot.onSlashCommand()
Set ID in interaction requests and match ID in responses to correlate form submissions, button clicks, and transaction/signature responses
Use readContract for reading smart contract state, writeContract for SimpleAccount operations, and execute() for external contract interactions
Fund bot.appAddress (Smart Account) for on-chain operations, not bot.botId (Gas Wallet/EOA)
Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally
Always check permissions using handler.hasAdminPermission() before performing admin operations like ban, redact, or pin
User IDs are hex addresses in format 0x..., not usernames - use them consistently throughout event handling and message sending
Slash commands do not trigger onMessage - register slash command handlers using bot.onSlashCommand() instead
Use getSmartAccountFromUserId() to retrieve a user's wallet address from their userId
Include required environment variables: APP_PRIVATE_DATA (bot credentials) and JWT_SECRET (webhook security token)

Files:

  • src/db/schema.ts
  • src/services/thread-service.ts
  • src/github-app/event-processor.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Provide alt text for image attachments and use appropriate MIME types for chunked attachments (videos, screenshots)

Files:

  • src/db/schema.ts
  • src/services/thread-service.ts
  • src/github-app/event-processor.ts
🧠 Learnings (3)
📓 Common learnings
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: All event handlers receive a base payload including userId, spaceId, channelId, eventId, and createdAt - use eventId as threadId/replyId when responding to maintain event threading
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: All event handlers receive a base payload including userId, spaceId, channelId, eventId, and createdAt - use eventId as threadId/replyId when responding to maintain event threading

Applied to files:

  • src/db/schema.ts
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: Applies to **/*.ts : Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally

Applied to files:

  • src/github-app/event-processor.ts
🧬 Code graph analysis (2)
src/services/thread-service.ts (2)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • eventThreads (237-272)
src/github-app/event-processor.ts (2)
src/services/thread-service.ts (2)
  • AnchorType (6-6)
  • ThreadService (17-142)
src/types/bot.ts (1)
  • TownsBot (8-8)
🪛 ESLint
src/services/thread-service.ts

[error] 1-1: Unable to resolve path to module 'drizzle-orm'.

(import-x/no-unresolved)

🔇 Additional comments (13)
src/db/schema.ts (1)

233-272: LGTM! Well-designed schema for thread mappings.

The table structure correctly models the threading requirements with appropriate constraints and indexes:

  • Composite unique constraint ensures one thread per (space, channel, repo, anchor type, anchor number)
  • Indexes support efficient lookups by repo+anchor and cleanup by expiry
  • Check constraint enforces valid anchor types
src/services/thread-service.ts (4)

1-9: LGTM! Clean type definitions and constants.

The AnchorType type correctly matches the database constraint, and the 30-day default expiry is reasonable for thread lifetime.

Note: The ESLint import error for drizzle-orm is a false positive.


22-45: LGTM! Correct thread lookup logic.

The method properly queries with all composite key fields and excludes expired threads using gte(eventThreads.expiresAt, new Date()).


50-86: Verify: Should re-sending anchor events extend thread lifetime?

The upsert logic updates both threadEventId and expiresAt on conflict (lines 81-84). This means if an anchor event is sent multiple times (e.g., during retries or manual re-processing), it will extend the thread's expiry by another 30 days.

Is this intended behavior, or should the expiry remain unchanged after the initial creation?

If the expiry should not be updated, apply this diff:

       .onConflictDoUpdate({
         target: [
           eventThreads.spaceId,
           eventThreads.channelId,
           eventThreads.repoFullName,
           eventThreads.anchorType,
           eventThreads.anchorNumber,
         ],
         set: {
           threadEventId: params.threadEventId,
-          expiresAt,
         },
       });

92-141: LGTM! Robust cleanup implementation.

The cleanup methods are well-designed:

  • cleanupExpired correctly deletes expired mappings
  • startPeriodicCleanup runs immediate cleanup on start and includes proper error handling
  • Logging provides operational visibility
src/github-app/event-processor.ts (8)

22-22: LGTM! Clean ThreadService integration.

The dependency injection pattern is properly implemented, and the ThreadingContext interface appropriately models the anchor/follow-up distinction with the isAnchor flag.

Also applies to: 39-46, 57-57, 59-67


79-88: LGTM! Backward-compatible signature extension.

The optional threadingContext parameter enables threading while maintaining backward compatibility for events that don't require threading.


102-108: LGTM! Extracted repoFullName for clarity and reuse.

Caching repoFullName improves readability and enables reuse in the threading logic below.


143-183: LGTM! Well-structured threading logic with proper error handling.

The per-channel threading implementation correctly:

  • Looks up existing threads for follow-up events
  • Sends messages with appropriate threadId
  • Stores thread mappings for anchor events after successful sends
  • Uses Promise.allSettled to ensure all channels are attempted even if some fail

Minor note: Line 158's ?? undefined is redundant since getThreadId already returns string | null, and undefined is the intended fallback when threadId is not provided to sendMessage.


190-205: LGTM! Correct PR threading implementation.

The logic correctly identifies "opened" actions as anchors and passes appropriate threading context for PR events.


228-242: LGTM! Issue threading correctly mirrors PR pattern.

The handler properly identifies "opened" actions as anchors and threads subsequent issue events accordingly.


279-296: LGTM! Correct PR vs issue comment detection.

The logic correctly determines whether a comment is on a PR or issue using the pull_request field (lines 281-283), as GitHub fires issue_comment webhooks for both issues and PRs.


303-338: LGTM! Reviews and review comments correctly thread to parent PR.

Both handlers properly set anchorType: "pr" and isAnchor: false to thread these follow-up events to their parent PR discussions.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/services/thread-service.ts (1)

111-145: Consider storing interval ID for graceful shutdown.

The returned timer ID is not stored or used in src/index.ts. While this works for long-running processes, storing the interval ID would enable graceful cleanup during testing or shutdown scenarios.

 // In src/index.ts
-threadService.startPeriodicCleanup();
+const threadCleanupInterval = threadService.startPeriodicCleanup();
+// Can be used later: clearInterval(threadCleanupInterval)
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 719b160 and f852948.

📒 Files selected for processing (8)
  • drizzle/0006_certain_micromacro.sql (1 hunks)
  • drizzle/meta/0006_snapshot.json (1 hunks)
  • drizzle/meta/_journal.json (1 hunks)
  • src/db/schema.ts (1 hunks)
  • src/github-app/event-processor.ts (8 hunks)
  • src/index.ts (3 hunks)
  • src/services/subscription-service.ts (3 hunks)
  • src/services/thread-service.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

**/*.ts: Store context externally - maintain stateless bot architecture with no message history, thread context, or conversation memory
Use <@{userId}> for mentions in messages AND add mentions in sendMessage options - do not use @username format
Implement event handlers for onMessage, onSlashCommand, onReaction, onTip, and onInteractionResponse to respond to Towns Protocol events
Define slash commands in src/commands.ts as a const array with name and description properties, then register handlers using bot.onSlashCommand()
Set ID in interaction requests and match ID in responses to correlate form submissions, button clicks, and transaction/signature responses
Use readContract for reading smart contract state, writeContract for SimpleAccount operations, and execute() for external contract interactions
Fund bot.appAddress (Smart Account) for on-chain operations, not bot.botId (Gas Wallet/EOA)
Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally
Always check permissions using handler.hasAdminPermission() before performing admin operations like ban, redact, or pin
User IDs are hex addresses in format 0x..., not usernames - use them consistently throughout event handling and message sending
Slash commands do not trigger onMessage - register slash command handlers using bot.onSlashCommand() instead
Use getSmartAccountFromUserId() to retrieve a user's wallet address from their userId
Include required environment variables: APP_PRIVATE_DATA (bot credentials) and JWT_SECRET (webhook security token)

Files:

  • src/db/schema.ts
  • src/index.ts
  • src/services/subscription-service.ts
  • src/services/thread-service.ts
  • src/github-app/event-processor.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Provide alt text for image attachments and use appropriate MIME types for chunked attachments (videos, screenshots)

Files:

  • src/db/schema.ts
  • src/index.ts
  • src/services/subscription-service.ts
  • src/services/thread-service.ts
  • src/github-app/event-processor.ts
🧠 Learnings (3)
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: All event handlers receive a base payload including userId, spaceId, channelId, eventId, and createdAt - use eventId as threadId/replyId when responding to maintain event threading

Applied to files:

  • src/db/schema.ts
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: Applies to **/*.ts : Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally

Applied to files:

  • src/github-app/event-processor.ts
📚 Learning: 2025-11-18T23:35:49.436Z
Learnt from: shuhuiluo
Repo: HereNotThere/bot-github PR: 26
File: src/db/index.ts:61-69
Timestamp: 2025-11-18T23:35:49.436Z
Learning: In `webhook_deliveries` table (src/db/index.ts), the `installation_id` column should NOT have a FOREIGN KEY constraint because the table serves as an immutable audit log for idempotency tracking. Records must persist independently even after installations are deleted, and a foreign key would create race conditions when webhooks arrive before installation records are created. The field is intentionally nullable to support webhooks without installation context.

Applied to files:

  • drizzle/meta/0006_snapshot.json
🧬 Code graph analysis (2)
src/index.ts (2)
src/services/thread-service.ts (1)
  • ThreadService (17-146)
src/github-app/event-processor.ts (1)
  • EventProcessor (52-368)
src/github-app/event-processor.ts (1)
src/services/thread-service.ts (2)
  • AnchorType (6-6)
  • ThreadService (17-146)
🔇 Additional comments (12)
drizzle/meta/_journal.json (1)

46-53: LGTM!

The migration journal entry follows the established pattern with consistent versioning and breakpoints configuration.

src/index.ts (2)

54-62: LGTM!

The ThreadService is correctly instantiated and wired into the EventProcessor. The initialization order ensures dependencies are available before use.


240-242: LGTM!

Periodic cleanup follows the same pattern as the OAuth cleanup service. The internal error handling in startPeriodicCleanup ensures failures don't crash the application.

src/services/subscription-service.ts (1)

614-641: LGTM!

The addition of spaceId to the return type and query is a clean, additive change that enables thread lookups without breaking existing consumers.

src/db/schema.ts (1)

232-272: LGTM!

The schema definition is well-documented and correctly mirrors the SQL migration. The check constraint and indexes align with the ThreadService operations. Based on learnings, the threadEventId correctly stores the Towns eventId for thread/reply correlation.

drizzle/meta/0006_snapshot.json (1)

1-963: LGTM!

This is an auto-generated Drizzle snapshot that correctly captures the new event_threads table schema with its constraints and indexes.

src/services/thread-service.ts (2)

17-44: LGTM!

The getThreadId method correctly queries all five composite key columns and safely returns null when no thread exists.


49-90: LGTM!

The upsert logic correctly handles both insert and conflict cases. On conflict, only threadEventId and expiresAt are updated, preserving the original createdAt timestamp which is appropriate for tracking when the thread was first created.

src/github-app/event-processor.ts (4)

37-44: LGTM!

The ThreadingContext interface cleanly encapsulates the threading metadata needed for event grouping.


52-65: LGTM!

Constructor properly accepts and stores the ThreadService dependency.


280-297: Correct detection of PR vs issue comments.

The pull_request field check correctly distinguishes between comments on PRs and issues, ensuring they thread to the appropriate parent.


133-183: Verified: Threading implementation correctly uses Towns Protocol bot SDK.

The code properly handles threading:

  • Anchor events (opened): sends message with sendMessage(channelId, message), stores the returned eventId as threadEventId for subsequent replies
  • Follow-up events: retrieves stored threadEventId and passes as threadId option to sendMessage(channelId, message, { threadId })
  • Graceful fallback: if no thread mapping exists, sends as top-level message

This matches the Towns Protocol bot SDK signature where sendMessage returns { eventId } and accepts a { threadId } option to post within existing threads.

@shuhuiluo shuhuiluo force-pushed the feat/thread-event-grouping branch from f852948 to 09e156e Compare December 3, 2025 10:48
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/github-app/event-processor.ts (1)

335-344: Missing threading context for PR review comments.

onPullRequestReviewComment doesn't include threadingContext, but onPullRequestReview does (lines 314-328). This inconsistency means review comments won't thread to their parent PR, while reviews will.

   async onPullRequestReviewComment(event: PullRequestReviewCommentPayload) {
     const { pull_request, repository } = event;
     const baseBranch = pull_request.base.ref;
     await this.processEvent(
       event,
       "review_comments",
       formatPullRequestReviewComment,
       `PR review comment event: ${event.action} - ${repository.full_name}#${pull_request.number}`,
-      { branch: baseBranch }
+      { branch: baseBranch },
+      {
+        anchorType: "pr",
+        anchorNumber: pull_request.number,
+        isAnchor: false, // Review comments are never anchors
+      }
     );
   }
🧹 Nitpick comments (2)
src/index.ts (1)

243-245: Consider storing the timer ID for graceful shutdown.

The timer ID returned by startPeriodicCleanup() is discarded. While this matches the pattern used for oauthService.startOAuthStateCleanup(), storing it would allow stopping the cleanup job during graceful shutdown.

+// Store cleanup timer for graceful shutdown
+const threadCleanupTimer = threadService.startPeriodicCleanup();
-threadService.startPeriodicCleanup();
 console.log("✅ Thread cleanup service started (daily cleanup)");
src/services/thread-service.ts (1)

22-44: Consider filtering out expired threads in getThreadId.

The query doesn't check expiresAt, so it could return expired thread IDs if cleanup hasn't run yet. While cleanup runs at startup, there's still a window during the 24h interval where expired entries may persist.

     const results = await db
       .select({ threadEventId: eventThreads.threadEventId })
       .from(eventThreads)
       .where(
         and(
           eq(eventThreads.spaceId, spaceId),
           eq(eventThreads.channelId, channelId),
           eq(eventThreads.repoFullName, repoFullName),
           eq(eventThreads.anchorType, anchorType),
-          eq(eventThreads.anchorNumber, anchorNumber)
+          eq(eventThreads.anchorNumber, anchorNumber),
+          // Exclude expired threads
+          lt(new Date(), eventThreads.expiresAt)
         )
       )
       .limit(1);

Note: You'll need to import or use the appropriate comparison. Since lt checks column < value, you'd need gte (greater than or equal) for expiresAt >= now:

-import { and, eq, lt } from "drizzle-orm";
+import { and, eq, gte, lt } from "drizzle-orm";

And in the query:

+          gte(eventThreads.expiresAt, new Date())
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f852948 and 09e156e.

📒 Files selected for processing (8)
  • drizzle/0006_certain_micromacro.sql (1 hunks)
  • drizzle/meta/0006_snapshot.json (1 hunks)
  • drizzle/meta/_journal.json (1 hunks)
  • src/db/schema.ts (1 hunks)
  • src/github-app/event-processor.ts (9 hunks)
  • src/index.ts (3 hunks)
  • src/services/subscription-service.ts (3 hunks)
  • src/services/thread-service.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (5)
  • drizzle/meta/_journal.json
  • drizzle/0006_certain_micromacro.sql
  • src/db/schema.ts
  • drizzle/meta/0006_snapshot.json
  • src/services/subscription-service.ts
🧰 Additional context used
📓 Path-based instructions (2)
**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

**/*.ts: Store context externally - maintain stateless bot architecture with no message history, thread context, or conversation memory
Use <@{userId}> for mentions in messages AND add mentions in sendMessage options - do not use @username format
Implement event handlers for onMessage, onSlashCommand, onReaction, onTip, and onInteractionResponse to respond to Towns Protocol events
Define slash commands in src/commands.ts as a const array with name and description properties, then register handlers using bot.onSlashCommand()
Set ID in interaction requests and match ID in responses to correlate form submissions, button clicks, and transaction/signature responses
Use readContract for reading smart contract state, writeContract for SimpleAccount operations, and execute() for external contract interactions
Fund bot.appAddress (Smart Account) for on-chain operations, not bot.botId (Gas Wallet/EOA)
Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally
Always check permissions using handler.hasAdminPermission() before performing admin operations like ban, redact, or pin
User IDs are hex addresses in format 0x..., not usernames - use them consistently throughout event handling and message sending
Slash commands do not trigger onMessage - register slash command handlers using bot.onSlashCommand() instead
Use getSmartAccountFromUserId() to retrieve a user's wallet address from their userId
Include required environment variables: APP_PRIVATE_DATA (bot credentials) and JWT_SECRET (webhook security token)

Files:

  • src/services/thread-service.ts
  • src/github-app/event-processor.ts
  • src/index.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Provide alt text for image attachments and use appropriate MIME types for chunked attachments (videos, screenshots)

Files:

  • src/services/thread-service.ts
  • src/github-app/event-processor.ts
  • src/index.ts
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: All event handlers receive a base payload including userId, spaceId, channelId, eventId, and createdAt - use eventId as threadId/replyId when responding to maintain event threading
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: Applies to **/*.ts : Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally

Applied to files:

  • src/github-app/event-processor.ts
🧬 Code graph analysis (2)
src/services/thread-service.ts (2)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • eventThreads (237-272)
src/index.ts (2)
src/services/thread-service.ts (1)
  • ThreadService (17-146)
src/github-app/event-processor.ts (1)
  • EventProcessor (54-394)
🔇 Additional comments (8)
src/index.ts (1)

52-60: LGTM! ThreadService integration is well-structured.

The ThreadService is correctly instantiated and injected into EventProcessor, following the established dependency injection pattern used for other services.

src/github-app/event-processor.ts (4)

39-46: LGTM! Clean interface for threading context.

The ThreadingContext interface clearly separates anchor events (that start threads) from follow-up events (that reply to threads).


143-193: Solid threading implementation with proper error isolation.

The per-channel try/catch ensures one channel's failure doesn't affect others, and Promise.allSettled correctly awaits all sends. The fallback to top-level messages when no thread is found handles the case where anchor events occurred before the bot joined.


201-215: LGTM! PR threading correctly identifies opened events as anchors.

The action === "opened" check properly distinguishes anchor events from follow-ups like reviews, comments, and state changes.


290-306: Good detection of PR vs issue comments.

The check for "pull_request" in issue && issue.pull_request != null correctly handles GitHub's behavior of firing issue_comment for both issues and PRs, ensuring comments thread to the appropriate anchor type.

src/services/thread-service.ts (3)

64-90: LGTM! Upsert logic handles re-opened PRs/issues correctly.

The onConflictDoUpdate ensures that if a PR or issue is closed and reopened, the thread mapping is refreshed with a new threadEventId and extended expiresAt.


96-103: LGTM! Efficient cleanup with indexed query.

The cleanup uses the expiresIndex defined in the schema for efficient deletion, and returning() provides the count without a separate query.


118-129: Initial cleanup is fire-and-forget by design.

The initial cleanup runs asynchronously without blocking startup, which is reasonable for a non-critical operation. Errors are properly caught and logged.

@shuhuiluo shuhuiluo force-pushed the feat/thread-event-grouping branch from 679224e to 93c483d Compare December 4, 2025 03:51
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/github-app/event-processor.ts (1)

335-345: Missing threading context for PR review comments.

onPullRequestReviewComment should thread to the parent PR, similar to onPullRequestReview. This appears to be an oversight since review comments are logically part of the PR discussion.

Apply this diff to add threading support:

 async onPullRequestReviewComment(event: PullRequestReviewCommentPayload) {
   const { pull_request, repository } = event;
   const baseBranch = pull_request.base.ref;
   await this.processEvent(
     event,
     "review_comments",
     formatPullRequestReviewComment,
     `PR review comment event: ${event.action} - ${repository.full_name}#${pull_request.number}`,
-    { branch: baseBranch }
+    { branch: baseBranch },
+    {
+      anchorType: "pr",
+      anchorNumber: pull_request.number,
+      isAnchor: false, // Review comments are never anchors
+    }
   );
 }
🧹 Nitpick comments (2)
src/db/schema.ts (1)

263-268: Consider removing the redundant repoAnchorIndex.

The repoAnchorIndex on (repoFullName, anchorType, anchorNumber) appears to be redundant. The primary query in ThreadService.getThreadId uses all five columns (spaceId, channelId, repoFullName, anchorType, anchorNumber), which is already covered by uniqueThread. There doesn't appear to be a query pattern that would benefit from this partial index.

If there's a planned use case for looking up threads across all spaces/channels for a given repo anchor, this index would be useful. Otherwise, it adds write overhead without query benefit.

src/services/thread-service.ts (1)

112-146: Consider structured logging for observability.

The cleanup logic is correct with proper error handling. For production observability, consider using structured logging with key-value pairs rather than template strings, which would make it easier to query and aggregate in logging systems.

-console.log(
-  `[Thread Cleanup] Starting periodic cleanup (every ${intervalMs / 1000 / 60 / 60} hours)`
-);
+console.log("[Thread Cleanup] Starting periodic cleanup", {
+  intervalHours: intervalMs / 1000 / 60 / 60,
+});
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 679224e and 93c483d.

📒 Files selected for processing (8)
  • drizzle/0006_certain_micromacro.sql (1 hunks)
  • drizzle/meta/0006_snapshot.json (1 hunks)
  • drizzle/meta/_journal.json (1 hunks)
  • src/db/schema.ts (1 hunks)
  • src/github-app/event-processor.ts (9 hunks)
  • src/index.ts (3 hunks)
  • src/services/subscription-service.ts (3 hunks)
  • src/services/thread-service.ts (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/services/subscription-service.ts
  • drizzle/meta/0006_snapshot.json
  • drizzle/0006_certain_micromacro.sql
🧰 Additional context used
📓 Path-based instructions (2)
**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

**/*.ts: Store context externally - maintain stateless bot architecture with no message history, thread context, or conversation memory
Use <@{userId}> for mentions in messages AND add mentions in sendMessage options - do not use @username format
Implement event handlers for onMessage, onSlashCommand, onReaction, onTip, and onInteractionResponse to respond to Towns Protocol events
Define slash commands in src/commands.ts as a const array with name and description properties, then register handlers using bot.onSlashCommand()
Set ID in interaction requests and match ID in responses to correlate form submissions, button clicks, and transaction/signature responses
Use readContract for reading smart contract state, writeContract for SimpleAccount operations, and execute() for external contract interactions
Fund bot.appAddress (Smart Account) for on-chain operations, not bot.botId (Gas Wallet/EOA)
Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally
Always check permissions using handler.hasAdminPermission() before performing admin operations like ban, redact, or pin
User IDs are hex addresses in format 0x..., not usernames - use them consistently throughout event handling and message sending
Slash commands do not trigger onMessage - register slash command handlers using bot.onSlashCommand() instead
Use getSmartAccountFromUserId() to retrieve a user's wallet address from their userId
Include required environment variables: APP_PRIVATE_DATA (bot credentials) and JWT_SECRET (webhook security token)

Files:

  • src/index.ts
  • src/db/schema.ts
  • src/github-app/event-processor.ts
  • src/services/thread-service.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Provide alt text for image attachments and use appropriate MIME types for chunked attachments (videos, screenshots)

Files:

  • src/index.ts
  • src/db/schema.ts
  • src/github-app/event-processor.ts
  • src/services/thread-service.ts
🧠 Learnings (3)
📓 Common learnings
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: All event handlers receive a base payload including userId, spaceId, channelId, eventId, and createdAt - use eventId as threadId/replyId when responding to maintain event threading
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: All event handlers receive a base payload including userId, spaceId, channelId, eventId, and createdAt - use eventId as threadId/replyId when responding to maintain event threading

Applied to files:

  • src/db/schema.ts
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: Applies to **/*.ts : Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally

Applied to files:

  • src/github-app/event-processor.ts
🧬 Code graph analysis (3)
src/index.ts (2)
src/services/thread-service.ts (1)
  • ThreadService (17-147)
src/github-app/event-processor.ts (1)
  • EventProcessor (54-394)
src/github-app/event-processor.ts (1)
src/services/thread-service.ts (2)
  • AnchorType (6-6)
  • ThreadService (17-147)
src/services/thread-service.ts (2)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • eventThreads (237-272)
🪛 ESLint
src/services/thread-service.ts

[error] 1-1: Unable to resolve path to module 'drizzle-orm'.

(import-x/no-unresolved)

🔇 Additional comments (11)
drizzle/meta/_journal.json (1)

46-53: LGTM!

The new migration journal entry follows the established pattern and correctly appends the 0006_certain_micromacro migration for the event_threads table.

src/index.ts (2)

52-60: LGTM!

The ThreadService integration follows the established patterns in the codebase. The service is properly instantiated and injected into the EventProcessor, consistent with how other services are wired.


243-245: LGTM!

The periodic cleanup startup mirrors the OAuth cleanup pattern and provides appropriate logging. The daily interval aligns with the 30-day expiry strategy.

src/db/schema.ts (1)

232-272: LGTM - well-designed schema for thread mappings.

The table structure correctly models the threading requirement with appropriate constraints. The composite unique index on (spaceId, channelId, repoFullName, anchorType, anchorNumber) aligns with the ThreadService.getThreadId query pattern. Based on learnings, using threadEventId to store the Towns eventId is the correct approach for thread replies.

src/services/thread-service.ts (3)

1-9: LGTM!

Clean imports and well-named constants. The AnchorType export provides type safety for threading context. The static analysis hint about drizzle-orm is a false positive—the module is a valid project dependency.


22-45: LGTM - correct query implementation.

The getThreadId method properly filters by all composite key columns and excludes expired threads with the gte(expiresAt, now) check. Using limit(1) is appropriate since the unique index guarantees at most one result.


78-90: Verify upsert behavior preserves createdAt.

The onConflictDoUpdate only updates threadEventId and expiresAt, which means createdAt is preserved on re-opened PRs/issues. This appears intentional (keeps the original thread creation time), but worth confirming this is the desired behavior.

Additionally, the expiry extension on conflict means a re-opened PR/issue refreshes the 30-day window—this seems reasonable for active threads.

src/github-app/event-processor.ts (4)

39-46: LGTM - clean interface definition.

The ThreadingContext interface clearly models the threading behavior with isAnchor distinguishing between thread-starting and follow-up events.


143-193: LGTM - well-implemented threading logic.

The per-channel send flow correctly:

  1. Sends anchor events as top-level messages and stores thread mappings
  2. Looks up existing threads for follow-up events
  3. Falls back to top-level messages gracefully when no thread exists
  4. Uses Promise.allSettled to ensure one channel failure doesn't block others

The error handling is appropriate for production use.


201-216: LGTM - correct anchor detection for PRs.

Using action === "opened" correctly identifies when to start a new thread. Follow-up actions (merged, closed, synchronize, etc.) will reply to the existing thread.


290-307: LGTM - smart detection of PR vs issue comments.

Correctly uses the presence of issue.pull_request to determine the anchor type, since GitHub fires issue_comment events for both issues and PRs.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
src/github-app/event-processor.ts (1)

324-333: Consider threading PR review comments to the parent PR as well

onPullRequestReviewComment currently calls processEvent without a threadingContext, so review comment notifications will always appear as top‑level messages, even when a thread exists for the parent PR.

If you want all PR‑related follow‑ups grouped (as with reviews and issue comments), consider passing a threading context here too:

  async onPullRequestReviewComment(event: PullRequestReviewCommentPayload) {
    const { pull_request, repository } = event;
    const baseBranch = pull_request.base.ref;
    await this.processEvent(
      event,
      "review_comments",
      formatPullRequestReviewComment,
      `PR review comment event: ${event.action} - ${repository.full_name}#${pull_request.number}`,
-      { branch: baseBranch }
+      { branch: baseBranch },
+      {
+        anchorType: "pr",
+        anchorNumber: pull_request.number,
+        isAnchor: false, // review comments are follow-ups
+      }
    );
  }

This would keep review comments in the same Towns thread as the PR and its other follow‑up events.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 93c483d and 0d5aec5.

📒 Files selected for processing (2)
  • src/github-app/event-processor.ts (9 hunks)
  • src/services/thread-service.ts (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

**/*.ts: Store context externally - maintain stateless bot architecture with no message history, thread context, or conversation memory
Use <@{userId}> for mentions in messages AND add mentions in sendMessage options - do not use @username format
Implement event handlers for onMessage, onSlashCommand, onReaction, onTip, and onInteractionResponse to respond to Towns Protocol events
Define slash commands in src/commands.ts as a const array with name and description properties, then register handlers using bot.onSlashCommand()
Set ID in interaction requests and match ID in responses to correlate form submissions, button clicks, and transaction/signature responses
Use readContract for reading smart contract state, writeContract for SimpleAccount operations, and execute() for external contract interactions
Fund bot.appAddress (Smart Account) for on-chain operations, not bot.botId (Gas Wallet/EOA)
Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally
Always check permissions using handler.hasAdminPermission() before performing admin operations like ban, redact, or pin
User IDs are hex addresses in format 0x..., not usernames - use them consistently throughout event handling and message sending
Slash commands do not trigger onMessage - register slash command handlers using bot.onSlashCommand() instead
Use getSmartAccountFromUserId() to retrieve a user's wallet address from their userId
Include required environment variables: APP_PRIVATE_DATA (bot credentials) and JWT_SECRET (webhook security token)

Files:

  • src/github-app/event-processor.ts
  • src/services/thread-service.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

Provide alt text for image attachments and use appropriate MIME types for chunked attachments (videos, screenshots)

Files:

  • src/github-app/event-processor.ts
  • src/services/thread-service.ts
🧠 Learnings (2)
📓 Common learnings
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: All event handlers receive a base payload including userId, spaceId, channelId, eventId, and createdAt - use eventId as threadId/replyId when responding to maintain event threading
📚 Learning: 2025-11-25T03:24:12.463Z
Learnt from: CR
Repo: HereNotThere/bot-github PR: 0
File: AGENTS.md:0-0
Timestamp: 2025-11-25T03:24:12.463Z
Learning: Applies to **/*.ts : Use bot.* handler methods directly (outside event handlers) for unprompted messages via webhooks, timers, or tasks - requires channelId, spaceId, or other context stored externally

Applied to files:

  • src/github-app/event-processor.ts
🧬 Code graph analysis (2)
src/github-app/event-processor.ts (2)
src/services/thread-service.ts (2)
  • AnchorType (6-6)
  • ThreadService (17-142)
src/types/webhooks.ts (3)
  • PullRequestPayload (14-14)
  • IssuesPayload (15-15)
  • IssueCommentPayload (19-19)
src/services/thread-service.ts (2)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • eventThreads (237-272)
🪛 ESLint
src/services/thread-service.ts

[error] 1-1: Unable to resolve path to module 'drizzle-orm'.

(import-x/no-unresolved)

🔇 Additional comments (5)
src/services/thread-service.ts (3)

1-1: Resolve drizzle-orm import lint error

ESLint is flagging import { and, eq, gte, lt } from "drizzle-orm"; as unresolved. Please confirm that:

  • drizzle-orm is present in dependencies (not only devDependencies if this code runs in production), and
  • your TS/eslint/module resolver config is set up so this bare import can be resolved.

Otherwise CI/lint may fail even though the runtime dependency is installed.


22-86: Thread mapping queries and upsert look correct

The getThreadId filter (including expiresAt >= now) and storeThread upsert keyed on (spaceId, channelId, repoFullName, anchorType, anchorNumber) line up with the event_threads schema and unique index. This should give deterministic per-space/channel threading and safe overwrites when an anchor is resent, without leaving expired mappings in use.


92-141: Periodic cleanup implementation is safe and non‑blocking

cleanupExpired() with expiresAt < now combined with startPeriodicCleanup()’s immediate run and setInterval loop looks solid: errors are contained to logging, and deletions are bounded by the indexed expiresAt column. No changes needed here.

src/github-app/event-processor.ts (2)

39-46: Threading integration in processEvent is coherent and robust

The ThreadingContext + per‑channel threadService.getThreadId/storeThread flow is well factored:

  • Anchors (isAnchor=true) always post top‑level and persist the returned eventId as the thread key per (spaceId, channelId, repo, anchorType, anchorNumber).
  • Follow‑ups attempt thread lookup and gracefully fall back to top‑level when no mapping exists or it has expired.
  • Branch filtering and subscription filtering remain unchanged, and Promise.allSettled semantics are preserved via the sendPromises array and per‑channel try/catch.

This aligns with the “store context externally and use event IDs as thread IDs” design described in the repo docs.

Based on learnings, this keeps the bot stateless while using externally stored thread context.

Also applies to: 80-89, 102-183


190-205: Anchor/follow‑up threading for PRs/issues/comments is consistent

Deriving the threading context from the webhook payloads:

  • PRs: anchorType: "pr", anchorNumber: pull_request.number, isAnchor: action === "opened".
  • Issues: anchorType: "issue", anchorNumber: issue.number, isAnchor: action === "opened".
  • Issue comments: detect PR vs issue via issue.pull_request and always treat as isAnchor: false.

This should ensure all PR/issue lifecycle events, comments, and reviews (via onPullRequestReview) correctly attach to the same thread started by the opened event when present, while still working sensibly when a channel only subscribes to a subset of event types.

Also applies to: 228-241, 279-295, 303-317

Group PR and issue events into threads:
- PR/issue opened creates anchor thread
- Follow-up events (comments, reviews, closed) reply to thread
- 30-day thread expiration with daily cleanup
- Filter expired threads in lookups

Changes:
- Add event_threads table for thread mappings
- Add ThreadService for storage/lookup
- Update EventProcessor with threading context
- Include PR review comments in threading

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
@shuhuiluo shuhuiluo force-pushed the feat/thread-event-grouping branch from 0d5aec5 to a421e99 Compare December 4, 2025 05:08
@shuhuiluo shuhuiluo merged commit f5cecd8 into main Dec 4, 2025
2 checks passed
@shuhuiluo shuhuiluo deleted the feat/thread-event-grouping branch December 4, 2025 05:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants