-
Notifications
You must be signed in to change notification settings - Fork 4
feat: add tag filter to coding agent slack tags endpoint #338
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sweetmantech
wants to merge
1
commit into
test
Choose a base branch
from
feature/coding-agent-tag-filter
base: test
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { type NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { getSlackTagOptionsHandler } from "@/lib/admins/coding-agent/getSlackTagOptionsHandler"; | ||
|
|
||
| /** | ||
| * GET /api/admins/coding-agent/slack-tags | ||
| * | ||
| * Returns the distinct set of Slack users who have tagged the Recoup Coding Agent bot. | ||
| * Used by the admin UI to populate tag filter chips. | ||
| * Requires admin authentication. | ||
| * | ||
| * @param request | ||
| */ | ||
| export async function GET(request: NextRequest): Promise<NextResponse> { | ||
| return getSlackTagOptionsHandler(request); | ||
| } | ||
|
|
||
| /** CORS preflight handler. */ | ||
| export async function OPTIONS(): Promise<NextResponse> { | ||
| return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); | ||
| } |
126 changes: 126 additions & 0 deletions
126
lib/admins/coding-agent/__tests__/getSlackTagOptionsHandler.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { NextRequest } from "next/server"; | ||
| import { getSlackTagOptionsHandler } from "../getSlackTagOptionsHandler"; | ||
|
|
||
| import { validateGetSlackTagOptionsQuery } from "../validateGetSlackTagOptionsQuery"; | ||
| import { fetchSlackMentions } from "@/lib/admins/slack/fetchSlackMentions"; | ||
|
|
||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ | ||
| getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), | ||
| })); | ||
|
|
||
| vi.mock("../validateGetSlackTagOptionsQuery", () => ({ | ||
| validateGetSlackTagOptionsQuery: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock("@/lib/admins/slack/fetchSlackMentions", () => ({ | ||
| fetchSlackMentions: vi.fn(), | ||
| })); | ||
|
|
||
| const mockMentions = [ | ||
| { | ||
| user_id: "U001", | ||
| user_name: "Bob", | ||
| user_avatar: null, | ||
| prompt: "hello", | ||
| timestamp: "2026-01-01T00:00:00Z", | ||
| channel_id: "C001", | ||
| channel_name: "general", | ||
| pull_requests: [], | ||
| }, | ||
| { | ||
| user_id: "U002", | ||
| user_name: "Alice", | ||
| user_avatar: "https://example.com/alice.png", | ||
| prompt: "fix bug", | ||
| timestamp: "2026-01-02T00:00:00Z", | ||
| channel_id: "C001", | ||
| channel_name: "general", | ||
| pull_requests: [], | ||
| }, | ||
| { | ||
| user_id: "U001", | ||
| user_name: "Bob", | ||
| user_avatar: null, | ||
| prompt: "second mention", | ||
| timestamp: "2026-01-03T00:00:00Z", | ||
| channel_id: "C002", | ||
| channel_name: "random", | ||
| pull_requests: [], | ||
| }, | ||
| ]; | ||
|
|
||
| /** | ||
| * | ||
| */ | ||
| function makeRequest() { | ||
| return new NextRequest("https://api.example.com/api/admins/coding-agent/slack-tags"); | ||
| } | ||
|
|
||
| describe("getSlackTagOptionsHandler", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe("successful cases", () => { | ||
| it("returns unique users sorted alphabetically", async () => { | ||
| vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); | ||
| vi.mocked(fetchSlackMentions).mockResolvedValue(mockMentions); | ||
|
|
||
| const response = await getSlackTagOptionsHandler(makeRequest()); | ||
| const body = await response.json(); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(body.status).toBe("success"); | ||
| expect(body.total).toBe(2); | ||
| // Sorted alphabetically: Alice before Bob | ||
| expect(body.tags[0].name).toBe("Alice"); | ||
| expect(body.tags[1].name).toBe("Bob"); | ||
| }); | ||
|
|
||
| it("deduplicates users appearing in multiple mentions", async () => { | ||
| vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); | ||
| vi.mocked(fetchSlackMentions).mockResolvedValue(mockMentions); | ||
|
|
||
| const response = await getSlackTagOptionsHandler(makeRequest()); | ||
| const body = await response.json(); | ||
|
|
||
| expect(body.total).toBe(2); // U001 and U002 only | ||
| }); | ||
|
|
||
| it("returns empty tags when no mentions exist", async () => { | ||
| vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); | ||
| vi.mocked(fetchSlackMentions).mockResolvedValue([]); | ||
|
|
||
| const response = await getSlackTagOptionsHandler(makeRequest()); | ||
| const body = await response.json(); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(body.total).toBe(0); | ||
| expect(body.tags).toEqual([]); | ||
| }); | ||
| }); | ||
|
|
||
| describe("error cases", () => { | ||
| it("returns 401 when auth fails", async () => { | ||
| const { NextResponse } = await import("next/server"); | ||
| vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue( | ||
| NextResponse.json({ error: "Unauthorized" }, { status: 401 }), | ||
| ); | ||
|
|
||
| const response = await getSlackTagOptionsHandler(makeRequest()); | ||
| expect(response.status).toBe(401); | ||
| }); | ||
|
|
||
| it("returns 500 when fetchSlackMentions throws", async () => { | ||
| vi.mocked(validateGetSlackTagOptionsQuery).mockResolvedValue(true); | ||
| vi.mocked(fetchSlackMentions).mockRejectedValue(new Error("Slack API error")); | ||
|
|
||
| const response = await getSlackTagOptionsHandler(makeRequest()); | ||
| const body = await response.json(); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(body.status).toBe("error"); | ||
| }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { validateGetSlackTagOptionsQuery } from "./validateGetSlackTagOptionsQuery"; | ||
| import { fetchSlackMentions } from "@/lib/admins/slack/fetchSlackMentions"; | ||
|
|
||
| export interface SlackTagOption { | ||
| id: string; | ||
| name: string; | ||
| avatar: string | null; | ||
| } | ||
|
|
||
| /** | ||
| * Handler for GET /api/admins/coding-agent/slack-tags | ||
| * | ||
| * Returns the distinct set of Slack users who have ever tagged the Recoup Coding Agent bot. | ||
| * Each entry is a { id, name, avatar } suitable for use as a filter chip in the admin UI. | ||
| * | ||
| * Requires admin authentication. | ||
| * | ||
| * @param request | ||
| */ | ||
| export async function getSlackTagOptionsHandler(request: NextRequest): Promise<NextResponse> { | ||
| try { | ||
| const validated = await validateGetSlackTagOptionsQuery(request); | ||
| if (validated instanceof NextResponse) { | ||
| return validated; | ||
| } | ||
|
|
||
| const mentions = await fetchSlackMentions("all"); | ||
|
|
||
| const seen = new Set<string>(); | ||
| const tags: SlackTagOption[] = []; | ||
| for (const mention of mentions) { | ||
| if (!seen.has(mention.user_id)) { | ||
| seen.add(mention.user_id); | ||
| tags.push({ | ||
| id: mention.user_id, | ||
| name: mention.user_name, | ||
| avatar: mention.user_avatar, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // Sort alphabetically by name | ||
| tags.sort((a, b) => a.name.localeCompare(b.name)); | ||
|
|
||
| return NextResponse.json( | ||
| { | ||
| status: "success", | ||
| total: tags.length, | ||
| tags, | ||
| }, | ||
| { status: 200, headers: getCorsHeaders() }, | ||
| ); | ||
| } catch (error) { | ||
| console.error("[ERROR] getSlackTagOptionsHandler:", error); | ||
| return NextResponse.json( | ||
| { status: "error", message: "Internal server error" }, | ||
| { status: 500, headers: getCorsHeaders() }, | ||
| ); | ||
| } | ||
| } |
20 changes: 20 additions & 0 deletions
20
lib/admins/coding-agent/validateGetSlackTagOptionsQuery.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; | ||
| import type { NextRequest } from "next/server"; | ||
|
|
||
| /** | ||
| * Validates admin auth for GET /api/admins/coding-agent/slack-tags. | ||
| * No query params — always returns all-time unique tags. | ||
| * | ||
| * @param request - The incoming Next.js request | ||
| * @returns A NextResponse on error, or true on success | ||
| */ | ||
| export async function validateGetSlackTagOptionsQuery( | ||
| request: NextRequest, | ||
| ): Promise<NextResponse | true> { | ||
| const authResult = await validateAdminAuth(request); | ||
| if (authResult instanceof NextResponse) { | ||
| return authResult; | ||
| } | ||
| return true; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reject blank
tagvalues to avoid accidental unfiltered responsesIf a client sends
?tag=, Line 32 captures"", it passes validation, and filtering is skipped later because the value is falsy. That returns full results instead of signaling invalid filter input.💡 Proposed fix
const { searchParams } = new URL(request.url); + const rawTag = searchParams.get("tag"); + if (rawTag !== null && rawTag.trim() === "") { + return NextResponse.json( + { status: "error", error: "tag must be a non-empty string" }, + { status: 400, headers: getCorsHeaders() }, + ); + } const raw = { period: searchParams.get("period") ?? "all", - tag: searchParams.get("tag") ?? undefined, + tag: rawTag ?? undefined, };Also applies to: 30-33
🤖 Prompt for AI Agents