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.
This commit is contained in:
Kacper
2025-12-23 16:57:29 +01:00
parent a881d175bc
commit 5f0ecc8dd6
4 changed files with 260 additions and 22 deletions

View File

@@ -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<Map<number, LinkedPullRequest[]>> {
const linkedPRsMap = new Map<number, LinkedPullRequest[]>();
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<number>();
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<void> => {
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,

View File

@@ -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();