mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 21:23:07 +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 {
|
export interface GitHubAuthor {
|
||||||
login: string;
|
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 {
|
export interface GitHubIssue {
|
||||||
@@ -24,6 +37,8 @@ export interface GitHubIssue {
|
|||||||
labels: GitHubLabel[];
|
labels: GitHubLabel[];
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
assignees: GitHubAssignee[];
|
||||||
|
linkedPRs?: LinkedPullRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListIssuesResult {
|
export interface ListIssuesResult {
|
||||||
@@ -33,6 +48,110 @@ export interface ListIssuesResult {
|
|||||||
error?: string;
|
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() {
|
export function createListIssuesHandler() {
|
||||||
return async (req: Request, res: Response): Promise<void> => {
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@@ -53,17 +172,17 @@ export function createListIssuesHandler() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch open and closed issues in parallel
|
// Fetch open and closed issues in parallel (now including assignees)
|
||||||
const [openResult, closedResult] = await Promise.all([
|
const [openResult, closedResult] = await Promise.all([
|
||||||
execAsync(
|
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,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
execAsync(
|
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,
|
cwd: projectPath,
|
||||||
env: execEnv,
|
env: execEnv,
|
||||||
@@ -77,6 +196,24 @@ export function createListIssuesHandler() {
|
|||||||
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
const openIssues: GitHubIssue[] = JSON.parse(openStdout || '[]');
|
||||||
const closedIssues: GitHubIssue[] = JSON.parse(closedStdout || '[]');
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
openIssues,
|
openIssues,
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export function createValidateIssueHandler() {
|
|||||||
|
|
||||||
// Create abort controller with 2 minute timeout for validation
|
// Create abort controller with 2 minute timeout for validation
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const VALIDATION_TIMEOUT_MS = 120000; // 2 minutes
|
const VALIDATION_TIMEOUT_MS = 360000; // 6 minutes
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`);
|
logger.warn(`Validation timeout reached after ${VALIDATION_TIMEOUT_MS}ms`);
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
Circle,
|
Circle,
|
||||||
X,
|
X,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
GitPullRequest,
|
||||||
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getElectronAPI,
|
getElectronAPI,
|
||||||
@@ -369,7 +371,7 @@ export function GitHubIssuesView() {
|
|||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{selectedIssue.labels.length > 0 && (
|
{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) => (
|
{selectedIssue.labels.map((label) => (
|
||||||
<span
|
<span
|
||||||
key={label.name}
|
key={label.name}
|
||||||
@@ -386,6 +388,75 @@ export function GitHubIssuesView() {
|
|||||||
</div>
|
</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 */}
|
{/* Body */}
|
||||||
{selectedIssue.body ? (
|
{selectedIssue.body ? (
|
||||||
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
|
<Markdown className="text-sm">{selectedIssue.body}</Markdown>
|
||||||
@@ -454,23 +525,38 @@ function IssueRow({ issue, isSelected, onClick, onOpenExternal, formatDate }: Is
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{issue.labels.length > 0 && (
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
<div className="flex items-center gap-1 mt-2 flex-wrap">
|
{/* Labels */}
|
||||||
{issue.labels.map((label) => (
|
{issue.labels.map((label) => (
|
||||||
<span
|
<span
|
||||||
key={label.name}
|
key={label.name}
|
||||||
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
className="px-1.5 py-0.5 text-[10px] font-medium rounded-full"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: `#${label.color}20`,
|
backgroundColor: `#${label.color}20`,
|
||||||
color: `#${label.color}`,
|
color: `#${label.color}`,
|
||||||
border: `1px solid #${label.color}40`,
|
border: `1px solid #${label.color}40`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{label.name}
|
{label.name}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
|
||||||
)}
|
{/* 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>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -118,6 +118,19 @@ export interface GitHubLabel {
|
|||||||
|
|
||||||
export interface GitHubAuthor {
|
export interface GitHubAuthor {
|
||||||
login: string;
|
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 {
|
export interface GitHubIssue {
|
||||||
@@ -129,6 +142,8 @@ export interface GitHubIssue {
|
|||||||
labels: GitHubLabel[];
|
labels: GitHubLabel[];
|
||||||
url: string;
|
url: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
assignees: GitHubAssignee[];
|
||||||
|
linkedPRs?: LinkedPullRequest[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitHubPR {
|
export interface GitHubPR {
|
||||||
|
|||||||
Reference in New Issue
Block a user