|
1 | 1 | import { z } from "zod" |
2 | | -import { eq } from "drizzle-orm" |
| 2 | +import { and, desc, eq, inArray, lt, or, sql } from "drizzle-orm" |
| 3 | +import { encodeCursor, decodeCursor } from "../cursor" |
3 | 4 | import { gunzipSync } from "node:zlib" |
4 | 5 | import { db } from "../../db" |
5 | 6 | import { reports, reportAttachments } from "../../db/schema" |
@@ -126,3 +127,122 @@ export const getTicketTool = { |
126 | 127 | } |
127 | 128 | }, |
128 | 129 | } |
| 130 | + |
| 131 | +export const listTicketsTool = { |
| 132 | + name: "repro_list_tickets", |
| 133 | + config: { |
| 134 | + description: |
| 135 | + "List Repro tickets (reports) in a project, newest first. Supports filtering by status, priority, tags, free-text search, and cursor pagination. Returns up to 50 items per page.", |
| 136 | + inputSchema: z.object({ |
| 137 | + projectId: z.string().uuid(), |
| 138 | + status: z |
| 139 | + .array(z.enum(["open", "in_progress", "resolved", "closed"])) |
| 140 | + .optional() |
| 141 | + .describe("Filter to these statuses (default: any)."), |
| 142 | + priority: z |
| 143 | + .array(z.enum(["low", "normal", "high", "urgent"])) |
| 144 | + .optional() |
| 145 | + .describe("Filter to these priorities (default: any)."), |
| 146 | + tag: z |
| 147 | + .array(z.string()) |
| 148 | + .optional() |
| 149 | + .describe("Filter to tickets containing ALL of these tags."), |
| 150 | + query: z |
| 151 | + .string() |
| 152 | + .optional() |
| 153 | + .describe("Case-insensitive substring match on title/description."), |
| 154 | + cursor: z.string().optional(), |
| 155 | + limit: z.number().int().min(1).max(50).optional(), |
| 156 | + }), |
| 157 | + }, |
| 158 | + handler: async ( |
| 159 | + input: { |
| 160 | + projectId: string |
| 161 | + status?: string[] |
| 162 | + priority?: string[] |
| 163 | + tag?: string[] |
| 164 | + query?: string |
| 165 | + cursor?: string |
| 166 | + limit?: number |
| 167 | + }, |
| 168 | + ctx: McpRequestContext, |
| 169 | + ) => { |
| 170 | + await requireProjectRoleByUser(ctx.userId, input.projectId, "viewer") |
| 171 | + const limit = input.limit ?? 25 |
| 172 | + const decodedCursor = input.cursor ? decodeCursor(input.cursor) : null |
| 173 | + |
| 174 | + const conditions = [eq(reports.projectId, input.projectId)] |
| 175 | + if (input.status?.length) { |
| 176 | + conditions.push( |
| 177 | + inArray(reports.status, input.status as ("open" | "in_progress" | "resolved" | "closed")[]), |
| 178 | + ) |
| 179 | + } |
| 180 | + if (input.priority?.length) { |
| 181 | + conditions.push( |
| 182 | + inArray(reports.priority, input.priority as ("low" | "normal" | "high" | "urgent")[]), |
| 183 | + ) |
| 184 | + } |
| 185 | + if (input.tag?.length) { |
| 186 | + // tags @> ARRAY[$tags] — Postgres array-contains. |
| 187 | + conditions.push(sql`${reports.tags} @> ${input.tag}::text[]`) |
| 188 | + } |
| 189 | + if (input.query) { |
| 190 | + const needle = `%${input.query.toLowerCase()}%` |
| 191 | + conditions.push( |
| 192 | + sql`(lower(${reports.title}) LIKE ${needle} OR lower(coalesce(${reports.description}, '')) LIKE ${needle})`, |
| 193 | + ) |
| 194 | + } |
| 195 | + if (decodedCursor) { |
| 196 | + // Keyset pagination: rows strictly after the cursor in (createdAt DESC, id DESC). |
| 197 | + conditions.push( |
| 198 | + or( |
| 199 | + lt(reports.createdAt, decodedCursor.createdAt), |
| 200 | + and(eq(reports.createdAt, decodedCursor.createdAt), lt(reports.id, decodedCursor.id)), |
| 201 | + )!, |
| 202 | + ) |
| 203 | + } |
| 204 | + |
| 205 | + const rows = await db |
| 206 | + .select({ |
| 207 | + id: reports.id, |
| 208 | + title: reports.title, |
| 209 | + status: reports.status, |
| 210 | + priority: reports.priority, |
| 211 | + tags: reports.tags, |
| 212 | + githubIssueNumber: reports.githubIssueNumber, |
| 213 | + githubIssueUrl: reports.githubIssueUrl, |
| 214 | + createdAt: reports.createdAt, |
| 215 | + updatedAt: reports.updatedAt, |
| 216 | + }) |
| 217 | + .from(reports) |
| 218 | + .where(and(...conditions)) |
| 219 | + .orderBy(desc(reports.createdAt), desc(reports.id)) |
| 220 | + .limit(limit + 1) |
| 221 | + |
| 222 | + const hasMore = rows.length > limit |
| 223 | + const page = rows.slice(0, limit) |
| 224 | + const nextCursor = |
| 225 | + hasMore && page.length > 0 |
| 226 | + ? encodeCursor({ |
| 227 | + createdAt: page[page.length - 1]!.createdAt, |
| 228 | + id: page[page.length - 1]!.id, |
| 229 | + }) |
| 230 | + : null |
| 231 | + |
| 232 | + const items = page.map((r) => ({ |
| 233 | + id: r.id, |
| 234 | + title: r.title, |
| 235 | + status: r.status, |
| 236 | + priority: r.priority, |
| 237 | + tags: r.tags, |
| 238 | + github: r.githubIssueNumber |
| 239 | + ? { issueNumber: r.githubIssueNumber, issueUrl: r.githubIssueUrl ?? null } |
| 240 | + : null, |
| 241 | + createdAt: r.createdAt.toISOString(), |
| 242 | + updatedAt: r.updatedAt.toISOString(), |
| 243 | + })) |
| 244 | + return { |
| 245 | + content: [{ type: "text" as const, text: JSON.stringify({ items, nextCursor }, null, 2) }], |
| 246 | + } |
| 247 | + }, |
| 248 | +} |
0 commit comments