Skip to content

Commit a5f5192

Browse files
rubenmarcusclaude
andauthored
feat: interactive GitHub issues wizard (ralph-starter github) (#270)
* feat(wizard): add shared wizard utilities (credential prompt, browse-or-url) Shared utilities for integration wizards: - ensureCredentials(): check for existing auth, prompt for API key if missing - askBrowseOrUrl(): common "browse or paste URL" prompt - askForUrl(): URL input with domain validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(wizard): add GitHub interactive wizard command New `githubCommand()` that guides users through: 1. Authentication check (gh CLI or token prompt) 2. Browse repos/issues or paste a URL 3. Optional label filtering 4. Multi-select issues (checkbox) 5. Delegates to runCommand() for each selected issue Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(cli): register ralph-starter github command + run --from fallback - Register `ralph-starter github` as top-level command in cli.ts (same pattern as `ralph-starter figma`) - Add wizard fallback in run.ts: `--from github` without --project/--issue redirects to the interactive github wizard instead of erroring Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add GitHub wizard documentation and examples - Add "Interactive Wizard" section to docs/docs/sources/github.md - Add github/linear/notion wizard commands to README commands table Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: anchor regex patterns and use type over interface - Anchor GitHub URL regex patterns with ^https?:// (CodeQL fix) - Change interface to type for plain data structures (Greptile) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a602b47 commit a5f5192

5 files changed

Lines changed: 360 additions & 3 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -469,10 +469,11 @@ This creates:
469469
| `ralph-starter` | Launch interactive wizard |
470470
| `ralph-starter run [task]` | Run an autonomous coding loop |
471471
| `ralph-starter fix [task]` | Fix build errors, lint issues, or design problems |
472-
| `ralph-starter auto` | Batch-process issues from GitHub/Linear |
473-
| `ralph-starter task <action>` | Manage tasks across GitHub and Linear (list, create, update, close, comment) |
472+
| `ralph-starter github` | Interactive GitHub issues wizard |
474473
| `ralph-starter linear` | Interactive Linear issues wizard |
475474
| `ralph-starter notion` | Interactive Notion pages wizard |
475+
| `ralph-starter auto` | Batch-process issues from GitHub/Linear |
476+
| `ralph-starter task <action>` | Manage tasks across GitHub and Linear (list, create, update, close, comment) |
476477
| `ralph-starter integrations <action>` | Manage integrations (list, help, test, fetch) |
477478
| `ralph-starter plan` | Create implementation plan from specs |
478479
| `ralph-starter init` | Initialize Ralph Playbook in a project |

docs/docs/sources/github.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,28 @@ Required scopes:
3535
- `repo` (for private repositories)
3636
- `public_repo` (for public repositories only)
3737

38+
## Interactive Wizard
39+
40+
The easiest way to get started:
41+
42+
```bash
43+
ralph-starter github
44+
```
45+
46+
This will:
47+
1. Check your authentication (prompt for token if needed)
48+
2. Let you browse repositories and select issues
49+
3. Multi-select which issues to work on
50+
4. Start the build loop automatically
51+
52+
You can also pass options:
53+
54+
```bash
55+
ralph-starter github --commit --pr --validate
56+
```
57+
58+
If you prefer the CLI flags approach, use `ralph-starter run --from github` (see below).
59+
3860
## Usage
3961

4062
```bash

src/cli.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { checkCommand } from './commands/check.js';
88
import { configCommand } from './commands/config.js';
99
import { figmaCommand } from './commands/figma.js';
1010
import { fixCommand } from './commands/fix.js';
11+
import { githubCommand } from './commands/github.js';
1112
import { initCommand } from './commands/init.js';
1213
import { integrationsCommand } from './commands/integrations.js';
1314
import { linearCommand } from './commands/linear.js';
@@ -189,6 +190,28 @@ program
189190
});
190191
});
191192

193+
// ralph-starter github - Build from GitHub issues wizard
194+
program
195+
.command('github')
196+
.description('Build from GitHub issues with an interactive wizard')
197+
.option('--commit', 'Auto-commit after tasks')
198+
.option('--push', 'Push to remote')
199+
.option('--pr', 'Create PR when done')
200+
.option('--validate', 'Run validation', true)
201+
.option('--no-validate', 'Skip validation')
202+
.option('--max-iterations <n>', 'Max loop iterations')
203+
.option('--agent <name>', 'Agent to use')
204+
.action(async (options) => {
205+
await githubCommand({
206+
commit: options.commit,
207+
push: options.push,
208+
pr: options.pr,
209+
validate: options.validate,
210+
maxIterations: options.maxIterations ? parseInt(options.maxIterations, 10) : undefined,
211+
agent: options.agent,
212+
});
213+
});
214+
192215
// ralph-starter linear - Linear issues wizard
193216
program
194217
.command('linear')

src/commands/github.ts

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/**
2+
* ralph-starter github — Interactive GitHub issues wizard
3+
*
4+
* Guides the user through selecting GitHub issues to work on:
5+
* 1. Authenticate (gh CLI or token)
6+
* 2. Browse repos + issues or paste a URL
7+
* 3. Select issues (multi-select)
8+
* 4. Delegate to run command
9+
*/
10+
11+
import chalk from 'chalk';
12+
import inquirer from 'inquirer';
13+
import { askBrowseOrUrl, askForUrl, ensureCredentials } from '../integrations/wizards/shared.js';
14+
import { type RunCommandOptions, runCommand } from './run.js';
15+
16+
export type GitHubWizardOptions = {
17+
commit?: boolean;
18+
push?: boolean;
19+
pr?: boolean;
20+
validate?: boolean;
21+
maxIterations?: number;
22+
agent?: string;
23+
};
24+
25+
type GitHubRepo = {
26+
name: string;
27+
owner: { login: string };
28+
description: string;
29+
};
30+
31+
type GitHubIssue = {
32+
number: number;
33+
title: string;
34+
labels: Array<{ name: string }>;
35+
};
36+
37+
type GitHubLabel = {
38+
name: string;
39+
};
40+
41+
/** Check if gh CLI is available and authenticated */
42+
async function isGhCliAvailable(): Promise<boolean> {
43+
try {
44+
const { execa } = await import('execa');
45+
await execa('gh', ['auth', 'status']);
46+
return true;
47+
} catch {
48+
return false;
49+
}
50+
}
51+
52+
/** Fetch user's repos via gh CLI */
53+
async function fetchReposViaCli(limit = 30): Promise<GitHubRepo[]> {
54+
const { execa } = await import('execa');
55+
const result = await execa('gh', [
56+
'repo',
57+
'list',
58+
'--json',
59+
'name,owner,description',
60+
'--limit',
61+
String(limit),
62+
'--sort',
63+
'updated',
64+
]);
65+
return JSON.parse(result.stdout);
66+
}
67+
68+
/** Fetch open issues for a repo via gh CLI */
69+
async function fetchIssuesViaCli(
70+
owner: string,
71+
repo: string,
72+
label?: string,
73+
limit = 30
74+
): Promise<GitHubIssue[]> {
75+
const { execa } = await import('execa');
76+
const args = [
77+
'issue',
78+
'list',
79+
'-R',
80+
`${owner}/${repo}`,
81+
'--json',
82+
'number,title,labels',
83+
'--limit',
84+
String(limit),
85+
'--state',
86+
'open',
87+
];
88+
if (label) {
89+
args.push('--label', label);
90+
}
91+
const result = await execa('gh', args);
92+
return JSON.parse(result.stdout);
93+
}
94+
95+
/** Fetch labels for a repo via gh CLI */
96+
async function fetchLabelsViaCli(owner: string, repo: string): Promise<GitHubLabel[]> {
97+
const { execa } = await import('execa');
98+
const result = await execa('gh', [
99+
'label',
100+
'list',
101+
'-R',
102+
`${owner}/${repo}`,
103+
'--json',
104+
'name',
105+
'--limit',
106+
'50',
107+
]);
108+
return JSON.parse(result.stdout);
109+
}
110+
111+
/** Parse a GitHub URL into owner/repo and optional issue number */
112+
function parseGitHubUrl(url: string): { owner: string; repo: string; issue?: number } | null {
113+
// Match: https://github.com/owner/repo/issues/123
114+
const issueMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/issues\/(\d+)/);
115+
if (issueMatch) {
116+
return {
117+
owner: issueMatch[1],
118+
repo: issueMatch[2].replace(/\.git$/, ''),
119+
issue: parseInt(issueMatch[3], 10),
120+
};
121+
}
122+
123+
// Match: https://github.com/owner/repo
124+
const repoMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)/);
125+
if (repoMatch) {
126+
return {
127+
owner: repoMatch[1],
128+
repo: repoMatch[2].replace(/\.git$/, '').replace(/\/$/, ''),
129+
};
130+
}
131+
132+
return null;
133+
}
134+
135+
export async function githubCommand(options: GitHubWizardOptions): Promise<void> {
136+
console.log();
137+
console.log(chalk.cyan.bold(' GitHub Issues'));
138+
console.log(chalk.dim(' Build from GitHub issues interactively'));
139+
console.log();
140+
141+
// Step 1: Ensure credentials
142+
await ensureCredentials('github', 'GitHub', {
143+
credKey: 'token',
144+
consoleUrl: 'https://github.com/settings/tokens',
145+
envVar: 'GITHUB_TOKEN',
146+
checkCliAuth: isGhCliAvailable,
147+
});
148+
149+
// Step 2: Browse or URL?
150+
const mode = await askBrowseOrUrl('GitHub');
151+
152+
if (mode === 'url') {
153+
const url = await askForUrl('GitHub', /^https?:\/\/github\.com\//);
154+
const parsed = parseGitHubUrl(url);
155+
if (!parsed) {
156+
console.log(
157+
chalk.red(' Could not parse GitHub URL. Expected format: github.com/owner/repo')
158+
);
159+
return;
160+
}
161+
162+
const runOpts: RunCommandOptions = {
163+
from: 'github',
164+
project: `${parsed.owner}/${parsed.repo}`,
165+
issue: parsed.issue,
166+
auto: true,
167+
commit: options.commit ?? false,
168+
push: options.push,
169+
pr: options.pr,
170+
validate: options.validate ?? true,
171+
maxIterations: options.maxIterations,
172+
agent: options.agent,
173+
};
174+
175+
await runCommand(undefined, runOpts);
176+
return;
177+
}
178+
179+
// Browse mode
180+
// Step 3: Fetch and select repository
181+
console.log(chalk.dim(' Fetching your repositories...'));
182+
let repos: GitHubRepo[];
183+
try {
184+
repos = await fetchReposViaCli();
185+
} catch (err) {
186+
console.log(chalk.red(' Failed to fetch repositories. Check your authentication.'));
187+
console.log(chalk.dim(` Error: ${err instanceof Error ? err.message : String(err)}`));
188+
return;
189+
}
190+
191+
if (repos.length === 0) {
192+
console.log(chalk.yellow(' No repositories found.'));
193+
return;
194+
}
195+
196+
const { selectedRepo } = await inquirer.prompt([
197+
{
198+
type: 'select',
199+
name: 'selectedRepo',
200+
message: 'Select a repository:',
201+
choices: repos.map((r) => ({
202+
name: `${r.owner.login}/${r.name}${r.description ? chalk.dim(` — ${r.description.slice(0, 60)}`) : ''}`,
203+
value: `${r.owner.login}/${r.name}`,
204+
})),
205+
},
206+
]);
207+
208+
const [owner, repo] = selectedRepo.split('/');
209+
210+
// Step 4: Optional label filter
211+
let selectedLabel: string | undefined;
212+
try {
213+
const labels = await fetchLabelsViaCli(owner, repo);
214+
if (labels.length > 0) {
215+
const { labelChoice } = await inquirer.prompt([
216+
{
217+
type: 'select',
218+
name: 'labelChoice',
219+
message: 'Filter by label?',
220+
choices: [
221+
{ name: 'All issues (no filter)', value: '__none__' },
222+
...labels.map((l) => ({ name: l.name, value: l.name })),
223+
],
224+
},
225+
]);
226+
if (labelChoice !== '__none__') {
227+
selectedLabel = labelChoice;
228+
}
229+
}
230+
} catch {
231+
// Labels fetch failed, skip filter
232+
}
233+
234+
// Step 5: Fetch and select issues
235+
console.log(chalk.dim(` Fetching open issues for ${owner}/${repo}...`));
236+
let issues: GitHubIssue[];
237+
try {
238+
issues = await fetchIssuesViaCli(owner, repo, selectedLabel);
239+
} catch (err) {
240+
console.log(chalk.red(' Failed to fetch issues.'));
241+
console.log(chalk.dim(` Error: ${err instanceof Error ? err.message : String(err)}`));
242+
return;
243+
}
244+
245+
if (issues.length === 0) {
246+
console.log(
247+
chalk.yellow(
248+
` No open issues found${selectedLabel ? ` with label "${selectedLabel}"` : ''}.`
249+
)
250+
);
251+
return;
252+
}
253+
254+
const { selectedIssues } = await inquirer.prompt([
255+
{
256+
type: 'checkbox',
257+
name: 'selectedIssues',
258+
message: 'Select issues to work on:',
259+
choices: issues.map((issue) => {
260+
const labelTags =
261+
issue.labels.length > 0
262+
? ` ${chalk.dim(`[${issue.labels.map((l) => l.name).join(', ')}]`)}`
263+
: '';
264+
return {
265+
name: `#${issue.number}${issue.title}${labelTags}`,
266+
value: issue.number,
267+
};
268+
}),
269+
validate: (input: number[]) => (input.length > 0 ? true : 'Please select at least one issue'),
270+
},
271+
]);
272+
273+
// Step 6: Run for each selected issue
274+
console.log();
275+
console.log(
276+
chalk.green(
277+
` Starting build for ${selectedIssues.length} issue${selectedIssues.length > 1 ? 's' : ''}...`
278+
)
279+
);
280+
console.log();
281+
282+
for (const issueNumber of selectedIssues) {
283+
const runOpts: RunCommandOptions = {
284+
from: 'github',
285+
project: `${owner}/${repo}`,
286+
issue: issueNumber,
287+
label: selectedLabel,
288+
auto: true,
289+
commit: options.commit ?? false,
290+
push: options.push,
291+
pr: options.pr,
292+
validate: options.validate ?? true,
293+
maxIterations: options.maxIterations,
294+
agent: options.agent,
295+
};
296+
297+
await runCommand(undefined, runOpts);
298+
}
299+
}

0 commit comments

Comments
 (0)