Skip to content

Conversation

@shuhuiluo
Copy link
Collaborator

@shuhuiluo shuhuiluo commented Nov 20, 2025

Add support for granular event type management in GitHub subscriptions:

  • Add getSubscription() method to retrieve specific subscription details
  • Add addEventTypes() method to add event types to existing subscriptions
  • Add removeEventTypes() method to remove specific event types
    • Automatically deletes subscription if all event types are removed
  • Update /github subscribe to add event types when already subscribed
  • Update /github unsubscribe to support --events flag for granular removal
  • Update command descriptions to reflect new capabilities

This allows users to manage subscriptions without removing the entire repository subscription. Users can now:

  • Subscribe to additional event types: /github subscribe owner/repo --events stars,forks
  • Remove specific event types: /github unsubscribe owner/repo --events commits
  • Keep existing subscriptions intact while modifying event preferences

Summary by CodeRabbit

  • New Features
    • Add specific event types to existing subscriptions via the --events flag
    • Granular unsubscribe to remove individual event types while keeping others
  • Messaging
    • Clarified usage text: subscribe can add event types and unsubscribe can remove specific event types
  • Behavior
    • Improved subscribe/unsubscribe flows with clearer success/failure feedback for add/update/remove actions

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

@coderabbitai
Copy link

coderabbitai bot commented Nov 20, 2025

Walkthrough

This PR adds granular GitHub subscription management: users can add or remove specific event types via --events. The github-subscription handler is refactored into dedicated flows (new subscription, update, remove types, full unsubscribe) and delegates OAuth-invalid handling to a new helper. SubscriptionService gains getSubscription, addEventTypes, and removeEventTypes; message formatting for delivery and subscription success is added. OAuth redirect actions are extended to include subscribe-update and unsubscribe-update. Tests and oauth-callback wiring are updated to use the new service methods and messaging helpers.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25–30 minutes

  • Verify new public methods in SubscriptionService (getSubscription, addEventTypes, removeEventTypes) for correctness: validation, merge/deduplication, deletion-on-empty behavior.
  • Review oauth-callback changes that route update/unsubscribe flows and ensure correct use of sendSubscriptionSuccess and error branches.
  • Inspect github-subscription-handler refactor for correct OAuth token-status handling and unified exit paths via handleInvalidOAuthToken.
  • Pay attention to parseEventTypes/parseRepoArg utilities for normalization and case-insensitive repo handling.

Possibly related PRs

  • #67 — Refactors event-type handling to use EventType[] and shared parsing/formatting utilities (overlaps parseEventTypes/formatEventTypes and DEFAULT_EVENT_TYPES_ARRAY changes).
  • #29 — Introduces core OAuth infrastructure (RedirectAction, GitHubOAuthService) that this PR extends with new redirect actions and OAuth flows.
  • #59 — Adds centralized OAuth token-status handling and prompting logic; closely related to the new handleInvalidOAuthToken utility and token-status branches.

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 PR title 'feat: implement granular subscription management' accurately summarizes the main change: adding granular event-type management capabilities to GitHub subscriptions.
Docstring Coverage ✅ Passed Docstring coverage is 93.33% which is sufficient. The required threshold is 80.00%.
✨ 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 claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o

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: 2

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

385-446: Consider normalizing newEventTypes inside the service for robustness

The logic to merge and deduplicate event types is sound, and validation against ALLOWED_EVENT_TYPES is correct. However, addEventTypes currently assumes callers will pass already-trimmed, lowercased values.

To make the service API more defensive and self-contained (especially if other callers are added later), consider normalizing newEventTypes here:

  • Trim and lowercase each incoming type.
  • Optionally dedupe before validation.

For example:

-  async addEventTypes(
+  async addEventTypes(
     spaceId: string,
     channelId: string,
     repoFullName: string,
-    newEventTypes: string[]
+    newEventTypes: string[]
   ): Promise<{ success: boolean; eventTypes?: string; error?: string }> {
+    const normalizedNewTypes = newEventTypes
+      .map(t => t.trim().toLowerCase())
+      .filter(t => t.length > 0);
+
     const subscription = await this.getSubscription(
       spaceId,
       channelId,
       repoFullName
     );
@@
-    const invalidTypes = newEventTypes.filter(
+    const invalidTypes = normalizedNewTypes.filter(
       t => !allowedSet.has(t as (typeof ALLOWED_EVENT_TYPES)[number])
     );
@@
-    const mergedTypes = Array.from(
-      new Set([...existingTypes, ...newEventTypes])
+    const mergedTypes = Array.from(
+      new Set([...existingTypes, ...normalizedNewTypes])
     );

This keeps handler code simpler and prevents subtle bugs if future callers pass differently cased or spaced strings.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2df5ffd and 5d1696d.

📒 Files selected for processing (3)
  • src/commands.ts (1 hunks)
  • src/handlers/github-subscription-handler.ts (4 hunks)
  • src/services/subscription-service.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/handlers/github-subscription-handler.ts (1)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
src/services/subscription-service.ts (3)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • githubSubscriptions (72-109)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
🔇 Additional comments (6)
src/commands.ts (1)

10-11: Github command description accurately reflects new granular capabilities

The updated description matches the new subscribe/unsubscribe behaviors and remains concise and clear. No changes needed.

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

3-6: Use of shared event-type constants is correct

Importing ALLOWED_EVENT_TYPES and DEFAULT_EVENT_TYPES here keeps the service aligned with handler validation and defaults. This centralization avoids drift between layers.


350-379: getSubscription implementation is straightforward and sufficient

Query filters on (spaceId, channelId, repoFullName) and returns the minimal fields (id, eventTypes, deliveryMode), with a clear null fallback when no row exists. This is a good low-level helper for higher-level methods.


453-517: removeEventTypes flow and auto-deletion behavior look correct

The method correctly:

  • Fetches the subscription by (spaceId, channelId, repoFullName).
  • Parses stored event types.
  • Computes remaining types after removal.
  • Deletes the subscription entirely when no types remain, otherwise updates eventTypes and updatedAt.

The return shape (success, deleted, eventTypes) is clear and gives callers enough information to present accurate UI/messaging. No changes required at the service layer.

src/handlers/github-subscription-handler.ts (2)

23-25: Help text accurately documents new granular event options

The updated usage bullets clearly describe --events for both subscribe and unsubscribe, including the full allowed list and the granular-removal behavior. This keeps the command self-discoverable.


204-210: Unsubscribe usage text and args destructuring look good

Including args in the event destructuring and updating the usage string to advertise --events matches the new granular behavior and is consistent with the subscribe help. No changes needed here.

@shuhuiluo shuhuiluo force-pushed the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch from 5d1696d to b422651 Compare November 22, 2025 20:34
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/subscription-service.ts (1)

350-517: New event-type helpers look correct; consider small robustness improvements

The getSubscription, addEventTypes, and removeEventTypes flows look sound: they correctly locate the row, validate additions, merge/dedup types, and delete the subscription when nothing remains.

A few non-blocking refinements to consider:

  • When parsing subscription.eventTypes, both methods currently keep empty tokens if the string ever contained stray commas:

    const existingTypes = subscription.eventTypes
      ? subscription.eventTypes.split(",").map(t => t.trim())
      : [];

    You could defensively filter falsy values (.filter(Boolean)) to avoid ever persisting blanks back to the DB.

  • removeEventTypes does not validate typesToRemove against ALLOWED_EVENT_TYPES (unlike addEventTypes). Today the handler guarantees valid input, but if these methods are ever reused elsewhere, invalid tokens will be silently ignored. Extracting a small shared validateEventTypes(string[]) helper in this service and reusing it in both methods would keep API behavior consistent.

  • Parameter ordering across service methods is a bit mixed (getChannelSubscriptions(channelId, spaceId) vs addEventTypes(spaceId, channelId, ...)). It’s easy to call with swapped arguments in future call sites; consider standardizing on one order or moving to an object parameter for new APIs.

None of these are blockers, but tightening them would make the service more defensive and easier to use correctly.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d1696d and b422651.

📒 Files selected for processing (3)
  • src/commands.ts (1 hunks)
  • src/handlers/github-subscription-handler.ts (4 hunks)
  • src/services/subscription-service.ts (2 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/services/subscription-service.ts (3)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • githubSubscriptions (72-109)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
src/handlers/github-subscription-handler.ts (1)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
🔇 Additional comments (4)
src/commands.ts (1)

9-12: github command description correctly reflects new granular behavior

The updated description succinctly matches the enhanced subscribe/unsubscribe/status capabilities; no further changes needed here.

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

3-6: Event-type constants import is correctly wired

Using both ALLOWED_EVENT_TYPES and DEFAULT_EVENT_TYPES here is appropriate for the new event-type management methods; nothing to adjust.

src/handlers/github-subscription-handler.ts (2)

19-26: Updated /github usage text is accurate and helpful

The top-level usage lines now clearly describe granular --events support for subscribe/unsubscribe and align with the underlying behavior.


204-336: Granular --events unsubscribe flow is robust and user-friendly

The extended handleUnsubscribe logic:

  • Correctly parses --events in both --events=pr,issues and --events pr,issues forms.
  • Validates requested types against ALLOWED_EVENT_TYPES with clear error messaging.
  • Computes actuallyRemoved by intersecting the requested types with subscription.eventTypes, so the “Removed” list now reflects what truly changed.
  • Falls back cleanly to full unsubscribe when --events is omitted.

This aligns well with the intended granular unsubscribe behavior.

Also applies to: 338-338

@shuhuiluo shuhuiluo force-pushed the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch from b422651 to c6f1ad3 Compare November 22, 2025 20:53
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 (2)
src/services/subscription-service.ts (1)

404-425: Normalize event types inside the service to avoid case/whitespace edge cases

Both addEventTypes and removeEventTypes currently trust callers to pass canonical event type strings (trimmed, lowercase). In practice the handler does that today, but the service itself:

  • Parses subscription.eventTypes with split(",").map(t => t.trim()) (no .toLowerCase()).
  • Compares against newEventTypes / typesToRemove as-is, so comparisons are case-sensitive.
  • In removeEventTypes, typesToRemove coming from the handler are lowercased, but existingTypes may not be, which can lead to no-op removals if legacy rows or other callers ever stored mixed-case values.

To make the service robust and self-contained, consider normalizing to a canonical lowercase form at the boundary of both methods, e.g.:

  • When parsing subscription.eventTypes, map(t => t.trim().toLowerCase()).
  • When consuming newEventTypes / typesToRemove, also trim + lowercase before validation and merging/removal.
  • Persist updatedEventTypes as a comma‑separated list of normalized, deduped tokens.

Sketch:

-const existingTypes = subscription.eventTypes
-  ? subscription.eventTypes.split(",").map(t => t.trim())
-  : [];
+const existingTypes = subscription.eventTypes
+  ? subscription.eventTypes
+      .split(",")
+      .map(t => t.trim().toLowerCase())
+      .filter(t => t.length > 0)
+  : [];

-const mergedTypes = Array.from(
-  new Set([...existingTypes, ...newEventTypes])
-);
+const normalizedNew = newEventTypes
+  .map(t => t.trim().toLowerCase())
+  .filter(t => t.length > 0);
+const mergedTypes = Array.from(
+  new Set([...existingTypes, ...normalizedNew])
+);

and similarly normalize existingTypes/typesToRemove in removeEventTypes before filtering.

This keeps behavior consistent even if older data or future callers don't perfectly match the handler’s normalization.

Also applies to: 477-503

src/handlers/github-subscription-handler.ts (1)

263-347: Granular unsubscribe logic is solid; keep normalization aligned with service

The --events handling here is thorough:

  • Supports both --events=pr,issues and --events pr,issues.
  • Normalizes requested types to lowercase, filters empties, and validates against ALLOWED_EVENT_TYPES.
  • Computes actuallyRemoved as the intersection with the current subscription and reports “(none)” when over-specified.
  • Falls back cleanly to full unsubscribe when --events is absent.

Given the service-level removeEventTypes currently does case-sensitive comparisons on subscription.eventTypes, it’s worth keeping the handler and service aligned on normalization. Once SubscriptionService.removeEventTypes is updated to normalize to lowercase before filtering (as suggested in the service file), this handler can rely on that canonicalization and you avoid any divergence between what’s reported in actuallyRemoved and what the DB actually stores/removes.

No changes strictly required here after that; just ensure any future tweaks to normalization happen in one place (ideally the service) and the handler continues to treat event type strings as opaque user-facing labels.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b422651 and c6f1ad3.

📒 Files selected for processing (3)
  • src/commands.ts (1 hunks)
  • src/handlers/github-subscription-handler.ts (5 hunks)
  • src/services/subscription-service.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/services/subscription-service.ts (3)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • githubSubscriptions (72-109)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
src/handlers/github-subscription-handler.ts (1)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
🔇 Additional comments (3)
src/services/subscription-service.ts (1)

350-379: getSubscription implementation fits schema and usage

The single-row lookup using the composite key (spaceId, channelId, repoFullName) is aligned with the unique index in the schema and provides exactly the shape needed by the handlers. No issues from a correctness or performance perspective here.

src/handlers/github-subscription-handler.ts (2)

23-25: Updated usage text clearly reflects granular event management

The top-level usage/help now accurately documents the --events flag, allowed values, and the ability to either add or remove specific event types. This will make the new behavior discoverable.


83-134: Subscribe flow correctly gates “append events” behavior on --events

The combination of:

  • hasEventsFlag = args.some(arg => arg.startsWith("--events"));
  • Case-insensitive lookup via getChannelSubscriptions to find an existing subscription.
  • Only calling addEventTypes when both existingSubscription and hasEventsFlag are true, and otherwise returning an explicit “Already subscribed” message,

gives predictable semantics and avoids silently widening a narrowly-configured subscription. The updated UX copy is also clear about how to modify existing subscriptions.

@shuhuiluo shuhuiluo force-pushed the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch from c6f1ad3 to 9e25d62 Compare November 22, 2025 21:31
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/services/subscription-service.ts (1)

405-410: Consider extracting the event type parsing logic.

The logic to parse and normalize existing event types (split by comma, trim, lowercase, filter empty) appears identically in both addEventTypes and removeEventTypes. Extracting this into a private helper method would reduce duplication and ensure consistency if the normalization logic needs to change.

For example:

private parseEventTypesString(eventTypes: string): string[] {
  return eventTypes
    ? eventTypes
        .split(",")
        .map(t => t.trim().toLowerCase())
        .filter(t => t.length > 0)
    : [];
}

Then use it in both methods:

-  const existingTypes = subscription.eventTypes
-    ? subscription.eventTypes
-        .split(",")
-        .map(t => t.trim().toLowerCase())
-        .filter(t => t.length > 0)
-    : [];
+  const existingTypes = this.parseEventTypesString(subscription.eventTypes);

Also applies to: 485-490

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c6f1ad3 and 9e25d62.

📒 Files selected for processing (3)
  • src/commands.ts (1 hunks)
  • src/handlers/github-subscription-handler.ts (5 hunks)
  • src/services/subscription-service.ts (2 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/handlers/github-subscription-handler.ts (1)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
src/services/subscription-service.ts (3)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • githubSubscriptions (72-109)
src/constants/event-types.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
🔇 Additional comments (6)
src/services/subscription-service.ts (3)

350-379: LGTM! Clean retrieval method.

The getSubscription method provides a straightforward query interface with proper scoping and returns the essential fields needed for event type management.


385-453: Solid implementation of addEventTypes with proper validation.

The method correctly validates new event types against ALLOWED_EVENT_TYPES, merges with existing types using Set deduplication, and updates the database. The error messages are clear and actionable.


460-532: removeEventTypes correctly handles auto-deletion.

The method properly removes specified event types and automatically deletes the subscription when no event types remain, which aligns with the PR objectives.

src/handlers/github-subscription-handler.ts (3)

83-133: Excellent handling of existing subscriptions—past review comments properly addressed.

The subscribe flow now correctly:

  • Only adds event types when --events is explicitly provided, preventing unwanted event type widening
  • Performs case-insensitive matching by fetching all channel subscriptions
  • Uses the canonical repo name from the database when calling addEventTypes
  • Provides clear guidance when a subscription already exists without the --events flag

This implementation aligns with the PR objectives and resolves all concerns from previous reviews.


263-347: Granular unsubscribe implementation is solid and accurate.

The granular unsubscribe flow properly:

  • Parses both --events=value and --events value formats
  • Validates event types against ALLOWED_EVENT_TYPES with clear error messages
  • Computes the actually removed types by intersecting with the current subscription (lines 310-316), ensuring the "Removed" message is accurate even when users request removal of types that aren't subscribed
  • Handles the edge case of removing all event types by automatically deleting the subscription

This addresses the past review comment about misleading "Removed" messages.


23-24: Usage messages clearly document the new granular capabilities.

The updated help text accurately reflects that:

  • Subscribe can add event types to existing subscriptions
  • Unsubscribe supports granular removal via --events

Also applies to: 220-220

@shuhuiluo shuhuiluo force-pushed the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch 3 times, most recently from 3706044 to 15f57f1 Compare November 23, 2025 06:53
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 (2)
src/services/subscription-service.ts (1)

427-622: Align event-type defaulting with existing getters for robustness against null/empty rows

The new getSubscription, addEventTypes, and removeEventTypes flows look correct (normalization, deduplication, and auto-delete when no types remain). One minor robustness gap is that parseEventTypesString returns an empty array for falsy/empty eventTypes, whereas getChannelSubscriptions/getRepoSubscribers treat falsy values as DEFAULT_EVENT_TYPES. If any legacy rows ever have NULL/'' in event_types, removeEventTypes would interpret that as “no events configured” and immediately delete the subscription when asked to remove anything, which is inconsistent with the display helpers.

You can make behavior consistent (and future-proof) by defaulting to DEFAULT_EVENT_TYPES inside parseEventTypesString when the stored value is null/empty:

-  private parseEventTypesString(eventTypes: string | null): string[] {
-    if (!eventTypes) return [];
-    return eventTypes
-      .split(",")
-      .map(t => t.trim().toLowerCase())
-      .filter(t => t.length > 0);
-  }
+  private parseEventTypesString(eventTypes: string | null): string[] {
+    const source =
+      eventTypes && eventTypes.trim().length > 0
+        ? eventTypes
+        : DEFAULT_EVENT_TYPES;
+
+    return source
+      .split(",")
+      .map(t => t.trim().toLowerCase())
+      .filter(t => t.length > 0);
+  }

This keeps semantics aligned everywhere and guards against any unexpected/null data without changing current behavior for well-formed rows.

src/handlers/github-subscription-handler.ts (1)

244-250: Granular unsubscribe flow is solid; consider clearer messaging when nothing changed

The --events parsing/validation and delegation to removeEventTypes are correct, and computing actuallyRemoved fixes the earlier mismatch between requested vs. effective removals. One small UX nit: when actuallyRemoved is empty (none of the requested types were enabled), the message still says “✅ Updated subscription” even though nothing actually changed.

You could keep the current behavior but slightly tweak the header when actuallyRemoved.length === 0 to make that explicit:

-    } else {
-      const removedLabel =
-        actuallyRemoved.length > 0 ? actuallyRemoved.join(", ") : "(none)";
-      await handler.sendMessage(
-        channelId,
-        `✅ **Updated subscription to ${repo}**\n\n` +
-          `Removed: **${removedLabel}**\n` +
-          `Remaining: **${formatEventTypes(removeResult.eventTypes!)}**`
-      );
-    }
+    } else {
+      const removedLabel =
+        actuallyRemoved.length > 0 ? actuallyRemoved.join(", ") : "(none)";
+      const header =
+        actuallyRemoved.length > 0
+          ? `✅ **Updated subscription to ${repo}**\n\n`
+          : `ℹ️ **Subscription to ${repo} unchanged**\n\n`;
+
+      await handler.sendMessage(
+        channelId,
+        header +
+          `Removed: **${removedLabel}**\n` +
+          `Remaining: **${formatEventTypes(removeResult.eventTypes!)}**`
+      );
+    }

This keeps the logic as-is while making the “no-op” case clearer to users.

Also applies to: 292-377

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3706044 and 15f57f1.

📒 Files selected for processing (3)
  • src/commands.ts (1 hunks)
  • src/handlers/github-subscription-handler.ts (5 hunks)
  • src/services/subscription-service.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/services/subscription-service.ts (3)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • githubSubscriptions (72-109)
src/constants.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
src/handlers/github-subscription-handler.ts (1)
src/constants.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
🔇 Additional comments (3)
src/services/subscription-service.ts (1)

10-14: ALLOWED_EVENT_TYPES import keeps validation co-located with subscription persistence

Importing ALLOWED_EVENT_TYPES here and validating in the service layer (in addition to the handler) is a good separation-of-concerns choice and ensures DB state can’t drift to unsupported event types even if new callers bypass the handler.

src/handlers/github-subscription-handler.ts (2)

25-27: Updated usage string clearly advertises granular event management

The revised usage text for /github now clearly explains both adding event types on subscribe and removing specific ones on unsubscribe, and the event-type list matches the allowed set plus all for the subscribe path.


92-143: Existing-subscription subscribe path behavior now matches intended semantics

The combination of hasEventsFlag and a case-insensitive lookup via getChannelSubscriptions cleanly differentiates between:

  • “append events” when --events is present, and
  • “already subscribed” when it’s not,

while reusing the stored repo casing for subsequent operations. This avoids silent widening of event sets and keeps UX predictable.

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 (2)
src/services/subscription-service.ts (2)

462-525: Consider early return for empty event types.

The method correctly validates and merges event types. However, if newEventTypes contains only whitespace or is empty, normalizedNew (line 485-487) will be empty, and the code will still proceed to update the database with an unchanged eventTypes field and a new updatedAt timestamp.

Adding an early check would avoid unnecessary DB writes:

 const normalizedNew = newEventTypes
   .map(t => t.trim().toLowerCase())
   .filter(t => t.length > 0);

+if (normalizedNew.length === 0) {
+  return {
+    success: true,
+    eventTypes: subscription.eventTypes,
+  };
+}
+
 const allowedSet = new Set(ALLOWED_EVENT_TYPES);

532-599: Consider early return for empty removal list.

The method correctly removes event types and deletes the subscription when no types remain. However, if typesToRemove contains only whitespace or is empty, normalizedRemove (lines 560-562) will be empty, and remainingTypes will equal existingTypes, resulting in an unnecessary DB update with an unchanged eventTypes field.

Adding an early check would avoid the unnecessary write:

 const normalizedRemove = typesToRemove
   .map(t => t.trim().toLowerCase())
   .filter(t => t.length > 0);

+if (normalizedRemove.length === 0) {
+  return {
+    success: true,
+    deleted: false,
+    eventTypes: subscription.eventTypes,
+  };
+}
+
 // Remove specified types
 const remainingTypes = existingTypes.filter(
   t => !normalizedRemove.includes(t)
 );
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 15f57f1 and 413d314.

📒 Files selected for processing (2)
  • src/handlers/github-subscription-handler.ts (5 hunks)
  • src/services/subscription-service.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
src/services/subscription-service.ts (3)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • githubSubscriptions (72-109)
src/constants.ts (2)
  • ALLOWED_EVENT_TYPES (10-22)
  • DEFAULT_EVENT_TYPES (5-5)
src/handlers/github-subscription-handler.ts (1)
src/constants.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
🔇 Additional comments (8)
src/services/subscription-service.ts (3)

10-10: LGTM!

The import of ALLOWED_EVENT_TYPES is necessary for validating event types in the new addEventTypes method.


427-456: LGTM!

The getSubscription method correctly queries for a specific subscription using the unique combination of spaceId, channelId, and repoFullName, and efficiently returns the first match or null.


611-627: LGTM!

The parseEventTypesString helper correctly normalizes event type strings with proper defaulting to DEFAULT_EVENT_TYPES and consistent lowercase normalization.

src/handlers/github-subscription-handler.ts (5)

25-26: LGTM!

The updated usage text clearly documents the new granular event-type management capabilities, including both the ability to add event types to existing subscriptions and remove specific event types.


92-92: LGTM!

The hasEventsFlag detection correctly identifies when the user has explicitly provided an --events flag, enabling the logic to distinguish between adding event types to an existing subscription versus preventing unintended modifications.


105-142: LGTM! Previous review concerns properly addressed.

The existing subscription handling correctly:

  • Uses case-insensitive matching via getChannelSubscriptions to find subscriptions regardless of user input casing
  • Only adds event types when --events flag is explicitly provided, preventing unexpected widening of the event set
  • Uses the canonical repository name from the database for the addEventTypes call
  • Provides clear guidance when users attempt to subscribe to an already-subscribed repository without the --events flag

These changes properly address the previous review comments about case-sensitivity and unexpected behavior.


244-249: LGTM!

The updated unsubscribe usage text and argument extraction correctly support the new granular event-type removal feature.


292-381: LGTM! Previous review concern about misleading "Removed" message properly addressed.

The granular unsubscribe logic correctly:

  • Parses the --events flag in both --events=value and --events value formats
  • Validates event types against ALLOWED_EVENT_TYPES with clear error messages
  • Computes actuallyRemoved by intersecting requested types with the subscription's current event types, ensuring the "Removed" list accurately reflects what was actually removed
  • Provides context-appropriate messages: "Updated subscription" when types were removed, "unchanged" when no matching types were found, or "Unsubscribed" when all types were removed
  • Falls back to full unsubscribe when no --events flag is present

This implementation properly addresses the previous review comment about displaying accurate removal information.

@shuhuiluo shuhuiluo force-pushed the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch from d6a3e41 to b998f75 Compare November 24, 2025 05:26
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 (2)
src/services/subscription-service.ts (2)

772-842: Consider verifying that the database update succeeded.

The method checks that the subscription exists (line 778-782), then updates it (line 824-836). However, it doesn't verify that the update affected any rows. If the subscription is deleted between the check and the update (unlikely but possible), the method will return success even though nothing changed.

Consider using .returning() to verify the update succeeded:

-    await db
+    const result = await db
       .update(githubSubscriptions)
       .set({
         eventTypes: updatedEventTypes,
         updatedAt: new Date(),
       })
       .where(
         and(
           eq(githubSubscriptions.spaceId, spaceId),
           eq(githubSubscriptions.channelId, channelId),
           eq(githubSubscriptions.repoFullName, repoFullName)
         )
-      );
+      )
+      .returning({ id: githubSubscriptions.id });
+
+    if (result.length === 0) {
+      return {
+        success: false,
+        error: `Subscription no longer exists for ${repoFullName}`,
+      };
+    }

849-924: Consider verifying database operations succeeded.

Similar to addEventTypes, this method has two potential robustness improvements:

  1. The update operation (lines 905-917) doesn't verify rows were affected
  2. The unsubscribe call (line 896) returns a boolean but the result isn't checked

Consider:

     if (remainingTypes.length === 0) {
-      await this.unsubscribe(channelId, spaceId, repoFullName);
+      const deleted = await this.unsubscribe(channelId, spaceId, repoFullName);
+      if (!deleted) {
+        return {
+          success: false,
+          error: `Failed to delete subscription for ${repoFullName}`,
+        };
+      }
       return {
         success: true,
         deleted: true,
       };
     }
 
     // Update subscription with remaining types
     const updatedEventTypes = remainingTypes.join(",");
-    await db
+    const result = await db
       .update(githubSubscriptions)
       .set({
         eventTypes: updatedEventTypes,
         updatedAt: new Date(),
       })
       .where(
         and(
           eq(githubSubscriptions.spaceId, spaceId),
           eq(githubSubscriptions.channelId, channelId),
           eq(githubSubscriptions.repoFullName, repoFullName)
         )
-      );
+      )
+      .returning({ id: githubSubscriptions.id });
+
+    if (result.length === 0) {
+      return {
+        success: false,
+        error: `Subscription no longer exists for ${repoFullName}`,
+      };
+    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d6a3e41 and b998f75.

📒 Files selected for processing (3)
  • src/commands.ts (1 hunks)
  • src/handlers/github-subscription-handler.ts (5 hunks)
  • src/services/subscription-service.ts (3 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/commands.ts
🧰 Additional context used
🧬 Code graph analysis (2)
src/services/subscription-service.ts (3)
src/db/index.ts (1)
  • db (57-57)
src/db/schema.ts (1)
  • githubSubscriptions (71-108)
src/constants.ts (2)
  • ALLOWED_EVENT_TYPES (10-22)
  • DEFAULT_EVENT_TYPES (5-5)
src/handlers/github-subscription-handler.ts (1)
src/constants.ts (1)
  • ALLOWED_EVENT_TYPES (10-22)
🔇 Additional comments (6)
src/services/subscription-service.ts (2)

737-766: LGTM!

The getSubscription method is straightforward and correctly queries the subscription using the compound key (spaceId, channelId, repoFullName). The use of limit(1) is appropriate.


938-954: LGTM!

The parseEventTypesString helper correctly normalizes event type strings to lowercase tokens and provides a sensible default. The split-trim-lowercase-filter pattern is consistent with the handler's event type parsing.

src/handlers/github-subscription-handler.ts (4)

25-27: LGTM!

The updated usage messages accurately reflect the new granular event management capabilities for both subscribe and unsubscribe commands.


92-142: Excellent implementation of granular subscribe!

The enhanced subscribe flow correctly addresses the previous review feedback:

  • Case-insensitive matching prevents duplicate subscriptions with different casing
  • The hasEventsFlag gate prevents unexpected widening of event types when --events is not specified
  • Clear messaging guides users when a subscription already exists

The logic is sound and the user experience is intuitive.


244-249: LGTM!

The updated unsubscribe signature and usage message correctly reflect the new optional --events parameter for granular event removal.


292-381: Excellent granular unsubscribe implementation!

This implementation correctly addresses the previous review feedback by computing the actually-removed types (lines 340-345) and using that for accurate messaging:

  • Shows "Removed: (none)" when requested types weren't enabled
  • Displays appropriate header: "✅ Updated" vs "ℹ️ unchanged" based on actual changes
  • Handles full deletion when all types are removed
  • Falls back to complete unsubscribe when --events is omitted

The validation against ALLOWED_EVENT_TYPES and support for both --events=value and --events value formats are well-implemented.

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/services/subscription-service.ts (1)

772-850: Consider using transactions for atomic read-modify-update operations.

Both addEventTypes and removeEventTypes follow a read-then-update pattern that could lead to lost updates if concurrent modifications occur. While unlikely in typical chat bot usage, wrapping the read and update in a transaction would guarantee consistency.

Example pattern:

await db.transaction(async tx => {
  const subscription = await tx
    .select({...})
    .from(githubSubscriptions)
    .where(...)
    .limit(1)
    .for('update'); // Lock the row

  // Perform modifications...
  
  await tx
    .update(githubSubscriptions)
    .set({...})
    .where(...);
});
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b998f75 and 4decdab.

📒 Files selected for processing (1)
  • src/services/subscription-service.ts (3 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
src/services/subscription-service.ts (1)
src/constants.ts (2)
  • ALLOWED_EVENT_TYPES (10-22)
  • DEFAULT_EVENT_TYPES (5-5)
🔇 Additional comments (3)
src/services/subscription-service.ts (3)

857-946: Implementation correctly handles automatic deletion when all event types are removed.

The logic properly implements the PR objective: when no event types remain after removal (line 903), it calls unsubscribe to delete the subscription entirely. The discriminated union return type with the deleted flag clearly communicates this behavior to callers.


960-976: Well-designed helper promotes consistency and code reuse.

The parseEventTypesString helper properly normalizes event types to lowercase, handles null/empty inputs by defaulting to DEFAULT_EVENT_TYPES, and filters out empty strings. This ensures consistent event type handling across addEventTypes and removeEventTypes.


737-766: ****

The original concern about eventTypes nullability is unfounded. The database schema declares eventTypes with .notNull().default(DEFAULT_EVENT_TYPES), confirming the column cannot be null. The return type declaration of eventTypes: string correctly matches the schema definition. The parseEventTypesString helper accepting string | null is defensive programming and does not indicate a mismatch with the actual return type.

Likely an incorrect or invalid review comment.

@shuhuiluo shuhuiluo force-pushed the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch 2 times, most recently from ff4571b to b07c314 Compare November 26, 2025 23:51
Add ability to add/remove specific event types from subscriptions
instead of only full subscribe/unsubscribe operations.

New commands:
- `/github subscribe owner/repo --events pr,issues` - subscribe to specific types
- `/github subscribe owner/repo --events releases` - add types to existing sub
- `/github unsubscribe owner/repo --events pr` - remove specific types

Key changes:
- Add addEventTypes/removeEventTypes methods to SubscriptionService
- Validate repo access via OAuth token for all subscription modifications
- Split handlers into focused functions (handleNewSubscription,
  handleUpdateSubscription, handleRemoveEventTypes, handleFullUnsubscribe)
- Extract handleInvalidOAuthToken helper to deduplicate OAuth validation
- Add unsubscribe-update redirect action for OAuth callback flow
- Show remaining event types after partial unsubscribe

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

Co-Authored-By: Claude <noreply@anthropic.com>
@shuhuiluo shuhuiluo force-pushed the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch from b07c314 to 5741793 Compare November 28, 2025 07:34
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

Caution

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

⚠️ Outside diff range comments (1)
tests/unit/handlers/github-subscription-handler.test.ts (1)

110-114: Inconsistent eventTypes mock data type.

This mock uses eventTypes: DEFAULT_EVENT_TYPES (a string), while Line 132 and other tests use DEFAULT_EVENT_TYPES.split(",") as EventType[] (an array). The handler/service likely expects an array, which could cause test failures or mask real bugs.

       mockSubscriptionService.getChannelSubscriptions = mock(() =>
         Promise.resolve([
-          { repo: "owner/repo", eventTypes: DEFAULT_EVENT_TYPES },
+          { repo: "owner/repo", eventTypes: DEFAULT_EVENT_TYPES.split(",") as EventType[] },
         ])
       );
🧹 Nitpick comments (7)
src/utils/oauth-helpers.ts (1)

115-173: Clean implementation of centralized OAuth error handling.

The switch correctly handles all current non-Valid token statuses with appropriate user messaging. The Unknown case appropriately uses a non-editable message since it represents a transient state.

Consider adding a default exhaustiveness check for future-proofing if TokenStatus ever gains new values:

     case TokenStatus.Unknown: {
       // ... existing code ...
       return;
     }
+
+    default: {
+      const _exhaustive: never = tokenStatus;
+      console.error("Unhandled token status:", _exhaustive);
+      return;
+    }
   }
 }
src/routes/oauth-callback.ts (3)

109-137: Verify subscribe-update handler covers error propagation from addEventTypes.

The handler correctly processes the update flow. However, addEventTypes may throw an Error if the OAuth token is missing (line 824-827 in subscription-service.ts). This thrown error would propagate to the top-level catch block (line 199), which returns a generic "Authorization failed" message - potentially confusing for the user since OAuth was already validated at the start of the callback.

Consider wrapping the addEventTypes call in a try-catch to provide a more specific error message:

-        const updateResult = await subscriptionService.addEventTypes(
-          townsUserId,
-          spaceId,
-          channelId,
-          redirectData.repo,
-          eventTypes
-        );
+        let updateResult;
+        try {
+          updateResult = await subscriptionService.addEventTypes(
+            townsUserId,
+            spaceId,
+            channelId,
+            redirectData.repo,
+            eventTypes
+          );
+        } catch (error) {
+          console.error("Failed to add event types:", error);
+          await bot.sendMessage(channelId, `❌ Failed to update subscription. Please try again.`);
+          return renderSuccess(c);
+        }

127-128: Non-null assertion on eventTypes is safe but fragile.

The updateResult.eventTypes! assertion is safe here because the addEventTypes method always returns eventTypes when success: true. However, this creates a coupling with the implementation details. Consider adding a defensive check or updating the return type to make eventTypes non-optional on success.


139-174: Apply same error handling consideration to unsubscribe-update handler.

Similar to subscribe-update, the removeEventTypes call can throw if the OAuth token is unexpectedly missing. Consider wrapping in try-catch for consistent, user-friendly error handling.

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

115-116: Consider adding defensive validation for repo format.

The comment states "caller validates format" but if an invalid format is passed (e.g., missing /), the destructuring on line 116 will silently assign undefined to repo, which could lead to confusing downstream errors.

-    // Parse owner/repo (caller validates format)
-    const [owner, repo] = repoIdentifier.split("/");
+    // Parse owner/repo (caller validates format)
+    const [owner, repo] = repoIdentifier.split("/");
+    if (!owner || !repo) {
+      return {
+        success: false,
+        requiresInstallation: false,
+        error: "Invalid repository format. Use owner/repo format.",
+      };
+    }

814-893: Extract shared validation logic to reduce duplication.

addEventTypes and removeEventTypes have nearly identical OAuth token retrieval and repository validation logic (lines 821-838 and 913-930). Consider extracting to a private helper:

private async validateUserRepoAccess(
  townsUserId: string,
  repoFullName: string
): Promise<{ success: true } | { success: false; error: string }> {
  const githubToken = await this.oauthService.getToken(townsUserId);
  if (!githubToken) {
    throw new Error("OAuth token not found...");
  }
  const [owner, repo] = repoFullName.split("/");
  try {
    await validateRepository(githubToken, owner, repo);
    return { success: true };
  } catch {
    return { success: false, error: "You don't have access to this repository" };
  }
}

Also applies to: 901-1002


862-864: Based on my investigation, I've verified the concerns raised in the original review comment. Here are my findings:

Key Findings:

  1. Data Flow Analysis: The subscription.eventTypes comes from parseEventTypes() (line 1006-1009), which performs an unchecked type assertion: (eventTypes.split(",") as EventType[]) with no validation.

  2. Handler Validation: The newEventTypes parameter is validated by callers in the handler (using ALLOWED_EVENT_TYPES_SET.has()), so it should be safe.

  3. Type Assertion Gap: The cast as EventType[] at line 862-864 indeed masks a validation gap. If the database ever contains corrupted or invalid event types, they would silently pass through.

  4. Practical Risk: In normal operation, this risk is low because:

    • Handlers validate before inserting into the database
    • No other code paths write directly to the eventTypes field
    • Database should only contain valid types
  5. Missing Validation: The ALLOWED_EVENT_TYPES_SET exists but is only used in the handler's parseEventTypes, not in the service's parseEventTypes.


Type assertion may mask invalid event types from DB.

The cast as EventType[] assumes merged values are valid EventTypes. While newEventTypes is validated by callers, subscription.eventTypes comes from parseEventTypes() at line 1006, which performs an unchecked cast without validating against ALLOWED_EVENT_TYPES_SET. If the database is ever corrupted or tampered with, invalid event types would silently bypass the type system. The Set deduplication correctly handles duplicates, but runtime validation is missing from the retrieval path.

Consider adding validation in parseEventTypes(): use ALLOWED_EVENT_TYPES_SET to filter invalid values or throw an error on corruption detection.

function parseEventTypes(eventTypes: string | null): EventType[] {
  const parsed = eventTypes?.split(",") ?? [];
  return parsed.filter(e => ALLOWED_EVENT_TYPES_SET.has(e as EventType));
}
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between b07c314 and 5741793.

📒 Files selected for processing (7)
  • src/formatters/subscription-messages.ts (1 hunks)
  • src/handlers/github-subscription-handler.ts (10 hunks)
  • src/routes/oauth-callback.ts (3 hunks)
  • src/services/subscription-service.ts (9 hunks)
  • src/types/oauth.ts (1 hunks)
  • src/utils/oauth-helpers.ts (2 hunks)
  • tests/unit/handlers/github-subscription-handler.test.ts (11 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/handlers/github-subscription-handler.ts
  • tests/unit/handlers/github-subscription-handler.test.ts
  • src/utils/oauth-helpers.ts
  • src/types/oauth.ts
  • src/formatters/subscription-messages.ts
  • src/services/subscription-service.ts
  • src/routes/oauth-callback.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/handlers/github-subscription-handler.ts
  • tests/unit/handlers/github-subscription-handler.test.ts
  • src/utils/oauth-helpers.ts
  • src/types/oauth.ts
  • src/formatters/subscription-messages.ts
  • src/services/subscription-service.ts
  • src/routes/oauth-callback.ts
🧠 Learnings (2)
📚 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 : Implement event handlers for onMessage, onSlashCommand, onReaction, onTip, and onInteractionResponse to respond to Towns Protocol events

Applied to files:

  • src/handlers/github-subscription-handler.ts
  • src/utils/oauth-helpers.ts
  • src/routes/oauth-callback.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/utils/oauth-helpers.ts
  • src/services/subscription-service.ts
🧬 Code graph analysis (5)
src/handlers/github-subscription-handler.ts (4)
src/constants.ts (2)
  • ALLOWED_EVENT_TYPES (17-29)
  • EventType (34-34)
src/types/bot.ts (1)
  • SlashCommandEvent (13-15)
src/utils/oauth-helpers.ts (1)
  • handleInvalidOAuthToken (115-173)
src/utils/stripper.ts (1)
  • stripMarkdown (6-13)
src/utils/oauth-helpers.ts (3)
src/services/github-oauth-service.ts (1)
  • GitHubOAuthService (65-540)
src/types/oauth.ts (1)
  • RedirectAction (12-12)
src/constants.ts (1)
  • EventType (34-34)
src/formatters/subscription-messages.ts (1)
src/constants.ts (1)
  • EventType (34-34)
src/services/subscription-service.ts (4)
src/constants.ts (2)
  • EventType (34-34)
  • DEFAULT_EVENT_TYPES_ARRAY (2-7)
src/formatters/subscription-messages.ts (2)
  • formatDeliveryInfo (6-13)
  • formatSubscriptionSuccess (18-27)
src/db/schema.ts (1)
  • githubSubscriptions (71-108)
src/api/user-oauth-client.ts (1)
  • validateRepository (32-76)
src/routes/oauth-callback.ts (2)
src/constants.ts (2)
  • EventType (34-34)
  • DEFAULT_EVENT_TYPES_ARRAY (2-7)
src/views/oauth-pages.ts (1)
  • renderSuccess (32-59)
🔇 Additional comments (18)
src/types/oauth.ts (1)

6-11: LGTM!

The extended RedirectActionSchema enum cleanly supports the new granular subscription update flows. The naming convention is consistent with existing actions.

src/utils/oauth-helpers.ts (1)

3-7: LGTM!

The new imports are correctly scoped to support the centralized OAuth error handling function.

src/formatters/subscription-messages.ts (1)

18-27: LGTM!

The success message formatter cleanly composes the subscription confirmation with proper GitHub link formatting.

tests/unit/handlers/github-subscription-handler.test.ts (3)

48-53: LGTM!

The mock service correctly includes the new removeEventTypes, registerPendingMessage, and sendSubscriptionSuccess methods aligned with the updated service API.


345-371: Well-structured polling mode test.

The test properly verifies that sendSubscriptionSuccess is called with the polling result and correct arguments. The use of as const for literal types improves type safety.


479-506: LGTM!

The unsubscribe test correctly verifies the removeEventTypes call with the expected arguments including userId, spaceId, channelId, repo, and eventTypes array.

src/handlers/github-subscription-handler.ts (7)

16-17: LGTM!

The import of the centralized handleInvalidOAuthToken helper consolidates OAuth error handling across flows.


76-150: Clean refactoring of subscribe flow.

The separation of concerns with hasEventsFlag gating the update path addresses the prior review concern about unexpectedly widening event sets. The case-insensitive matching via toLowerCase() ensures consistent behavior across subscribe/unsubscribe paths.


155-213: LGTM!

The handleNewSubscription function cleanly handles the OAuth validation, subscription creation, and error cases. Delegating success messaging to sendSubscriptionSuccess centralizes the formatting logic.


218-266: LGTM!

The handleUpdateSubscription function correctly uses the subscribe-update redirect action for OAuth flows and properly validates access before modifying the subscription.


352-454: Well-implemented granular event removal.

The actuallyRemoved computation (Lines 416-418) correctly addresses the prior review concern about misleading "Removed" messages. The differentiated header (✅ Updated vs ℹ️ unchanged) provides clear feedback to users.


459-501: Clarify redirect action for full unsubscribe.

The function uses "unsubscribe-update" as the redirect action (Line 480), but this is a full unsubscribe operation, not a partial update. While functionally this works (the OAuth callback likely handles both cases identically), the action name could be semantically misleading.

If the OAuth callback treats unsubscribe-update the same regardless of whether it's partial or full removal, this is fine. Otherwise, consider whether a dedicated "unsubscribe" action should be added to RedirectActionSchema for clarity.


539-549: LGTM!

The parseRepoArg helper cleanly extracts markdown stripping and format validation into a reusable function.

src/routes/oauth-callback.ts (2)

65-67: LGTM - Explicit event types handling with defaults.

The spread operator correctly creates a mutable copy of DEFAULT_EVENT_TYPES_ARRAY (which is readonly), preventing accidental mutations.


78-84: LGTM - Refactored to use centralized message formatter.

Delegating message formatting to sendSubscriptionSuccess improves consistency and reduces duplication.

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

692-715: LGTM - Clean extraction of subscription success messaging.

The method properly handles both webhook and polling modes, uses the new formatters, and registers pending messages for polling mode upgrades.


771-807: LGTM - Clean implementation of getSubscription.

The method correctly normalizes event types via parseEventTypes and uses proper query constraints.


1005-1009: I'll help you verify this review comment about the unsafe type cast in parseEventTypes. Let me investigate the codebase to understand the context and validate the concerns.
<function_calls>
<invoke_name>shell
<invoke_description>Search for ALLOWED_EVENT_TYPES and EventType definitions, and examine the parseEventTypes function</invoke_description>
<invoke_content>#!/bin/bash

Find ALLOWED_EVENT_TYPES definition

echo "=== Searching for ALLOWED_EVENT_TYPES ==="
rg -n 'ALLOWED_EVENT_TYPES' --type=ts

echo -e "\n=== Searching for EventType definition ==="
rg -n 'type EventType|enum EventType' --type=ts

echo -e "\n=== Searching for DEFAULT_EVENT_TYPES_ARRAY ==="
rg -n 'DEFAULT_EVENT_TYPES_ARRAY' --type=ts
</invoke_content>
</invoke_function_calls>

- Add exhaustiveness check to handleInvalidOAuthToken for future-proofing
- parseEventTypes now filters invalid DB values via ALLOWED_EVENT_TYPES_SET
- Extract validateRepoAccessAndGetSubscription helper to deduplicate
  OAuth token retrieval and repo access validation in addEventTypes
  and removeEventTypes methods

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

Co-Authored-By: Claude <noreply@anthropic.com>
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 (4)
src/utils/oauth-helpers.ts (1)

115-178: Consider error handling and pattern consistency in token status handling.

The function properly uses type narrowing with Exclude<TokenStatus, TokenStatus.Valid> and an exhaustive check. However, there are two areas worth reviewing:

  1. Inconsistent messaging pattern for TokenStatus.Unknown: The NotLinked and Invalid cases use sendEditableOAuthPrompt (two-phase pattern with message editing), but Unknown directly calls sendMessage. This means the Unknown case doesn't benefit from the "Checking..." → final message flow and doesn't track the eventId.

  2. Missing error handling: None of the async calls to sendEditableOAuthPrompt or sendMessage are wrapped in try-catch. If these fail, the function will throw and propagate the error to the caller.

Option 1: Use consistent pattern for Unknown case

     case TokenStatus.Unknown: {
-      const authUrl = await oauthService.getAuthorizationUrl(
+      await sendEditableOAuthPrompt(
+        oauthService,
+        handler,
         userId,
         channelId,
         spaceId,
+        `⚠️ **Unable to Verify GitHub Connection**\n\n` +
+          `We couldn't verify your GitHub token. This could be temporary (rate limiting) or indicate a connection issue.\n\n` +
+          `Please try again in a few moments, or [reconnect your account]({authUrl}) if the problem persists.`,
         redirectAction,
         redirectData
       );
-      await handler.sendMessage(
-        channelId,
-        `⚠️ **Unable to Verify GitHub Connection**\n\n` +
-          `We couldn't verify your GitHub token. This could be temporary (rate limiting) or indicate a connection issue.\n\n` +
-          `Please try again in a few moments, or [reconnect your account](${authUrl}) if the problem persists.`
-      );
       return;
     }

Option 2: Add error handling

   switch (tokenStatus) {
     case TokenStatus.NotLinked:
+      try {
         await sendEditableOAuthPrompt(
           oauthService,
           handler,
           userId,
           channelId,
           spaceId,
           `🔐 **GitHub Account Required**\n\n` +
             `To modify subscriptions, you need to connect your GitHub account.\n\n` +
             `[Connect GitHub Account]({authUrl})`,
           redirectAction,
           redirectData
         );
+      } catch (error) {
+        console.error("Failed to send OAuth prompt for NotLinked status:", error);
+        throw error;
+      }
       return;
src/services/subscription-service.ts (3)

275-327: Review the early return behavior when newEventTypes is empty.

The method correctly validates access, merges, and deduplicates event types. However, the early return at lines 291-293 when newEventTypes is empty means the subscription's updatedAt timestamp won't be updated, even though the method was called.

This behavior is probably fine, but consider: if a user explicitly tries to add event types that are already present, should the updatedAt field be updated to reflect the operation attempt? The current implementation would return success without updating the timestamp.

If you want to track all modification attempts, consider removing the early return and allowing the update to proceed even with no net changes:

-    if (newEventTypes.length === 0) {
-      return { success: true, eventTypes: currentTypes };
-    }
-
     // Merge and deduplicate
     const mergedTypes = [
       ...new Set([...currentTypes, ...newEventTypes]),
     ] as EventType[];
+
+    // No new types to add
+    if (mergedTypes.length === currentTypes.length) {
+      return { success: true, eventTypes: currentTypes };
+    }

This approach deduplicates first, then checks if there's actually anything new to add before updating the database.


809-829: Add error handling for message sending failure.

The method correctly formats and sends subscription success messages. However, if sender.sendMessage() throws an error, the pending message registration won't occur, which could lead to inconsistent state.

Consider wrapping the message sending and registration in a try-catch:

   async sendSubscriptionSuccess(
     result: Extract<SubscribeResult, { success: true }>,
     eventTypes: EventType[],
     channelId: string,
     sender: Pick<BotHandler, "sendMessage">
   ): Promise<void> {
     const installUrl =
       result.deliveryMode === "polling" ? result.installUrl : undefined;
     const deliveryInfo = formatDeliveryInfo(result.deliveryMode, installUrl);
     const message = formatSubscriptionSuccess(
       result.repoFullName,
       eventTypes,
       deliveryInfo
     );
 
-    const { eventId } = await sender.sendMessage(channelId, message);
-
-    if (result.deliveryMode === "polling" && eventId) {
-      this.registerPendingMessage(channelId, result.repoFullName, eventId);
+    try {
+      const { eventId } = await sender.sendMessage(channelId, message);
+
+      if (result.deliveryMode === "polling" && eventId) {
+        this.registerPendingMessage(channelId, result.repoFullName, eventId);
+      }
+    } catch (error) {
+      console.error(
+        `Failed to send subscription success message for ${result.repoFullName}:`,
+        error
+      );
+      throw error;
     }
   }

953-989: Consider more specific error messaging for repository validation failures.

The helper correctly validates OAuth token, repository access, and subscription existence. However, the error handling at lines 972-977 catches all exceptions from validateRepository() and returns a generic "You don't have access to this repository" message.

While this is simple from a UX perspective, it could be misleading when the actual issue is a network error, rate limiting, or another transient problem rather than an access/permissions issue.

Consider logging the actual error for debugging while keeping the user message generic:

     const [owner, repo] = repoFullName.split("/");
     try {
       await validateRepository(githubToken, owner, repo);
-    } catch {
+    } catch (error) {
+      console.error(
+        `Repository validation failed for ${repoFullName}:`,
+        error instanceof Error ? error.message : String(error)
+      );
       return {
         success: false,
         error: "You don't have access to this repository",
       };
     }

Alternatively, you could differentiate between error types (403/404 vs others) to provide more helpful messages, similar to how createSubscription handles validation errors at lines 177-183.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5741793 and 12152d1.

📒 Files selected for processing (2)
  • src/services/subscription-service.ts (11 hunks)
  • src/utils/oauth-helpers.ts (2 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/utils/oauth-helpers.ts
  • src/services/subscription-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/utils/oauth-helpers.ts
  • src/services/subscription-service.ts
🧠 Learnings (2)
📚 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/utils/oauth-helpers.ts
  • src/services/subscription-service.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 : Implement event handlers for onMessage, onSlashCommand, onReaction, onTip, and onInteractionResponse to respond to Towns Protocol events

Applied to files:

  • src/utils/oauth-helpers.ts
🧬 Code graph analysis (1)
src/utils/oauth-helpers.ts (2)
src/types/oauth.ts (1)
  • RedirectAction (12-12)
src/constants.ts (1)
  • EventType (34-34)
🔇 Additional comments (7)
src/utils/oauth-helpers.ts (1)

3-7: LGTM - Necessary imports for OAuth token handling.

The new imports support the token status handling logic in the new handleInvalidOAuthToken function.

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

2-2: LGTM - Necessary imports for subscription management.

The new imports for BotHandler, constants, and message formatters support the new subscription management features.

Also applies to: 12-13, 22-25


335-406: LGTM - Correct implementation of granular event type removal.

The method properly handles the complete flow:

  • Validates repository access before modifications
  • Correctly removes specified event types from the current set
  • Automatically deletes the subscription when all event types are removed
  • Handles edge cases (subscription no longer exists, delete failures)

The implementation silently ignores event types in typesToRemove that aren't currently subscribed, which is reasonable behavior.


501-534: LGTM - Clean implementation of subscription retrieval.

The method correctly retrieves a specific subscription and parses the event types using the new parseEventTypes helper. The null return for non-existent subscriptions is appropriate.


888-912: LGTM - Clean extraction of installation failure logic.

The helper properly consolidates the logic for handling cases where GitHub App installation is required, including storing the pending subscription for later completion.


918-946: LGTM - Correct implementation of pending subscription storage.

The helper properly stores pending subscriptions with expiration times and uses onConflictDoNothing() for idempotent behavior.


992-998: LGTM - Robust event type parsing with validation.

The parseEventTypes utility correctly:

  • Returns default event types for null/empty input
  • Filters out invalid event types using the allowed set
  • Maintains type safety with the EventType[] return type

This centralized parsing ensures consistency across all subscription retrieval paths.

@shuhuiluo shuhuiluo merged commit a4a11cf into main Nov 28, 2025
2 checks passed
@shuhuiluo shuhuiluo deleted the claude/granular-unsubscribe-management-01HS4mvDjHAGGfVjT9VqvP3o branch November 28, 2025 08:22
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