175 lines
4.4 KiB
TypeScript
175 lines
4.4 KiB
TypeScript
/**
|
|
* GitHub Issue utilities
|
|
*
|
|
* Fetches issue content via `gh` CLI and formats it as task text
|
|
* for workflow execution or task creation.
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createLogger } from '../../shared/utils/index.js';
|
|
import type { GitHubIssue, GhCliStatus } from './types.js';
|
|
|
|
export type { GitHubIssue, GhCliStatus };
|
|
|
|
const log = createLogger('github');
|
|
|
|
/** Regex to match `#N` patterns (issue numbers) */
|
|
const ISSUE_NUMBER_REGEX = /^#(\d+)$/;
|
|
|
|
/**
|
|
* Check if `gh` CLI is available and authenticated.
|
|
*/
|
|
export function checkGhCli(): GhCliStatus {
|
|
try {
|
|
execFileSync('gh', ['auth', 'status'], { stdio: 'pipe' });
|
|
return { available: true };
|
|
} catch {
|
|
try {
|
|
execFileSync('gh', ['--version'], { stdio: 'pipe' });
|
|
return {
|
|
available: false,
|
|
error: 'gh CLI is installed but not authenticated. Run `gh auth login` first.',
|
|
};
|
|
} catch {
|
|
return {
|
|
available: false,
|
|
error: 'gh CLI is not installed. Install it from https://cli.github.com/',
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch issue content via `gh issue view`.
|
|
* Throws on failure (issue not found, network error, etc.).
|
|
*/
|
|
export function fetchIssue(issueNumber: number): GitHubIssue {
|
|
log.debug('Fetching issue', { issueNumber });
|
|
|
|
const raw = execFileSync(
|
|
'gh',
|
|
['issue', 'view', String(issueNumber), '--json', 'number,title,body,labels,comments'],
|
|
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
);
|
|
|
|
const data = JSON.parse(raw) as {
|
|
number: number;
|
|
title: string;
|
|
body: string;
|
|
labels: Array<{ name: string }>;
|
|
comments: Array<{ author: { login: string }; body: string }>;
|
|
};
|
|
|
|
return {
|
|
number: data.number,
|
|
title: data.title,
|
|
body: data.body ?? '',
|
|
labels: data.labels.map((l) => l.name),
|
|
comments: data.comments.map((c) => ({
|
|
author: c.author.login,
|
|
body: c.body,
|
|
})),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Format a GitHub issue into task text for workflow execution.
|
|
*
|
|
* Output format:
|
|
* ```
|
|
* ## GitHub Issue #6: Fix authentication bug
|
|
*
|
|
* {body}
|
|
*
|
|
* ### Labels
|
|
* bug, priority:high
|
|
*
|
|
* ### Comments
|
|
* **user1**: Comment body...
|
|
* ```
|
|
*/
|
|
export function formatIssueAsTask(issue: GitHubIssue): string {
|
|
const parts: string[] = [];
|
|
|
|
parts.push(`## GitHub Issue #${issue.number}: ${issue.title}`);
|
|
|
|
if (issue.body) {
|
|
parts.push('');
|
|
parts.push(issue.body);
|
|
}
|
|
|
|
if (issue.labels.length > 0) {
|
|
parts.push('');
|
|
parts.push('### Labels');
|
|
parts.push(issue.labels.join(', '));
|
|
}
|
|
|
|
if (issue.comments.length > 0) {
|
|
parts.push('');
|
|
parts.push('### Comments');
|
|
for (const comment of issue.comments) {
|
|
parts.push(`**${comment.author}**: ${comment.body}`);
|
|
}
|
|
}
|
|
|
|
return parts.join('\n');
|
|
}
|
|
|
|
/**
|
|
* Parse `#N` patterns from argument strings.
|
|
* Returns issue numbers found, or empty array if none.
|
|
*
|
|
* Each argument must be exactly `#N` (no mixed text).
|
|
* Examples:
|
|
* ['#6'] → [6]
|
|
* ['#6', '#7'] → [6, 7]
|
|
* ['Fix bug'] → []
|
|
* ['#6', 'and', '#7'] → [] (mixed, not all are issue refs)
|
|
*/
|
|
export function parseIssueNumbers(args: string[]): number[] {
|
|
if (args.length === 0) return [];
|
|
|
|
const numbers: number[] = [];
|
|
for (const arg of args) {
|
|
const match = arg.match(ISSUE_NUMBER_REGEX);
|
|
if (!match?.[1]) return []; // Not all args are issue refs
|
|
numbers.push(Number.parseInt(match[1], 10));
|
|
}
|
|
|
|
return numbers;
|
|
}
|
|
|
|
/**
|
|
* Check if a single task string is an issue reference (`#N`).
|
|
*/
|
|
export function isIssueReference(task: string): boolean {
|
|
return ISSUE_NUMBER_REGEX.test(task.trim());
|
|
}
|
|
|
|
/**
|
|
* Resolve issue references in a task string.
|
|
* If task contains `#N` patterns (space-separated), fetches issues and returns formatted text.
|
|
* Otherwise returns the task string as-is.
|
|
*
|
|
* Checks gh CLI availability before fetching.
|
|
* Throws if gh CLI is not available or issue fetch fails.
|
|
*/
|
|
export function resolveIssueTask(task: string): string {
|
|
const tokens = task.trim().split(/\s+/);
|
|
const issueNumbers = parseIssueNumbers(tokens);
|
|
|
|
if (issueNumbers.length === 0) {
|
|
return task;
|
|
}
|
|
|
|
const ghStatus = checkGhCli();
|
|
if (!ghStatus.available) {
|
|
throw new Error(ghStatus.error ?? 'gh CLI is not available');
|
|
}
|
|
|
|
log.info('Resolving issue references', { issueNumbers });
|
|
|
|
const issues = issueNumbers.map((n) => fetchIssue(n));
|
|
return issues.map(formatIssueAsTask).join('\n\n---\n\n');
|
|
}
|