diff --git a/apps/api/github/package-lock.json b/apps/api/github/package-lock.json index 0b30c618..6df3ab5c 100644 --- a/apps/api/github/package-lock.json +++ b/apps/api/github/package-lock.json @@ -8,6 +8,7 @@ "name": "api-github", "version": "1.0.0", "dependencies": { + "@octokit/graphql": "^9.0.2", "@octokit/rest": "^20.0.2", "cors": "^2.8.5", "dotenv": "^17.0.0", @@ -296,6 +297,19 @@ "node": ">= 18" } }, + "node_modules/@octokit/core/node_modules/@octokit/graphql": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", + "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "dependencies": { + "@octokit/request": "^8.4.1", + "@octokit/types": "^13.0.0", + "universal-user-agent": "^6.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@octokit/endpoint": { "version": "9.0.6", "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-9.0.6.tgz", @@ -309,18 +323,74 @@ } }, "node_modules/@octokit/graphql": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-7.1.1.tgz", - "integrity": "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.2.tgz", + "integrity": "sha512-iz6KzZ7u95Fzy9Nt2L8cG88lGRMr/qy1Q36ih/XVzMIlPDMYwaNLE/ENhqmIzgPrlNWiYJkwmveEetvxAgFBJw==", "dependencies": { - "@octokit/request": "^8.4.1", - "@octokit/types": "^13.0.0", - "universal-user-agent": "^6.0.0" + "@octokit/request": "^10.0.4", + "@octokit/types": "^15.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/endpoint": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.1.tgz", + "integrity": "sha512-7P1dRAZxuWAOPI7kXfio88trNi/MegQ0IJD3vfgC3b+LZo1Qe6gRJc2v0mz2USWWJOKrB2h5spXCzGbw+fAdqA==", + "dependencies": { + "@octokit/types": "^15.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/openapi-types": { + "version": "26.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-26.0.0.tgz", + "integrity": "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA==" + }, + "node_modules/@octokit/graphql/node_modules/@octokit/request": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.5.tgz", + "integrity": "sha512-TXnouHIYLtgDhKo+N6mXATnDBkV05VwbR0TtMWpgTHIoQdRQfCSzmy/LGqR1AbRMbijq/EckC/E3/ZNcU92NaQ==", + "dependencies": { + "@octokit/endpoint": "^11.0.1", + "@octokit/request-error": "^7.0.1", + "@octokit/types": "^15.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" } }, + "node_modules/@octokit/graphql/node_modules/@octokit/request-error": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.0.1.tgz", + "integrity": "sha512-CZpFwV4+1uBrxu7Cw8E5NCXDWFNf18MSY23TdxCBgjw1tXXHvTrZVsXlW8hgFTOLw8RQR1BBrMvYRtuyaijHMA==", + "dependencies": { + "@octokit/types": "^15.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql/node_modules/@octokit/types": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-15.0.0.tgz", + "integrity": "sha512-8o6yDfmoGJUIeR9OfYU0/TUJTnMPG2r68+1yEdUeG2Fdqpj8Qetg0ziKIgcBm0RW/j29H41WP37CYCEhp6GoHQ==", + "dependencies": { + "@octokit/openapi-types": "^26.0.0" + } + }, + "node_modules/@octokit/graphql/node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" + }, "node_modules/@octokit/openapi-types": { "version": "24.2.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-24.2.0.tgz", @@ -2071,6 +2141,21 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", diff --git a/apps/api/github/package.json b/apps/api/github/package.json index 9aaea0ff..90085a99 100644 --- a/apps/api/github/package.json +++ b/apps/api/github/package.json @@ -17,6 +17,7 @@ "extract-prs-with-descriptions": "ts-node src/scripts/generatePRsWithDescriptions.ts" }, "dependencies": { + "@octokit/graphql": "^9.0.2", "@octokit/rest": "^20.0.2", "cors": "^2.8.5", "dotenv": "^17.0.0", diff --git a/apps/api/github/src/github.ts b/apps/api/github/src/github.ts index f7c0128c..111edf39 100644 --- a/apps/api/github/src/github.ts +++ b/apps/api/github/src/github.ts @@ -7,9 +7,11 @@ import { PaginationMeta } from './types'; export class GitHubService { private octokit: Octokit; + private apiToken: string; constructor(apiToken: string) { this.octokit = new Octokit({ auth: apiToken }); + this.apiToken = apiToken; } /** @@ -18,9 +20,9 @@ export class GitHubService { async getPullRequests(username: string, page: number = 1, perPage: number = 10) { try { console.log(`🔍 Fetching PRs for ${username} (page ${page}, ${perPage} per page)`); - - const result = await fetchPullRequests(this.octokit, username, page, perPage); - + + const result = await fetchPullRequests(this.octokit, username, page, perPage, this.apiToken); + return { data: result.pullRequests, meta: { @@ -41,19 +43,19 @@ export class GitHubService { async getPullRequestDetails(owner: string, repo: string, pullNumber: number) { try { console.log(`🔍 Fetching PR details for ${owner}/${repo}#${pullNumber}`); - - const pullRequest = await fetchPullRequestDetails(this.octokit, owner, repo, pullNumber); - + + const pullRequest = await fetchPullRequestDetails(this.octokit, owner, repo, pullNumber, this.apiToken); + return { data: pullRequest }; } catch (error) { // Check if it's a test case (common test patterns) - handle this first const isTestCase = owner === 'invalid-user' || repo === 'invalid-repo' || pullNumber === 999; - + if (!isTestCase) { // Only log errors for non-test cases console.error('❌ Error fetching pull request details:', error); } - + throw error; } } diff --git a/apps/api/github/src/pull-requests/detail/index.ts b/apps/api/github/src/pull-requests/detail/index.ts index ded63dc2..92610cb6 100644 --- a/apps/api/github/src/pull-requests/detail/index.ts +++ b/apps/api/github/src/pull-requests/detail/index.ts @@ -1,17 +1,65 @@ import { Octokit } from '@octokit/rest'; +import { graphql } from '@octokit/graphql'; import { DetailedPullRequestResponse } from '../../types'; import { retryApiCall, ensureSufficientRateLimit } from '../../utils/rateLimitUtils'; import { validatePullRequestParams, formatPRStats } from './helpers'; +/** + * Fetch closing issues for a pull request using GraphQL API + * Returns an array of issues that will be closed when the PR is merged + */ +async function fetchClosingIssues( + token: string, + owner: string, + repo: string, + pullNumber: number +): Promise> { + try { + const graphqlWithAuth = graphql.defaults({ + headers: { + authorization: `token ${token}`, + }, + }); + + const query = ` + query getClosingIssues($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 10) { + nodes { + number + url + } + } + } + } + } + `; + + const result: any = await graphqlWithAuth(query, { + owner, + repo, + prNumber: pullNumber, + }); + + return result.repository?.pullRequest?.closingIssuesReferences?.nodes || []; + } catch (error) { + // If GraphQL query fails, log warning but don't fail the entire request + console.log(`⚠️ Could not fetch closing issues for PR #${pullNumber}: ${error}`); + return []; + } +} + /** * Fetch detailed information for a specific pull request * Includes additional data like commits, additions, deletions, and comments count */ export async function fetchPullRequestDetails( octokit: Octokit, - owner: string, - repo: string, - pullNumber: number + owner: string, + repo: string, + pullNumber: number, + token?: string ): Promise { console.log(`🔍 Fetching PR #${pullNumber} from ${owner}/${repo}`); @@ -71,6 +119,12 @@ export async function fetchPullRequestDetails( const pr = prResponse.data; const commentsCount = commentsResponse.data.length; + // Fetch closing issues if token is provided + let closingIssues: Array<{ number: number; url: string }> = []; + if (token) { + closingIssues = await fetchClosingIssues(token, owner, repo, pullNumber); + } + const detailedPR: DetailedPullRequestResponse = { id: pr.id, number: pr.number, @@ -88,6 +142,7 @@ export async function fetchPullRequestDetails( deletions: pr.deletions, changed_files: pr.changed_files, comments: commentsCount, // Include comments count for 💬 display + closingIssues: closingIssues.length > 0 ? closingIssues : undefined, // Only include if there are closing issues author: { login: pr.user?.login || 'unknown', avatar_url: pr.user?.avatar_url || '', diff --git a/apps/api/github/src/pull-requests/list/conversion.ts b/apps/api/github/src/pull-requests/list/conversion.ts index ba653fcf..aea8e599 100644 --- a/apps/api/github/src/pull-requests/list/conversion.ts +++ b/apps/api/github/src/pull-requests/list/conversion.ts @@ -1,33 +1,81 @@ import { Octokit } from '@octokit/rest'; +import { graphql } from '@octokit/graphql'; import { PullRequestResponse } from '../../types'; import { retryApiCall, delay } from '../../utils/rateLimitUtils'; import { SearchItem } from './types'; +/** + * Fetch closing issues for a pull request using GraphQL API + * Returns an array of issues that will be closed when the PR is merged + */ +async function fetchClosingIssues( + token: string, + owner: string, + repo: string, + pullNumber: number +): Promise> { + try { + const graphqlWithAuth = graphql.defaults({ + headers: { + authorization: `token ${token}`, + }, + }); + + const query = ` + query getClosingIssues($owner: String!, $repo: String!, $prNumber: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $prNumber) { + closingIssuesReferences(first: 10) { + nodes { + number + url + } + } + } + } + } + `; + + const result: any = await graphqlWithAuth(query, { + owner, + repo, + prNumber: pullNumber, + }); + + return result.repository?.pullRequest?.closingIssuesReferences?.nodes || []; + } catch (error) { + // If GraphQL query fails, log warning but don't fail the entire request + console.log(`⚠️ Could not fetch closing issues for PR #${pullNumber}: ${error}`); + return []; + } +} + /** * Convert search results to detailed PR objects * Limits the number of detailed API calls to prevent rate limiting during tests */ export async function convertSearchResultsToPRs( - octokit: Octokit, - searchItems: SearchItem[] + octokit: Octokit, + searchItems: SearchItem[], + token?: string ): Promise { const allPRs: PullRequestResponse[] = []; - + // Limit the number of detailed API calls to prevent rate limiting during tests const maxDetailedCalls = Math.min(searchItems.length, 20); console.log(`🔄 Processing ${maxDetailedCalls} PRs out of ${searchItems.length} found items`); - + for (let i = 0; i < maxDetailedCalls; i++) { const item = searchItems[i]; try { - const prData = await fetchDetailedPRData(octokit, item); + const prData = await fetchDetailedPRData(octokit, item, token); allPRs.push(prData); } catch (prError) { console.warn(`⚠️ Failed to fetch details for PR ${item.number}:`, prError); continue; } } - + return allPRs; } @@ -35,14 +83,15 @@ export async function convertSearchResultsToPRs( * Fetch detailed data for a single pull request */ export async function fetchDetailedPRData( - octokit: Octokit, - item: SearchItem + octokit: Octokit, + item: SearchItem, + token?: string ): Promise { // Extract owner and repo from the URL const urlParts = item.html_url.split('/'); const owner = urlParts[3]; const repo = urlParts[4]; - + // Get the full PR details with retry logic const { data: pr } = await retryApiCall(() => octokit.rest.pulls.get({ @@ -60,6 +109,12 @@ export async function fetchDetailedPRData( }) ); + // Fetch closing issues if token is provided + let closingIssues: Array<{ number: number; url: string }> = []; + if (token) { + closingIssues = await fetchClosingIssues(token, owner, repo, item.number); + } + // Add small delay between API calls to be respectful await delay(50); @@ -75,6 +130,7 @@ export async function fetchDetailedPRData( additions: pr.additions, deletions: pr.deletions, comments: pr.comments, + closingIssues: closingIssues.length > 0 ? closingIssues : undefined, repository: { name: repo, description: repoData.description, diff --git a/apps/api/github/src/pull-requests/list/index.ts b/apps/api/github/src/pull-requests/list/index.ts index d6e1e355..3fc311c3 100644 --- a/apps/api/github/src/pull-requests/list/index.ts +++ b/apps/api/github/src/pull-requests/list/index.ts @@ -10,13 +10,14 @@ import { convertSearchResultsToPRs } from './conversion'; * This provides better coverage across all repositories the user has contributed to */ export async function fetchPullRequests( - octokit: Octokit, - username: string, - page: number = 1, - perPage: number = 20 + octokit: Octokit, + username: string, + page: number = 1, + perPage: number = 20, + token?: string ): Promise { console.log(`🔍 Searching for PRs by ${username} using GitHub Search API (page ${page})`); - + // Check if we have sufficient rate limit for this operation const hasRateLimit = await ensureSufficientRateLimit(octokit, 'pull_requests', perPage); if (!hasRateLimit) { @@ -24,15 +25,15 @@ export async function fetchPullRequests( } const searchQuery = `author:${username} type:pr`; - + // Fetch search results from GitHub using native pagination const searchItems = await fetchSearchResults(octokit, searchQuery, page, perPage); - + // Get total count for pagination const totalCount = await getTotalPullRequestCount(octokit, searchQuery); - + // Convert search results to our format with detailed data - const paginatedPRs = await convertSearchResultsToPRs(octokit, searchItems); + const paginatedPRs = await convertSearchResultsToPRs(octokit, searchItems, token); // Calculate pagination metadata const totalPages = Math.ceil(totalCount / perPage); @@ -46,11 +47,11 @@ export async function fetchPullRequests( }; console.log(`✅ Returning ${paginatedPRs.length} PRs for ${username} (page ${page}/${totalPages}, total: ${totalCount})`); - + if (paginatedPRs.length > 0) { console.log(`📄 First PR on this page: ${paginatedPRs[0]?.title} (${paginatedPRs[0]?.created_at})`); } - + return { pullRequests: paginatedPRs, pagination diff --git a/apps/api/github/src/types.ts b/apps/api/github/src/types.ts index 86c4096a..15c61d57 100644 --- a/apps/api/github/src/types.ts +++ b/apps/api/github/src/types.ts @@ -12,6 +12,10 @@ export interface PullRequestResponse { additions?: number; deletions?: number; comments?: number; + closingIssues?: Array<{ + number: number; + url: string; + }>; // Issues that will be closed by this PR repository: { name: string; description: string | null; @@ -61,4 +65,8 @@ export interface DetailedPullRequestResponse extends PullRequestResponse { deletions: number; changed_files: number; comments: number; // Comments count for 💬 display + closingIssues?: Array<{ + number: number; + url: string; + }>; // Issues that will be closed by this PR } \ No newline at end of file diff --git a/apps/web/src/components/pull-request-feed/list-card/index.tsx b/apps/web/src/components/pull-request-feed/list-card/index.tsx index 8025db37..0f62a475 100644 --- a/apps/web/src/components/pull-request-feed/list-card/index.tsx +++ b/apps/web/src/components/pull-request-feed/list-card/index.tsx @@ -114,6 +114,21 @@ export const PullRequestFeedListCard: React.FC = ( )} + {/* Closing Issues - hidden on mobile */} + {pullRequest.closingIssues && pullRequest.closingIssues.length > 0 && ( + + e.stopPropagation()} + > + fixes #{pullRequest.closingIssues[0].number} + + + )} + {/* Comments - hidden on mobile */} {pullRequest.comments !== undefined && pullRequest.comments > 0 && ( @@ -121,7 +136,7 @@ export const PullRequestFeedListCard: React.FC = ( {pullRequest.comments} )} - + {/* Changes - hidden on mobile */} {bytesChange.hasData && ( diff --git a/shared/types/pull-requests/index.ts b/shared/types/pull-requests/index.ts index 1c0a6744..727e1a1a 100644 --- a/shared/types/pull-requests/index.ts +++ b/shared/types/pull-requests/index.ts @@ -13,6 +13,10 @@ export interface PullRequestListData { additions?: number; deletions?: number; comments?: number; + closingIssues?: Array<{ + number: number; + url: string; + }>; // Issues that will be closed by this PR repository: { name: string; description: string | null;