mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 && (
|
||||
<div className="flex items-center gap-2 mb-6 flex-wrap">
|
||||
<div className="flex items-center gap-2 mb-4 flex-wrap">
|
||||
{selectedIssue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
@@ -386,6 +388,75 @@ export function GitHubIssuesView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees */}
|
||||
{selectedIssue.assignees && selectedIssue.assignees.length > 0 && (
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Assigned to:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedIssue.assignees.map((assignee) => (
|
||||
<span
|
||||
key={assignee.login}
|
||||
className="inline-flex items-center gap-1.5 px-2 py-0.5 text-xs font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20"
|
||||
>
|
||||
{assignee.avatarUrl && (
|
||||
<img
|
||||
src={assignee.avatarUrl}
|
||||
alt={assignee.login}
|
||||
className="h-4 w-4 rounded-full"
|
||||
/>
|
||||
)}
|
||||
{assignee.login}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Pull Requests */}
|
||||
{selectedIssue.linkedPRs && selectedIssue.linkedPRs.length > 0 && (
|
||||
<div className="mb-6 p-3 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<GitPullRequest className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">Linked Pull Requests</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{selectedIssue.linkedPRs.map((pr) => (
|
||||
<div key={pr.number} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 text-xs font-medium rounded',
|
||||
pr.state === 'open'
|
||||
? 'bg-green-500/10 text-green-500'
|
||||
: pr.state === 'merged'
|
||||
? 'bg-purple-500/10 text-purple-500'
|
||||
: 'bg-red-500/10 text-red-500'
|
||||
)}
|
||||
>
|
||||
{pr.state === 'open'
|
||||
? 'Open'
|
||||
: pr.state === 'merged'
|
||||
? 'Merged'
|
||||
: 'Closed'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">#{pr.number}</span>
|
||||
<span className="truncate">{pr.title}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 flex-shrink-0"
|
||||
onClick={() => handleOpenInGitHub(pr.url)}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{selectedIssue.body ? (
|
||||
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
|
||||
@@ -454,23 +525,38 @@ function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: Is
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{issue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-1 mt-2 flex-wrap">
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{/* Labels */}
|
||||
{issue.labels.map((label) => (
|
||||
<span
|
||||
key={label.name}
|
||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||
style={{
|
||||
backgroundColor: `#${label.color}20`,
|
||||
color: `#${label.color}`,
|
||||
border: `1px solid #${label.color}40`,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{/* Linked PR indicator */}
|
||||
{issue.linkedPRs && issue.linkedPRs.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-purple-500/10 text-purple-500 border border-purple-500/20">
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{issue.linkedPRs.length} PR{issue.linkedPRs.length > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Assignee indicator */}
|
||||
{issue.assignees && issue.assignees.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded-full bg-blue-500/10 text-blue-500 border border-blue-500/20">
|
||||
<User className="h-3 w-3" />
|
||||
{issue.assignees.map((a) => a.login).join(', ')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -118,6 +118,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 {
|
||||
@@ -129,6 +142,8 @@ export interface GitHubIssue {
|
||||
labels: GitHubLabel[];
|
||||
url: string;
|
||||
body: string;
|
||||
assignees: GitHubAssignee[];
|
||||
linkedPRs?: LinkedPullRequest[];
|
||||
}
|
||||
|
||||
export interface GitHubPR {
|
||||
|
||||
Reference in New Issue
Block a user