Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 186 additions & 1 deletion bin/ralph
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>

Expand Down Expand Up @@ -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",
Expand All @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
87 changes: 87 additions & 0 deletions tests/github-import.mjs
Original file line number Diff line number Diff line change
@@ -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.");