From a6b5224b28632788ba6e54d13abb30d540499582 Mon Sep 17 00:00:00 2001 From: Shrey Paharia Date: Fri, 23 May 2025 23:55:37 +0530 Subject: [PATCH] feat: add ability to search for issues --- src/api/tools/commonTools.ts | 160 +++++++++++++++++- src/api/tools/index.test.ts | 12 ++ .../tools/repoHandlers/DefaultRepoHandler.ts | 37 ++++ .../tools/repoHandlers/GenericRepoHandler.ts | 43 +++++ src/api/utils/githubClient.ts | 42 +++++ 5 files changed, 293 insertions(+), 1 deletion(-) diff --git a/src/api/tools/commonTools.ts b/src/api/tools/commonTools.ts index 090aeb9..f8d8668 100644 --- a/src/api/tools/commonTools.ts +++ b/src/api/tools/commonTools.ts @@ -7,7 +7,7 @@ import { } from "../utils/github.js"; import { fetchFileWithRobotsTxtCheck } from "../utils/robotsTxt.js"; import htmlToMd from "html-to-md"; -import { searchCode } from "../utils/githubClient.js"; +import { searchCode, searchIssues } from "../utils/githubClient.js"; import { fetchFileFromR2 } from "../utils/r2.js"; import { generateServerName } from "../../shared/nameUtils.js"; import { @@ -708,6 +708,135 @@ export async function searchRepositoryCode({ } } +/** + * Search for issues in a GitHub repository + * Supports filtering by issue state and pagination + */ +export async function searchRepositoryIssues({ + repoData, + query, + state = "all", + page = 1, + env, + ctx, +}: { + repoData: RepoData; + query: string; + state?: "open" | "closed" | "all"; + page?: number; + env: Env; + ctx: any; +}): Promise<{ + searchQuery: string; + content: { type: "text"; text: string }[]; + pagination?: { + totalCount: number; + currentPage: number; + perPage: number; + hasMorePages: boolean; + }; +}> { + try { + const owner = repoData.owner; + const repo = repoData.repo; + + if (!owner || !repo) { + return { + searchQuery: query, + content: [ + { + type: "text" as const, + text: `### Issue Search Results for: "${query}"\n\nCannot perform issue search without repository information.`, + }, + ], + }; + } + + const currentPage = Math.max(1, page); + const resultsPerPage = 30; + + const data = await searchIssues( + query, + owner, + repo, + env, + currentPage, + resultsPerPage, + state, + ); + + if (!data) { + return { + searchQuery: query, + content: [ + { + type: "text" as const, + text: `### Issue Search Results for: "${query}"\n\nFailed to search issues in ${owner}/${repo}. GitHub API request failed.`, + }, + ], + }; + } + + if (data.total_count === 0 || !data.items || data.items.length === 0) { + return { + searchQuery: query, + content: [ + { + type: "text" as const, + text: `### Issue Search Results for: "${query}"\n\nNo issues found in ${owner}/${repo}.`, + }, + ], + }; + } + + const totalCount = data.total_count; + const hasMorePages = currentPage * resultsPerPage < totalCount; + const totalPages = Math.ceil(totalCount / resultsPerPage); + + let formattedResults = `### Issue Search Results for: "${query}"\n\n`; + formattedResults += `Found ${totalCount} issues in ${owner}/${repo}.\n`; + formattedResults += `Page ${currentPage} of ${totalPages}.\n\n`; + + for (const item of data.items) { + formattedResults += `#### #${item.number}: ${item.title}\n`; + formattedResults += `- **State**: ${item.state}\n`; + formattedResults += `- **URL**: ${item.html_url}\n`; + formattedResults += `- **Score**: ${item.score}\n\n`; + } + + if (hasMorePages) { + formattedResults += `_Showing ${data.items.length} of ${totalCount} results. Use pagination to see more results._\n\n`; + } + + return { + searchQuery: query, + content: [ + { + type: "text" as const, + text: formattedResults, + }, + ], + pagination: { + totalCount, + currentPage, + perPage: resultsPerPage, + hasMorePages, + }, + }; + } catch (error) { + console.error(`Error in searchRepositoryIssues: ${error}`); + return { + searchQuery: query, + content: [ + { + type: "text" as const, + text: `### Issue Search Results for: "${query}"\n\nAn error occurred while searching issues: ${error}`, + }, + ], + }; + } +} + export async function fetchUrlContent({ url, env }: { url: string; env: Env }) { try { // Use the robotsTxt checking function to respect robots.txt rules @@ -992,6 +1121,35 @@ export function generateCodeSearchToolDescription({ return `Search for code within the GitHub repository: "${owner}/${repo}" using the GitHub Search API (exact match). Returns matching files for you to query further if relevant.`; } +/** + * Generate a dynamic tool name for the issue search tool based on the URL + */ +export function generateIssueSearchToolName({ + urlType, + repo, +}: RepoData): string { + try { + let toolName = "search_issues"; + if (urlType == "subdomain" || urlType == "github") { + return enforceToolNameLengthLimit("search_", repo, "_issues"); + } + return toolName.replace(/[^a-zA-Z0-9]/g, "_"); + } catch (error) { + console.error("Error generating issue search tool name:", error); + return "search_issues"; + } +} + +/** + * Generate a dynamic description for the issue search tool based on the URL + */ +export function generateIssueSearchToolDescription({ + owner, + repo, +}: RepoData): string { + return `Search open or closed issues within the GitHub repository: "${owner}/${repo}".`; +} + /** * Recursively list every subfolder prefix under `startPrefix`. * @param {R2Bucket} bucket – the Workers-bound R2 bucket diff --git a/src/api/tools/index.test.ts b/src/api/tools/index.test.ts index 7abce06..057ca8d 100644 --- a/src/api/tools/index.test.ts +++ b/src/api/tools/index.test.ts @@ -60,6 +60,10 @@ describe("Tools Module", () => { description: 'Search for code within the GitHub repository: "myorg/myrepo" using the GitHub Search API (exact match). Returns matching files for you to query further if relevant.', }, + search_myrepo_issues: { + description: + 'Search open or closed issues within the GitHub repository: "myorg/myrepo".', + }, }, }, // default handler - subdomain @@ -83,6 +87,10 @@ describe("Tools Module", () => { description: 'Search for code within the GitHub repository: "myorg/myrepo" using the GitHub Search API (exact match). Returns matching files for you to query further if relevant.', }, + search_myrepo_issues: { + description: + 'Search open or closed issues within the GitHub repository: "myorg/myrepo".', + }, }, }, // generic handler @@ -98,6 +106,10 @@ describe("Tools Module", () => { description: "Search for code in any GitHub repository by providing owner, project name, and search query. Returns matching files. Supports pagination with 30 results per page.", }, + search_generic_issues: { + description: + "Search issues in any GitHub repository by providing owner, project name, and search query. Supports filtering by state and pagination with 30 results per page.", + }, fetch_generic_url_content: { description: "Generic tool to fetch content from any absolute URL, respecting robots.txt rules. Use this to retrieve referenced urls (absolute urls) that were mentioned in previously fetched documentation.", diff --git a/src/api/tools/repoHandlers/DefaultRepoHandler.ts b/src/api/tools/repoHandlers/DefaultRepoHandler.ts index e7c6a25..b9b9556 100644 --- a/src/api/tools/repoHandlers/DefaultRepoHandler.ts +++ b/src/api/tools/repoHandlers/DefaultRepoHandler.ts @@ -2,6 +2,7 @@ import { fetchDocumentation, searchRepositoryDocumentation, searchRepositoryCode, + searchRepositoryIssues, fetchUrlContent, generateFetchToolName, generateFetchToolDescription, @@ -9,6 +10,8 @@ import { generateSearchToolDescription, generateCodeSearchToolName, generateCodeSearchToolDescription, + generateIssueSearchToolName, + generateIssueSearchToolDescription, } from "../commonTools.js"; import { z } from "zod"; import type { RepoData } from "../../../shared/repoData.js"; @@ -25,6 +28,9 @@ class DefaultRepoHandler implements RepoHandler { const codeSearchToolName = generateCodeSearchToolName(repoData); const codeSearchToolDescription = generateCodeSearchToolDescription(repoData); + const issueSearchToolName = generateIssueSearchToolName(repoData); + const issueSearchToolDescription = + generateIssueSearchToolDescription(repoData); return [ { @@ -76,6 +82,37 @@ class DefaultRepoHandler implements RepoHandler { }); }, }, + { + name: issueSearchToolName, + description: issueSearchToolDescription, + paramsSchema: { + query: z + .string() + .describe("The search query to find relevant issues"), + state: z + .enum(["open", "closed", "all"]) + .optional() + .describe( + "Filter issues by state. Defaults to all if not specified.", + ), + page: z + .number() + .optional() + .describe( + "Page number to retrieve (starting from 1). Each page contains 30 results.", + ), + }, + cb: async ({ query, state, page }) => { + return searchRepositoryIssues({ + repoData, + query, + state, + page, + env, + ctx, + }); + }, + }, ]; } diff --git a/src/api/tools/repoHandlers/GenericRepoHandler.ts b/src/api/tools/repoHandlers/GenericRepoHandler.ts index 344bb26..88e2671 100644 --- a/src/api/tools/repoHandlers/GenericRepoHandler.ts +++ b/src/api/tools/repoHandlers/GenericRepoHandler.ts @@ -5,6 +5,7 @@ import { fetchDocumentation, searchRepositoryDocumentation, searchRepositoryCode, + searchRepositoryIssues, } from "../commonTools.js"; import { incrementRepoViewCount } from "../../utils/badge.js"; import rawMapping from "./generic/static-mapping.json"; @@ -151,6 +152,48 @@ class GenericRepoHandler implements RepoHandler { return searchRepositoryCode({ repoData, query, page, env, ctx }); }, }, + { + name: "search_generic_issues", + description: + "Search issues in any GitHub repository by providing owner, project name, and search query. Supports filtering by state and pagination with 30 results per page.", + paramsSchema: { + owner: z + .string() + .describe("The GitHub repository owner (username or organization)"), + repo: z.string().describe("The GitHub repository name"), + query: z + .string() + .describe("The search query to find relevant issues"), + state: z + .enum(["open", "closed", "all"]) + .optional() + .describe( + "Filter issues by state. Defaults to all if not specified.", + ), + page: z + .number() + .optional() + .describe( + "Page number to retrieve (starting from 1). Each page contains 30 results.", + ), + }, + cb: async ({ owner, repo, query, state, page }) => { + const repoData: RepoData = { + owner, + repo, + urlType: "github", + host: "gitmcp.io", + }; + return searchRepositoryIssues({ + repoData, + query, + state, + page, + env, + ctx, + }); + }, + }, ]; } diff --git a/src/api/utils/githubClient.ts b/src/api/utils/githubClient.ts index 83989c3..391ee42 100644 --- a/src/api/utils/githubClient.ts +++ b/src/api/utils/githubClient.ts @@ -267,6 +267,48 @@ export async function searchCode( return response.json(); } +/** + * Search for issues in a GitHub repository + * @param query - Search query + * @param owner - Repository owner + * @param repo - Repository name + * @param env - Environment for GitHub token + * @param page - Page number (1-indexed) + * @param perPage - Results per page (max 100) + * @param state - Issue state filter + */ +export async function searchIssues( + query: string, + owner: string, + repo: string, + env: Env, + page: number = 1, + perPage: number = 30, + state: "open" | "closed" | "all" = "all", +): Promise { + const validPerPage = Math.min(Math.max(1, perPage), 100); + + let searchQuery = `${query} repo:${owner}/${repo} type:issue`; + if (state !== "all") { + searchQuery += ` state:${state}`; + } + + const searchUrl = + `https://api.github.com/search/issues?q=${encodeURIComponent(searchQuery)}` + + `&page=${page}&per_page=${validPerPage}`; + + const response = await githubApiRequest(searchUrl, {}, env); + + if (!response || !response.ok) { + console.warn( + `GitHub API issue search failed: ${response?.status} ${response?.statusText}`, + ); + return null; + } + + return response.json(); +} + /** * Search for a specific filename in a GitHub repository * @param filename - Filename to search for