From 5f0ecc8dd6c34176544a1722f504713386e7d096 Mon Sep 17 00:00:00 2001 From: Kacper Date: Tue, 23 Dec 2025 16:57:29 +0100 Subject: [PATCH] feat: Enhance GitHub issue handling with assignees and linked PRs - Added support for assignees in GitHub issue data structure. - Implemented fetching of linked pull requests for open issues using the GitHub GraphQL API. - Updated UI to display assignees and linked PRs for selected issues. - Adjusted issue listing commands to include assignees in the fetched data. --- .../src/routes/github/routes/list-issues.ts | 143 +++++++++++++++++- .../routes/github/routes/validate-issue.ts | 2 +- .../components/views/github-issues-view.tsx | 122 ++++++++++++--- apps/ui/src/lib/electron.ts | 15 ++ 4 files changed, 260 insertions(+), 22 deletions(-) diff --git a/apps/server/src/routes/github/routes/list-issues.ts b/apps/server/src/routes/github/routes/list-issues.ts index 08f94135..581a4eaf 100644 --- a/apps/server/src/routes/github/routes/list-issues.ts +++ b/apps/server/src/routes/github/routes/list-issues.ts @@ -13,6 +13,19 @@ export interface GitHubLabel { export interface GitHubAuthor { login: string; + avatarUrl?: string; +} + +export interface GitHubAssignee { + login: string; + avatarUrl?: string; +} + +export interface LinkedPullRequest { + number: number; + title: string; + state: string; + url: string; } export interface GitHubIssue { @@ -24,6 +37,8 @@ export interface GitHubIssue { labels: GitHubLabel[]; url: string; body: string; + assignees: GitHubAssignee[]; + linkedPRs?: LinkedPullRequest[]; } export interface ListIssuesResult { @@ -33,6 +48,110 @@ export interface ListIssuesResult { error?: string; } +/** + * Fetch linked PRs for a list of issues using GitHub GraphQL API + */ +async function fetchLinkedPRs( + projectPath: string, + owner: string, + repo: string, + issueNumbers: number[] +): Promise> { + const linkedPRsMap = new Map(); + + if (issueNumbers.length === 0) { + return linkedPRsMap; + } + + // Build GraphQL query for batch fetching linked PRs + // We fetch up to 20 issues at a time to avoid query limits + const batchSize = 20; + for (let i = 0; i < issueNumbers.length; i += batchSize) { + const batch = issueNumbers.slice(i, i + batchSize); + + const issueQueries = batch + .map( + (num, idx) => ` + issue${idx}: issue(number: ${num}) { + number + timelineItems(first: 10, itemTypes: [CROSS_REFERENCED_EVENT, CONNECTED_EVENT]) { + nodes { + ... on CrossReferencedEvent { + source { + ... on PullRequest { + number + title + state + url + } + } + } + ... on ConnectedEvent { + subject { + ... on PullRequest { + number + title + state + url + } + } + } + } + } + }` + ) + .join('\n'); + + const query = `{ + repository(owner: "${owner}", name: "${repo}") { + ${issueQueries} + } + }`; + + try { + const { stdout } = await execAsync(`gh api graphql -f query='${query}'`, { + cwd: projectPath, + env: execEnv, + }); + + const response = JSON.parse(stdout); + const repoData = response?.data?.repository; + + if (repoData) { + batch.forEach((issueNum, idx) => { + const issueData = repoData[`issue${idx}`]; + if (issueData?.timelineItems?.nodes) { + const linkedPRs: LinkedPullRequest[] = []; + const seenPRs = new Set(); + + for (const node of issueData.timelineItems.nodes) { + const pr = node?.source || node?.subject; + if (pr?.number && !seenPRs.has(pr.number)) { + seenPRs.add(pr.number); + linkedPRs.push({ + number: pr.number, + title: pr.title, + state: pr.state.toLowerCase(), + url: pr.url, + }); + } + } + + if (linkedPRs.length > 0) { + linkedPRsMap.set(issueNum, linkedPRs); + } + } + }); + } + } catch { + // If GraphQL fails, continue without linked PRs + console.warn('Failed to fetch linked PRs via GraphQL'); + } + } + + return linkedPRsMap; +} + export function createListIssuesHandler() { return async (req: Request, res: Response): Promise => { try { @@ -53,17 +172,17 @@ export function createListIssuesHandler() { return; } - // Fetch open and closed issues in parallel + // Fetch open and closed issues in parallel (now including assignees) const [openResult, closedResult] = await Promise.all([ execAsync( - 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body --limit 100', + 'gh issue list --state open --json number,title,state,author,createdAt,labels,url,body,assignees --limit 100', { cwd: projectPath, env: execEnv, } ), execAsync( - 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body --limit 50', + 'gh issue list --state closed --json number,title,state,author,createdAt,labels,url,body,assignees --limit 50', { cwd: projectPath, env: execEnv, @@ -77,6 +196,24 @@ export function createListIssuesHandler() { const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]'); const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]'); + // Fetch linked PRs for open issues (more relevant for active work) + if (remoteStatus.owner && remoteStatus.repo && openIssues.length > 0) { + const linkedPRsMap = await fetchLinkedPRs( + projectPath, + remoteStatus.owner, + remoteStatus.repo, + openIssues.map((i) => i.number) + ); + + // Attach linked PRs to issues + for (const issue of openIssues) { + const linkedPRs = linkedPRsMap.get(issue.number); + if (linkedPRs) { + issue.linkedPRs = linkedPRs; + } + } + } + res.json({ success: true, openIssues, diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 3bb7a66e..69af0418 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -76,7 +76,7 @@ export function createValidateIssueHandler() { // Create abort controller with 2 minute timeout for validation const abortController = new AbortController(); - const VALIDATION_TIMEOUT_MS = 120000; // 2 minutes + const VALIDATION_TIMEOUT_MS = 360000; // 6 minutes timeoutId = setTimeout(() => { logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`); abortController.abort(); diff --git a/apps/ui/src/components/views/github-issues-view.tsx b/apps/ui/src/components/views/github-issues-view.tsx index ef760833..b04397fd 100644 --- a/apps/ui/src/components/views/github-issues-view.tsx +++ b/apps/ui/src/components/views/github-issues-view.tsx @@ -8,6 +8,8 @@ import { Circle, X, Wand2, + GitPullRequest, + User, } from 'lucide-react'; import { getElectronAPI, @@ -369,7 +371,7 @@ export function GitHubIssuesView() { {/* Labels */} {selectedIssue.labels.length > 0 && ( -
+
{selectedIssue.labels.map((label) => ( )} + {/* Assignees */} + {selectedIssue.assignees && selectedIssue.assignees.length > 0 && ( +
+ + Assigned to: +
+ {selectedIssue.assignees.map((assignee) => ( + + {assignee.avatarUrl && ( + {assignee.login} + )} + {assignee.login} + + ))} +
+
+ )} + + {/* Linked Pull Requests */} + {selectedIssue.linkedPRs && selectedIssue.linkedPRs.length > 0 && ( +
+
+ + Linked Pull Requests +
+
+ {selectedIssue.linkedPRs.map((pr) => ( +
+
+ + {pr.state === 'open' + ? 'Open' + : pr.state === 'merged' + ? 'Merged' + : 'Closed'} + + #{pr.number} + {pr.title} +
+ +
+ ))} +
+
+ )} + {/* Body */} {selectedIssue.body ? ( {selectedIssue.body} @@ -454,23 +525,38 @@ function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: Is
- {issue.labels.length > 0 && ( -
- {issue.labels.map((label) => ( - - {label.name} - - ))} -
- )} +
+ {/* Labels */} + {issue.labels.map((label) => ( + + {label.name} + + ))} + + {/* Linked PR indicator */} + {issue.linkedPRs && issue.linkedPRs.length > 0 && ( + + + {issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''} + + )} + + {/* Assignee indicator */} + {issue.assignees && issue.assignees.length > 0 && ( + + + {issue.assignees.map((a) => a.login).join(', ')} + + )} +