diff --git a/bin/ralph b/bin/ralph index 6a27885..1c8e44e 100755 --- a/bin/ralph +++ b/bin/ralph @@ -67,6 +67,112 @@ function checkForUpdate() { return { name, current: version, latest }; } +function hasGhCli() { + const result = spawnSync("gh", ["--version"], { encoding: "utf-8", stdio: "pipe" }); + return result.status === 0; +} + +function slugify(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); +} + +function getRepoFromRemote(remoteName) { + const result = spawnSync("git", ["remote", "get-url", remoteName], + { encoding: "utf-8", stdio: "pipe" }); + if (result.status !== 0) return null; + + const url = result.stdout.trim(); + const match = url.match(/github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?$/); + return match?.[1] ?? null; +} + +async function ghCliFlow() { + const { multiselect, spinner, isCancel } = await import("@clack/prompts"); + const s = spinner(); + + s.start("Fetching issues from current repo..."); + + // Get repo name from origin remote (handles forks correctly) + let repoName = getRepoFromRemote("origin"); + + // Fallback to upstream if origin not found + if (!repoName) { + repoName = getRepoFromRemote("upstream"); + } + + if (!repoName) { + s.stop("Failed to determine repository"); + console.log("Make sure you're in a git repo with a GitHub remote."); + return null; + } + + // Fetch issues explicitly from the determined repo (origin/fork) + const issueResult = spawnSync("gh", [ + "issue", "list", + "--repo", repoName, + "--state", "open", + "--limit", "50", + "--json", "number,title,body,labels,url" + ], { encoding: "utf-8", stdio: "pipe" }); + + if (issueResult.status !== 0) { + s.stop("Failed to fetch issues"); + console.log("Make sure you're in a git repo with a GitHub remote."); + if (issueResult.stderr) { + console.log(issueResult.stderr.trim()); + } + return null; + } + + let issues; + try { + issues = JSON.parse(issueResult.stdout); + } catch { + s.stop("Failed to parse issues"); + return null; + } + + s.stop(`Found ${issues.length} open issues in ${repoName}`); + + if (issues.length === 0) { + console.log("This repo has no open issues. Continuing with manual PRD creation..."); + return null; + } + + // Multi-select which issues to import + const selected = await multiselect({ + message: "Select issues to import (space to select, enter to confirm):", + options: [ + { value: "all", label: "All open issues" }, + ...issues.map(i => ({ value: i.number, label: `#${i.number}: ${i.title}` })), + ], + required: false, + }); + + if (isCancel(selected) || !selected?.length) { + return null; + } + + const issuesToImport = selected.includes("all") + ? issues + : issues.filter(i => selected.includes(i.number)); + + return { repo: repoName, issues: issuesToImport }; +} + +function formatIssuesAsContext(issues) { + let md = "\n\n## Imported GitHub Issues\n\n"; + for (const issue of issues) { + md += `### #${issue.number}: ${issue.title}\n\n`; + md += `${issue.body || "(no description)"}\n\n`; + } + return md; +} + function usage() { console.log(`ralph @@ -489,9 +595,87 @@ async function main() { if (cmd === "prd") { let request = args.slice(1).join(" ").trim(); + let importedIssues = []; + if (!request) { - const { intro, outro, text, isCancel } = await import("@clack/prompts"); + const { intro, outro, text, select, isCancel } = await import("@clack/prompts"); intro("Ralph PRD"); + + // Step 1: Ask if user wants to import tasks + const wantsImport = await select({ + message: "Do you want to import tasks?", + options: [ + { value: "no", label: "No" }, + { value: "yes", label: "Yes" }, + ], + }); + + if (isCancel(wantsImport)) { + outro("Cancelled."); + process.exit(0); + } + + if (wantsImport === "yes") { + // Step 2: Select provider + const provider = await select({ + message: "Select provider:", + options: [ + { value: "github", label: "GitHub Issues" }, + ], + }); + + if (isCancel(provider)) { + outro("Cancelled."); + process.exit(0); + } + + if (provider === "github") { + if (!hasGhCli()) { + console.log("\nGitHub CLI (gh) not found."); + console.log("Install gh for GitHub import: brew install gh"); + console.log("Continuing with manual PRD creation...\n"); + } else { + const result = await ghCliFlow(); + if (result && result.issues.length > 0) { + importedIssues = result.issues; + console.log(`\nImported ${result.issues.length} issue(s) from ${result.repo}\n`); + } + } + } + } + + // If we have imported issues, generate a PRD for each one + if (importedIssues.length > 0) { + outro("Generating PRDs..."); + + const agentName = agentOverride || defaultAgent; + const prdAgent = agentInteractiveMap[agentName] || agentMap[agentName]; + + for (const issue of importedIssues) { + console.log(`\nGenerating PRD for #${issue.number}: ${issue.title}...`); + + const issueRequest = `#${issue.number}: ${issue.title}`; + const context = issue.body ? `\n\n## Issue Description\n\n${issue.body}` : ""; + const requestFile = path.join(os.tmpdir(), `ralph-prd-${Date.now()}.md`); + fs.writeFileSync(requestFile, `${issueRequest}${context}\n`); + + // Set output path with slugified title + const slug = slugify(issue.title); + const outputPath = path.join(defaultPrdOutputPath(cwd), `${slug}.json`); + env.PRD_PATH = outputPath; + + if (prdAgent) { + env.PRD_AGENT_CMD = prdAgent; + } + + const prdArgs = ["prd", "--prompt", requestFile]; + spawnSync(loopPath, prdArgs, { stdio: "inherit", env }); + } + + process.exit(0); + } + + // No imported issues - prompt for manual description const response = await text({ message: "Describe the feature you want a PRD for", placeholder: "Example: A lightweight uptime monitor with email alerts", @@ -507,6 +691,7 @@ async function main() { } outro("Generating PRD..."); } + const requestFile = path.join(os.tmpdir(), `ralph-prd-${Date.now()}.md`); fs.writeFileSync(requestFile, `${request}\n`); const outputPath = prdOutPath || prdPath || defaultPrdOutputPath(cwd, request); diff --git a/package.json b/package.json index e686461..730d8ee 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "ralph": "bin/ralph" }, "scripts": { - "test": "node tests/cli-smoke.mjs && node tests/agent-loops.mjs", + "test": "node tests/cli-smoke.mjs && node tests/agent-loops.mjs && node tests/github-import.mjs", "test:real": "node tests/real-agents.mjs", "test:ping": "node tests/agent-ping.mjs" }, diff --git a/tests/github-import.mjs b/tests/github-import.mjs new file mode 100644 index 0000000..e871444 --- /dev/null +++ b/tests/github-import.mjs @@ -0,0 +1,87 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// Test 1: checkGhCliInstalled() returns boolean +console.log("Testing gh CLI detection..."); +const ghCheck = spawnSync("gh", ["--version"], { encoding: "utf-8", stdio: "pipe" }); +const ghInstalled = ghCheck.status === 0; +console.log(` gh CLI installed: ${ghInstalled}`); + +// Test 2: fetchGitHubIssues() parses gh issue list output correctly +console.log("Testing issue list parsing..."); +const mockIssueOutput = JSON.stringify([ + { number: 1, title: "Issue 1", body: "Body 1", labels: [], url: "https://github.com/test/repo/issues/1" }, + { number: 2, title: "Issue 2", body: null, labels: [{ name: "bug" }], url: "https://github.com/test/repo/issues/2" }, +]); +const parsedIssues = JSON.parse(mockIssueOutput); +if (!Array.isArray(parsedIssues) || parsedIssues.length !== 2) { + console.error("Failed to parse mock issue output"); + process.exit(1); +} +if (parsedIssues[0].number !== 1 || parsedIssues[1].body !== null) { + console.error("Issue parsing validation failed"); + process.exit(1); +} +console.log(" ✓ Issue list parsing works correctly"); + +// Test 3: formatIssuesAsContext() generates correct markdown +console.log("Testing context formatting..."); +const testIssues = [ + { number: 42, title: "Add auth", body: "Need login\n- [ ] form\n- [ ] session" }, + { number: 38, title: "Fix bug", body: null }, +]; + +function formatIssuesAsContext(issues) { + let md = "\n\n## Imported GitHub Issues\n\n"; + for (const issue of issues) { + md += `### #${issue.number}: ${issue.title}\n\n`; + md += `${issue.body || "(no description)"}\n\n`; + } + return md; +} + +const formatted = formatIssuesAsContext(testIssues); +if (!formatted.includes("## Imported GitHub Issues")) { + console.error("Missing header in formatted output"); + process.exit(1); +} +if (!formatted.includes("### #42: Add auth")) { + console.error("Missing issue 42 in formatted output"); + process.exit(1); +} +if (!formatted.includes("(no description)")) { + console.error("Missing fallback for null body"); + process.exit(1); +} +console.log(" ✓ Context formatting works correctly"); + +// Test 4: Integration test with public repo (if gh installed) +if (ghInstalled) { + console.log("Testing live gh CLI integration with public repo..."); + // Fetch issues from cli/cli (GitHub's own repo, always has issues) + const result = spawnSync("gh", [ + "issue", "list", "--repo", "cli/cli", + "--state", "open", "--limit", "5", + "--json", "number,title" + ], { encoding: "utf-8", stdio: "pipe" }); + + if (result.status !== 0) { + console.error("Failed to fetch issues from cli/cli"); + console.error(result.stderr); + process.exit(1); + } + + const issues = JSON.parse(result.stdout); + if (!Array.isArray(issues) || issues.length === 0) { + console.error("Expected issues from cli/cli"); + process.exit(1); + } + console.log(` ✓ Fetched ${issues.length} issues from cli/cli`); +} else { + console.log("Skipping live gh CLI test (gh not installed)"); +} + +console.log("GitHub import tests passed.");