diff --git a/README.md b/README.md index 3852a9c4..eeb9ce77 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,7 @@ This creates: | `ralph-starter run [task]` | Run an autonomous coding loop | | `ralph-starter fix [task]` | Fix build errors, lint issues, or design problems | | `ralph-starter auto` | Batch-process issues from GitHub/Linear | +| `ralph-starter task ` | Manage tasks across GitHub and Linear (list, create, update, close, comment) | | `ralph-starter integrations ` | Manage integrations (list, help, test, fetch) | | `ralph-starter plan` | Create implementation plan from specs | | `ralph-starter init` | Initialize Ralph Playbook in a project | diff --git a/docs/docs/cli/task.md b/docs/docs/cli/task.md new file mode 100644 index 00000000..f2936259 --- /dev/null +++ b/docs/docs/cli/task.md @@ -0,0 +1,181 @@ +--- +sidebar_position: 3 +title: task +description: Manage tasks across GitHub and Linear +keywords: [cli, task, command, github, linear, issues, unified, assignee] +--- + +# ralph-starter task + +Unified task management across GitHub and Linear. Issues stay where they are -- ralph-starter detects the platform from the ID format and routes operations accordingly. + +## Synopsis + +```bash +ralph-starter task list [options] +ralph-starter task create --title "..." [options] +ralph-starter task update [options] +ralph-starter task close [options] +ralph-starter task comment "message" [options] +``` + +## Description + +The `task` command provides a single interface for managing issues on both GitHub and Linear. Instead of switching between `gh issue` and Linear's UI, you can: + +- **List** tasks from both platforms in a unified table +- **Create** issues on either platform +- **Update** status, priority, or assignee +- **Close** issues with optional comments +- **Comment** on issues + +### Smart ID Detection + +ralph-starter automatically detects which platform an issue belongs to based on the ID format: + +| Format | Platform | Example | +|--------|----------|---------| +| `#123` or `123` | GitHub | `ralph-starter task close #42` | +| `TEAM-123` | Linear | `ralph-starter task update ENG-42 --status "In Progress"` | + +You can always override detection with `--source github` or `--source linear`. + +## Actions + +### `task list` + +List tasks from all configured integrations. + +```bash +# List from all sources +ralph-starter task list + +# List from GitHub only +ralph-starter task list --source github --project owner/repo + +# List from Linear only +ralph-starter task list --source linear + +# Filter by label and status +ralph-starter task list --label "bug" --status "open" --limit 10 +``` + +### `task create` + +Create a new issue on GitHub or Linear. + +```bash +# Create on GitHub (default) +ralph-starter task create --title "Add dark mode" --project owner/repo + +# Create on Linear +ralph-starter task create --title "Add dark mode" --source linear --priority P1 + +# Create with assignee and labels +ralph-starter task create --title "Fix auth bug" --source github \ + --project owner/repo --assignee octocat --label "bug,urgent" +``` + +### `task update` + +Update an existing issue. + +```bash +# Update status on Linear (auto-detected from ID) +ralph-starter task update ENG-42 --status "In Progress" + +# Assign a task +ralph-starter task update ENG-42 --assignee ruben + +# Update priority +ralph-starter task update ENG-42 --priority P0 + +# Update a GitHub issue +ralph-starter task update #123 --assignee octocat --project owner/repo +``` + +### `task close` + +Close an issue with an optional comment. + +```bash +# Close a Linear issue +ralph-starter task close ENG-42 + +# Close with a comment +ralph-starter task close #123 --comment "Fixed in PR #456" --project owner/repo +``` + +### `task comment` + +Add a comment to an issue. + +```bash +ralph-starter task comment ENG-42 "Working on this now" +ralph-starter task comment #123 "Needs design review" --project owner/repo +``` + +## Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--source ` | Filter by source: `github`, `linear`, or `all` | `all` (list) / `github` (create) | +| `--project ` | Project filter (`owner/repo` for GitHub, team name for Linear) | - | +| `--label ` | Filter by label or set labels (comma-separated) | - | +| `--status ` | Filter by status or set status on update | - | +| `--limit ` | Max tasks to fetch | `50` | +| `--title ` | Task title (for create) | - | +| `--body <body>` | Task description (for create) | - | +| `--priority <p>` | Priority: `P0`, `P1`, `P2`, `P3` | - | +| `--assignee <name>` | Assign to team member (GitHub username or Linear display name) | - | +| `--comment <text>` | Comment text (for close/update) | - | + +## Assignee Resolution + +### GitHub + +Pass a GitHub username directly: + +```bash +ralph-starter task update #123 --assignee octocat --project owner/repo +``` + +### Linear + +ralph-starter resolves display names, full names, and email prefixes to Linear user IDs: + +```bash +# Match by display name +ralph-starter task update ENG-42 --assignee "Ruben" + +# Match by email prefix +ralph-starter task update ENG-42 --assignee "ruben" +``` + +Matching is case-insensitive. If no match is found, ralph-starter shows available team members. + +## Prerequisites + +At least one integration must be configured: + +```bash +# GitHub (via GitHub CLI) +gh auth login + +# Linear (via API key) +ralph-starter config set linear.apiKey lin_api_xxxxxxxxxxxx +``` + +Check integration status: + +```bash +ralph-starter integrations list +``` + +## See Also + +- [`ralph-starter auto`](auto.md) -- Batch-process tasks autonomously +- [`ralph-starter integrations`](integrations.md) -- Manage integrations +- [`ralph-starter auth`](auth.md) -- Authenticate with services +- [GitHub Source](../sources/github.md) -- GitHub integration details +- [Linear Source](../sources/linear.md) -- Linear integration details diff --git a/docs/docs/sources/github.md b/docs/docs/sources/github.md index 8d5fb807..dd59adb0 100644 --- a/docs/docs/sources/github.md +++ b/docs/docs/sources/github.md @@ -175,6 +175,26 @@ Verify your authentication: ralph-starter source test github ``` +## Task Management + +Beyond fetching specs, you can create, update, and close GitHub issues directly from the CLI: + +```bash +# List open issues +ralph-starter task list --source github --project owner/repo + +# Create an issue +ralph-starter task create --title "Add dark mode" --project owner/repo --assignee octocat + +# Update an issue +ralph-starter task update #42 --assignee octocat --project owner/repo + +# Close an issue +ralph-starter task close #42 --comment "Fixed in PR #100" --project owner/repo +``` + +See [`ralph-starter task`](/docs/cli/task) for full details. + ## Tips 1. **Use labels effectively** - Create a "ready-to-build" or "ralph" label for issues that are well-specified diff --git a/docs/docs/sources/linear.md b/docs/docs/sources/linear.md index 5bc0b307..824d7ea4 100644 --- a/docs/docs/sources/linear.md +++ b/docs/docs/sources/linear.md @@ -136,6 +136,31 @@ Create these labels in Linear: 2. Add comments with build progress 3. Close issues when build succeeds +## Task Management + +Beyond fetching specs, you can create, update, close, and assign Linear issues from the CLI: + +```bash +# List issues from Linear +ralph-starter task list --source linear + +# Create an issue on a specific team +ralph-starter task create --title "Add dark mode" --source linear --priority P1 + +# Assign an issue (resolves display name to user ID automatically) +ralph-starter task update ENG-42 --assignee ruben + +# Update status +ralph-starter task update ENG-42 --status "In Progress" + +# Close an issue +ralph-starter task close ENG-42 --comment "Shipped in v1.2" +``` + +Assignee resolution is case-insensitive and matches against display name, full name, and email prefix. If no match is found, ralph-starter shows available team members. + +See [`ralph-starter task`](/docs/cli/task) for full details. + ## Tips 1. **Write detailed issues** - Linear's rich markdown support is perfect for detailed specs diff --git a/docs/sidebars.ts b/docs/sidebars.ts index 2f1b4f30..f1de8a6c 100644 --- a/docs/sidebars.ts +++ b/docs/sidebars.ts @@ -46,6 +46,7 @@ const sidebars: SidebarsConfig = { 'cli/config', 'cli/source', 'cli/auto', + 'cli/task', 'cli/auth', 'cli/setup', 'cli/check', diff --git a/src/cli.ts b/src/cli.ts index 4e99bdcf..df00bd8a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,6 +16,7 @@ import { runCommand } from './commands/run.js'; import { setupCommand } from './commands/setup.js'; import { skillCommand } from './commands/skill.js'; import { sourceCommand } from './commands/source.js'; +import { taskCommand } from './commands/task.js'; import { templateCommand } from './commands/template.js'; import { startMcpServer } from './mcp/server.js'; import { formatPresetsHelp, getPresetNames } from './presets/index.js'; @@ -61,7 +62,7 @@ program .option('--docker', 'Run in Docker sandbox (coming soon)') .option('--prd <file>', 'Read tasks from a PRD markdown file') .option('--max-iterations <n>', 'Maximum loop iterations (auto-calculated if not specified)') - .option('--agent <name>', 'Specify agent (claude-code, cursor, codex, opencode)') + .option('--agent <name>', 'Specify agent (claude-code, cursor, codex, opencode, openclaw)') .option('--from <source>', 'Fetch spec from source (file, url, github, todoist, linear, notion)') .option('--project <name>', 'Project/repo name for --from integrations') .option('--label <name>', 'Label filter for --from integrations') @@ -275,6 +276,24 @@ program }); }); +// ralph-starter task - Manage tasks across GitHub and Linear +program + .command('task [action] [args...]') + .description('Manage tasks across GitHub and Linear (list, create, update, close, comment)') + .option('--source <source>', 'Source: github, linear, or all (default: all)') + .option('--project <name>', 'Project filter (owner/repo for GitHub, team name for Linear)') + .option('--label <name>', 'Filter by label') + .option('--status <status>', 'Filter by status or set status on update') + .option('--limit <n>', 'Max tasks to fetch (default: 50)', '50') + .option('--title <title>', 'Task title (for create)') + .option('--body <body>', 'Task description (for create)') + .option('--priority <p>', 'Priority: P0, P1, P2, P3') + .option('--comment <text>', 'Comment text (for close/update)') + .option('--assignee <name>', 'Assign to team member (GitHub username or Linear display name)') + .action(async (action: string | undefined, args: string[], options) => { + await taskCommand(action, args, options); + }); + // ralph-starter pause - Pause a running session program .command('pause') @@ -324,7 +343,7 @@ program .option('--pr', 'Create a pull request when done') .option('--validate', 'Run tests/lint/build after each iteration') .option('--max-iterations <n>', 'Maximum loop iterations') - .option('--agent <name>', 'Specify agent (claude-code, cursor, codex, opencode)') + .option('--agent <name>', 'Specify agent (claude-code, cursor, codex, opencode, openclaw)') .action(async (action: string | undefined, args: string[], options) => { await templateCommand(action, args, options); }); diff --git a/src/commands/task.ts b/src/commands/task.ts new file mode 100644 index 00000000..798b9828 --- /dev/null +++ b/src/commands/task.ts @@ -0,0 +1,356 @@ +/** + * Task Command + * + * Unified task management across GitHub and Linear. + * Issues stay where they are — ralph-starter detects the platform + * from the ID format and routes operations accordingly. + */ + +import chalk from 'chalk'; + +import { + type IntegrationOptions, + isWritableIntegration, + type TaskReference, + type WritableIntegration, +} from '../integrations/base.js'; + +type TaskSource = 'github' | 'linear' | 'all'; + +interface TaskCommandOptions { + source?: TaskSource; + project?: string; + label?: string; + status?: string; + limit?: string; + title?: string; + body?: string; + priority?: string; + comment?: string; + assignee?: string; +} + +/** + * Detect source from task identifier format: + * - "#123" or bare number → GitHub + * - "RAL-42" (TEAM-number) → Linear + */ +function detectSource(id: string): TaskSource { + // Bare number or #number → GitHub + if (/^#?\d+$/.test(id)) return 'github'; + // TEAM-number pattern → Linear + if (/^[A-Z]+-\d+$/i.test(id)) return 'linear'; + // UUID-like → Linear + if (id.includes('-') && id.length > 10) return 'linear'; + return 'all'; +} + +/** + * Normalize a task ID (strip # prefix for GitHub) + */ +function normalizeId(id: string): string { + return id.replace(/^#/, ''); +} + +/** + * Get writable integrations for the requested source(s) + */ +async function getIntegrations(source: TaskSource): Promise<WritableIntegration[]> { + const integrations: WritableIntegration[] = []; + + if (source === 'github' || source === 'all') { + const { GitHubIntegration } = await import('../integrations/github/source.js'); + const gh = new GitHubIntegration(); + if (isWritableIntegration(gh) && (await gh.isAvailable())) { + integrations.push(gh); + } + } + + if (source === 'linear' || source === 'all') { + const { LinearIntegration } = await import('../integrations/linear/source.js'); + const linear = new LinearIntegration(); + if (isWritableIntegration(linear) && (await linear.isAvailable())) { + integrations.push(linear); + } + } + + return integrations; +} + +/** + * Map priority string (P0-P3) to number (1-4) + */ +function parsePriority(p?: string): number | undefined { + if (!p) return undefined; + const map: Record<string, number> = { P0: 1, P1: 2, P2: 3, P3: 4 }; + return map[p.toUpperCase()]; +} + +/** + * Map priority number to display string + */ +function priorityLabel(p?: number): string { + if (!p) return ''; + const map: Record<number, string> = { 1: 'P0', 2: 'P1', 3: 'P2', 4: 'P3' }; + return map[p] || ''; +} + +/** + * Print tasks as a table + */ +function printTaskTable(tasks: TaskReference[]): void { + if (tasks.length === 0) { + console.log(chalk.yellow('\n No tasks found.\n')); + return; + } + + console.log(); + const header = ` ${'ID'.padEnd(14)} ${'Source'.padEnd(8)} ${'Status'.padEnd(14)} ${'Priority'.padEnd(8)} Title`; + console.log(chalk.bold(header)); + console.log(chalk.dim(` ${'─'.repeat(80)}`)); + + for (const task of tasks) { + const sourceColor = task.source === 'github' ? chalk.cyan : chalk.magenta; + const line = ` ${task.identifier.padEnd(14)} ${sourceColor(task.source.padEnd(8))} ${task.status.padEnd(14)} ${priorityLabel(task.priority).padEnd(8)} ${task.title}`; + console.log(line); + } + + console.log(chalk.dim(`\n ${tasks.length} task(s) total\n`)); +} + +// ============================================ +// Command handlers +// ============================================ + +async function handleList(options: TaskCommandOptions): Promise<void> { + const source: TaskSource = options.source || 'all'; + const integrations = await getIntegrations(source); + + if (integrations.length === 0) { + console.log(chalk.red('\n No integrations available. Configure GitHub or Linear first.\n')); + console.log(' Run: ralph-starter auth github'); + console.log(' Run: ralph-starter config set linear.apiKey <key>\n'); + return; + } + + const allTasks: TaskReference[] = []; + const intOpts: IntegrationOptions = { + project: options.project, + label: options.label, + status: options.status, + limit: options.limit ? parseInt(options.limit, 10) : 50, + }; + + for (const integration of integrations) { + try { + const tasks = await integration.listTasks(intOpts); + allTasks.push(...tasks); + } catch (err) { + console.log( + chalk.yellow( + ` Warning: Could not fetch from ${integration.name}: ${(err as Error).message}` + ) + ); + } + } + + // Sort by priority (lower = higher priority), then by source + allTasks.sort((a, b) => (a.priority || 99) - (b.priority || 99)); + + printTaskTable(allTasks); +} + +async function handleCreate(options: TaskCommandOptions): Promise<void> { + if (!options.title) { + console.log(chalk.red('\n --title is required for creating a task.\n')); + return; + } + + const source: TaskSource = options.source || 'github'; + if (source === 'all') { + console.log(chalk.red('\n Specify --source github or --source linear for creating tasks.\n')); + return; + } + + const integrations = await getIntegrations(source); + if (integrations.length === 0) { + console.log(chalk.red(`\n ${source} integration not available. Configure it first.\n`)); + return; + } + + const integration = integrations[0]; + const task = await integration.createTask( + { + title: options.title, + description: options.body, + labels: options.label ? options.label.split(',') : undefined, + priority: parsePriority(options.priority), + assignee: options.assignee, + project: options.project, + }, + { project: options.project } + ); + + console.log(chalk.green(`\n Created: ${task.identifier} — ${task.title}`)); + console.log(chalk.dim(` ${task.url}\n`)); +} + +async function handleUpdate(id: string, options: TaskCommandOptions): Promise<void> { + const source = options.source || detectSource(id); + if (source === 'all') { + console.log( + chalk.red('\n Could not detect source from ID. Use --source github or --source linear.\n') + ); + return; + } + + const integrations = await getIntegrations(source as TaskSource); + if (integrations.length === 0) { + console.log(chalk.red(`\n ${source} integration not available.\n`)); + return; + } + + const integration = integrations[0]; + const task = await integration.updateTask( + normalizeId(id), + { + status: options.status, + comment: options.comment, + priority: parsePriority(options.priority), + assignee: options.assignee, + }, + { project: options.project } + ); + + console.log(chalk.green(`\n Updated: ${task.identifier} — ${task.status}`)); + console.log(chalk.dim(` ${task.url}\n`)); +} + +async function handleClose(id: string, options: TaskCommandOptions): Promise<void> { + const source = options.source || detectSource(id); + if (source === 'all') { + console.log( + chalk.red('\n Could not detect source from ID. Use --source github or --source linear.\n') + ); + return; + } + + const integrations = await getIntegrations(source as TaskSource); + if (integrations.length === 0) { + console.log(chalk.red(`\n ${source} integration not available.\n`)); + return; + } + + const integration = integrations[0]; + await integration.closeTask(normalizeId(id), options.comment, { project: options.project }); + + console.log(chalk.green(`\n Closed: ${id}`)); + if (options.comment) { + console.log(chalk.dim(` Comment: ${options.comment}`)); + } + console.log(); +} + +async function handleComment( + id: string, + message: string, + options: TaskCommandOptions +): Promise<void> { + const source = options.source || detectSource(id); + if (source === 'all') { + console.log( + chalk.red('\n Could not detect source from ID. Use --source github or --source linear.\n') + ); + return; + } + + const integrations = await getIntegrations(source as TaskSource); + if (integrations.length === 0) { + console.log(chalk.red(`\n ${source} integration not available.\n`)); + return; + } + + const integration = integrations[0]; + await integration.addComment(normalizeId(id), message, { project: options.project }); + + console.log(chalk.green(`\n Comment added to ${id}\n`)); +} + +/** + * Main task command dispatcher + */ +export async function taskCommand( + action: string | undefined, + args: string[], + options: TaskCommandOptions +): Promise<void> { + try { + switch (action) { + case 'list': + case 'ls': + await handleList(options); + break; + + case 'create': + case 'new': + await handleCreate(options); + break; + + case 'update': + if (!args[0]) { + console.log(chalk.red('\n Usage: ralph-starter task update <id> --status <status>\n')); + return; + } + await handleUpdate(args[0], options); + break; + + case 'close': + case 'done': + if (!args[0]) { + console.log(chalk.red('\n Usage: ralph-starter task close <id> [--comment "..."]\n')); + return; + } + await handleClose(args[0], options); + break; + + case 'comment': + if (!args[0] || !args[1]) { + console.log(chalk.red('\n Usage: ralph-starter task comment <id> "message"\n')); + return; + } + await handleComment(args[0], args.slice(1).join(' '), options); + break; + + default: + console.log(` + ${chalk.bold('ralph-starter task')} — Manage tasks across GitHub and Linear + + ${chalk.bold('Usage:')} + ralph-starter task list [--source github|linear] [--project <name>] + ralph-starter task create --title "..." [--source github|linear] [--priority P0-P3] + ralph-starter task update <id> --status <status> + ralph-starter task close <id> [--comment "..."] + ralph-starter task comment <id> "message" + + ${chalk.bold('ID Detection:')} + #123 or 123 → GitHub issue + RAL-42 → Linear issue (detected from TEAM-number format) + + ${chalk.bold('Options:')} + --source Filter by source: github, linear, or all (default: all) + --project Project filter (owner/repo for GitHub, team name for Linear) + --label Filter by label + --status Filter by status or set status on update + --limit Max tasks to fetch (default: 50) + --title Task title (for create) + --body Task description (for create) + --priority Priority: P0, P1, P2, P3 (for create/update) + --assignee Assign to team member (GitHub username or Linear display name) + --comment Comment text (for close/update) +`); + break; + } + } catch (err) { + console.log(chalk.red(`\n Error: ${(err as Error).message}\n`)); + } +} diff --git a/src/integrations/base.ts b/src/integrations/base.ts index 261329eb..94823428 100644 --- a/src/integrations/base.ts +++ b/src/integrations/base.ts @@ -113,6 +113,77 @@ export interface Integration { getInfo(): Promise<IntegrationInfo>; } +// ============================================ +// Writable Integration (bidirectional task management) +// ============================================ + +/** + * Input for creating a task on GitHub/Linear + */ +export interface TaskCreateInput { + title: string; + description?: string; + labels?: string[]; + /** Priority: 1=Urgent, 2=High, 3=Medium, 4=Low */ + priority?: number; + assignee?: string; + project?: string; +} + +/** + * Input for updating a task + */ +export interface TaskUpdateInput { + status?: string; + comment?: string; + labels?: string[]; + priority?: number; + assignee?: string; +} + +/** + * Reference to a task on any platform + */ +export interface TaskReference { + id: string; + /** Display identifier: "#123" (GitHub) or "RAL-42" (Linear) */ + identifier: string; + title: string; + url: string; + status: string; + source: 'github' | 'linear'; + priority?: number; + labels?: string[]; +} + +/** + * Writable Integration Interface + * Extends Integration with task management (create, update, close, comment) + */ +export interface WritableIntegration extends Integration { + createTask(input: TaskCreateInput, options?: IntegrationOptions): Promise<TaskReference>; + updateTask( + id: string, + input: TaskUpdateInput, + options?: IntegrationOptions + ): Promise<TaskReference>; + closeTask(id: string, comment?: string, options?: IntegrationOptions): Promise<void>; + addComment(id: string, body: string, options?: IntegrationOptions): Promise<void>; + listTasks(options?: IntegrationOptions): Promise<TaskReference[]>; + readonly supportsWrite: true; +} + +/** + * Type guard: check if an integration supports write operations + */ +export function isWritableIntegration( + integration: Integration +): integration is WritableIntegration { + return ( + 'supportsWrite' in integration && (integration as WritableIntegration).supportsWrite === true + ); +} + /** * Base class for integrations * Provides common functionality diff --git a/src/integrations/github/source.ts b/src/integrations/github/source.ts index 17cc47b7..07a19cbf 100644 --- a/src/integrations/github/source.ts +++ b/src/integrations/github/source.ts @@ -10,6 +10,10 @@ import { BaseIntegration, type IntegrationOptions, type IntegrationResult, + type TaskCreateInput, + type TaskReference, + type TaskUpdateInput, + type WritableIntegration, } from '../base.js'; interface GitHubIssue { @@ -20,7 +24,8 @@ interface GitHubIssue { labels?: Array<string | { name: string }>; } -export class GitHubIntegration extends BaseIntegration { +export class GitHubIntegration extends BaseIntegration implements WritableIntegration { + readonly supportsWrite = true as const; name = 'github'; displayName = 'GitHub'; description = 'Fetch issues from GitHub repositories'; @@ -208,6 +213,166 @@ export class GitHubIntegration extends BaseIntegration { }; } + // ============================================ + // WritableIntegration methods + // ============================================ + + async listTasks(options?: IntegrationOptions): Promise<TaskReference[]> { + const project = options?.project; + if (!project) { + this.error('Project (owner/repo) is required for listing GitHub tasks'); + } + + const { execa } = await import('execa'); + const args = [ + 'issue', + 'list', + '-R', + project, + '--json', + 'number,title,state,url,labels', + '--state', + options?.status || 'open', + '--limit', + String(options?.limit || 50), + ]; + if (options?.label) args.push('--label', options.label); + + const result = await execa('gh', args); + const issues = JSON.parse(result.stdout) as Array<{ + number: number; + title: string; + state: string; + url: string; + labels?: Array<{ name: string }>; + }>; + + return issues.map((issue) => ({ + id: String(issue.number), + identifier: `#${issue.number}`, + title: issue.title, + url: issue.url, + status: issue.state, + source: 'github' as const, + labels: issue.labels?.map((l) => l.name), + })); + } + + async createTask(input: TaskCreateInput, options?: IntegrationOptions): Promise<TaskReference> { + const project = input.project || options?.project; + if (!project) { + this.error('Project (owner/repo) is required for creating GitHub issues'); + } + + const { execa } = await import('execa'); + const args = ['issue', 'create', '-R', project, '--title', input.title]; + + if (input.description) args.push('--body', input.description); + if (input.labels && input.labels.length > 0) { + args.push('--label', input.labels.join(',')); + } + if (input.assignee) args.push('--assignee', input.assignee); + + const result = await execa('gh', args); + // gh issue create outputs the URL of the new issue + const url = result.stdout.trim(); + const numberMatch = url.match(/\/issues\/(\d+)$/); + const number = numberMatch ? numberMatch[1] : '0'; + + return { + id: number, + identifier: `#${number}`, + title: input.title, + url, + status: 'open', + source: 'github', + labels: input.labels, + }; + } + + async updateTask( + id: string, + input: TaskUpdateInput, + options?: IntegrationOptions + ): Promise<TaskReference> { + const project = options?.project; + if (!project) { + this.error('Project (owner/repo) is required for updating GitHub issues'); + } + + const { execa } = await import('execa'); + + if (input.labels && input.labels.length > 0) { + await execa('gh', [ + 'issue', + 'edit', + id, + '-R', + project, + '--add-label', + input.labels.join(','), + ]); + } + + if (input.assignee) { + await execa('gh', ['issue', 'edit', id, '-R', project, '--add-assignee', input.assignee]); + } + + if (input.status) { + const stateFlag = input.status.toLowerCase() === 'closed' ? 'closed' : 'open'; + await execa('gh', ['issue', 'edit', id, '-R', project, '--state', stateFlag]); + } + + if (input.comment) { + await execa('gh', ['issue', 'comment', id, '-R', project, '--body', input.comment]); + } + + // Fetch updated issue to return current state + const result = await execa('gh', [ + 'issue', + 'view', + id, + '-R', + project, + '--json', + 'number,title,state,url,labels', + ]); + const issue = JSON.parse(result.stdout); + + return { + id: String(issue.number), + identifier: `#${issue.number}`, + title: issue.title, + url: issue.url, + status: issue.state, + source: 'github', + labels: issue.labels?.map((l: { name: string }) => l.name), + }; + } + + async closeTask(id: string, comment?: string, options?: IntegrationOptions): Promise<void> { + const project = options?.project; + if (!project) { + this.error('Project (owner/repo) is required for closing GitHub issues'); + } + + const { execa } = await import('execa'); + const args = ['issue', 'close', id, '-R', project]; + if (comment) args.push('--comment', comment); + + await execa('gh', args); + } + + async addComment(id: string, body: string, options?: IntegrationOptions): Promise<void> { + const project = options?.project; + if (!project) { + this.error('Project (owner/repo) is required for commenting on GitHub issues'); + } + + const { execa } = await import('execa'); + await execa('gh', ['issue', 'comment', id, '-R', project, '--body', body]); + } + getHelp(): string { return ` github: Fetch issues from GitHub repositories diff --git a/src/integrations/linear/source.ts b/src/integrations/linear/source.ts index 9f143b5a..9eef7524 100644 --- a/src/integrations/linear/source.ts +++ b/src/integrations/linear/source.ts @@ -10,12 +10,17 @@ import { BaseIntegration, type IntegrationOptions, type IntegrationResult, + type TaskCreateInput, + type TaskReference, + type TaskUpdateInput, + type WritableIntegration, } from '../base.js'; interface LinearIssue { id: string; identifier: string; title: string; + url: string; description: string | null; priority: number; priorityLabel: string | null; @@ -44,7 +49,8 @@ interface LinearResponse { errors?: Array<{ message: string }>; } -export class LinearIntegration extends BaseIntegration { +export class LinearIntegration extends BaseIntegration implements WritableIntegration { + readonly supportsWrite = true as const; name = 'linear'; displayName = 'Linear'; description = 'Fetch issues from Linear'; @@ -149,6 +155,7 @@ export class LinearIntegration extends BaseIntegration { id identifier title + url description priority priorityLabel @@ -291,6 +298,396 @@ export class LinearIntegration extends BaseIntegration { }; } + // ============================================ + // WritableIntegration methods + // ============================================ + + private async getAuthKey(): Promise<string> { + const authMethod = await this.getConfiguredAuthMethod(); + if (authMethod === 'cli') { + return this.getApiKeyFromCli(); + } + return this.getApiKey(); + } + + private async graphqlMutation( + apiKey: string, + query: string, + variables: Record<string, unknown> + ): Promise<Record<string, unknown>> { + const response = await fetch(this.API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: apiKey, + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + this.error(`Linear API error: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as { + data: Record<string, unknown>; + errors?: Array<{ message: string }>; + }; + if (data.errors) { + this.error(`Linear API error: ${data.errors[0].message}`); + } + return data.data; + } + + async listTasks(options?: IntegrationOptions): Promise<TaskReference[]> { + const apiKey = await this.getAuthKey(); + const projectName = options?.project || 'all'; + const issues = await this.fetchIssues(apiKey, projectName, options); + + return issues.map((issue) => ({ + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + status: issue.state?.name || 'Unknown', + source: 'linear' as const, + priority: issue.priority, + labels: issue.labels?.nodes?.map((l) => l.name), + })); + } + + async createTask(input: TaskCreateInput, options?: IntegrationOptions): Promise<TaskReference> { + const apiKey = await this.getAuthKey(); + + // First, resolve team ID from project name + const teamId = await this.resolveTeamId(apiKey, input.project || options?.project); + + const mutationInput: Record<string, unknown> = { + title: input.title, + teamId, + }; + + if (input.description) mutationInput.description = input.description; + if (input.priority) mutationInput.priority = input.priority; + + // Resolve label IDs if provided + if (input.labels && input.labels.length > 0) { + const labelIds = await this.resolveLabelIds(apiKey, teamId, input.labels); + if (labelIds.length > 0) mutationInput.labelIds = labelIds; + } + + // Resolve assignee ID if provided + if (input.assignee) { + mutationInput.assigneeId = await this.resolveAssigneeId(apiKey, input.assignee); + } + + const query = ` + mutation CreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + title + url + state { name } + priority + } + } + } + `; + + const data = await this.graphqlMutation(apiKey, query, { input: mutationInput }); + const result = data.issueCreate as { + success: boolean; + issue: { + id: string; + identifier: string; + title: string; + url: string; + state: { name: string }; + priority: number; + }; + }; + + if (!result.success) { + this.error('Failed to create Linear issue'); + } + + return { + id: result.issue.id, + identifier: result.issue.identifier, + title: result.issue.title, + url: result.issue.url, + status: result.issue.state.name, + source: 'linear', + priority: result.issue.priority, + labels: input.labels, + }; + } + + async updateTask( + id: string, + input: TaskUpdateInput, + options?: IntegrationOptions + ): Promise<TaskReference> { + const apiKey = await this.getAuthKey(); + + // Resolve the issue ID — could be identifier (RAL-42) or UUID + const issueId = await this.resolveIssueId(apiKey, id); + + const updateInput: Record<string, unknown> = {}; + + if (input.status) { + const stateId = await this.resolveStateId(apiKey, issueId, input.status); + if (stateId) updateInput.stateId = stateId; + } + + if (input.priority !== undefined) { + updateInput.priority = input.priority; + } + + if (input.assignee) { + updateInput.assigneeId = await this.resolveAssigneeId(apiKey, input.assignee); + } + + if (Object.keys(updateInput).length > 0) { + const query = ` + mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { + id + identifier + title + url + state { name } + priority + } + } + } + `; + + await this.graphqlMutation(apiKey, query, { id: issueId, input: updateInput }); + } + + if (input.comment) { + await this.addComment(id, input.comment, options); + } + + // Fetch updated issue + return await this.getIssueRef(apiKey, issueId); + } + + async closeTask(id: string, comment?: string, options?: IntegrationOptions): Promise<void> { + const apiKey = await this.getAuthKey(); + const issueId = await this.resolveIssueId(apiKey, id); + + // Find the "Done" state for this issue's team + const doneStateId = await this.resolveStateId(apiKey, issueId, 'Done'); + if (doneStateId) { + const query = ` + mutation CloseIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { success } + } + `; + await this.graphqlMutation(apiKey, query, { id: issueId, input: { stateId: doneStateId } }); + } + + if (comment) { + await this.addComment(id, comment, options); + } + } + + async addComment(id: string, body: string, _options?: IntegrationOptions): Promise<void> { + const apiKey = await this.getAuthKey(); + const issueId = await this.resolveIssueId(apiKey, id); + + const query = ` + mutation AddComment($input: CommentCreateInput!) { + commentCreate(input: $input) { success } + } + `; + + await this.graphqlMutation(apiKey, query, { input: { issueId, body } }); + } + + // ============================================ + // Helper resolvers for Linear GraphQL + // ============================================ + + private async resolveTeamId(apiKey: string, projectOrTeam?: string): Promise<string> { + const query = ` + query GetTeams { + teams { nodes { id name key } } + } + `; + const data = await this.graphqlMutation(apiKey, query, {}); + const teams = (data.teams as { nodes: Array<{ id: string; name: string; key: string }> }).nodes; + + if (teams.length === 0) { + this.error('No teams found in your Linear workspace'); + } + + if (projectOrTeam) { + const match = teams.find( + (t) => + t.name.toLowerCase() === projectOrTeam.toLowerCase() || + t.key.toLowerCase() === projectOrTeam.toLowerCase() + ); + if (match) return match.id; + } + + // Default to first team + return teams[0].id; + } + + private async resolveLabelIds( + apiKey: string, + teamId: string, + labels: string[] + ): Promise<string[]> { + const query = ` + query GetLabels($teamId: ID!) { + team(id: $teamId) { + labels { nodes { id name } } + } + } + `; + const data = await this.graphqlMutation(apiKey, query, { teamId }); + const existingLabels = (data.team as { labels: { nodes: Array<{ id: string; name: string }> } }) + .labels.nodes; + + return labels + .map((name) => existingLabels.find((l) => l.name.toLowerCase() === name.toLowerCase())?.id) + .filter((id): id is string => !!id); + } + + private async resolveIssueId(apiKey: string, idOrIdentifier: string): Promise<string> { + // Linear's issue(id:) accepts both UUIDs and identifiers (e.g., "ENG-42") + // Verify the issue exists and return its UUID + const query = ` + query GetIssue($id: String!) { + issue(id: $id) { id } + } + `; + try { + const data = await this.graphqlMutation(apiKey, query, { id: idOrIdentifier }); + return (data.issue as { id: string }).id; + } catch { + this.error(`Linear issue not found: ${idOrIdentifier}`); + } + } + + private async resolveStateId( + apiKey: string, + issueId: string, + stateName: string + ): Promise<string | null> { + const query = ` + query GetIssueTeam($id: String!) { + issue(id: $id) { + team { + states { nodes { id name type } } + } + } + } + `; + const data = await this.graphqlMutation(apiKey, query, { id: issueId }); + const states = ( + data.issue as { + team: { states: { nodes: Array<{ id: string; name: string; type: string }> } }; + } + ).team.states.nodes; + + const match = states.find((s) => s.name.toLowerCase() === stateName.toLowerCase()); + if (match) return match.id; + + // Try matching by type (e.g., "completed" type for "Done") + if (stateName.toLowerCase() === 'done') { + const completed = states.find((s) => s.type === 'completed'); + if (completed) return completed.id; + } + + return null; + } + + private async resolveAssigneeId(apiKey: string, assigneeName: string): Promise<string> { + const query = ` + query GetUsers { + users { nodes { id name displayName email active } } + } + `; + const data = await this.graphqlMutation(apiKey, query, {}); + const users = ( + data.users as { + nodes: Array<{ + id: string; + name: string; + displayName: string; + email: string; + active: boolean; + }>; + } + ).nodes.filter((u) => u.active); + + const needle = assigneeName.toLowerCase(); + + // Exact match: name, displayName, or email prefix + const exact = users.find( + (u) => + u.name.toLowerCase() === needle || + u.displayName.toLowerCase() === needle || + u.email.toLowerCase().split('@')[0] === needle + ); + if (exact) return exact.id; + + // Partial match: contains + const partial = users.find( + (u) => + u.name.toLowerCase().includes(needle) || + u.displayName.toLowerCase().includes(needle) || + u.email.toLowerCase().includes(needle) + ); + if (partial) return partial.id; + + const available = users.map((u) => u.displayName || u.name).join(', '); + this.error(`No Linear user matching "${assigneeName}". Available members: ${available}`); + } + + private async getIssueRef(apiKey: string, issueId: string): Promise<TaskReference> { + const query = ` + query GetIssue($id: String!) { + issue(id: $id) { + id identifier title url + state { name } + priority + labels { nodes { name } } + } + } + `; + const data = await this.graphqlMutation(apiKey, query, { id: issueId }); + const issue = data.issue as { + id: string; + identifier: string; + title: string; + url: string; + state: { name: string }; + priority: number; + labels: { nodes: Array<{ name: string }> }; + }; + + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + status: issue.state.name, + source: 'linear', + priority: issue.priority, + labels: issue.labels.nodes.map((l) => l.name), + }; + } + getHelp(): string { return ` linear: Fetch issues from Linear diff --git a/src/loop/__tests__/agents.test.ts b/src/loop/__tests__/agents.test.ts index e8294c0f..ba6fc02f 100644 --- a/src/loop/__tests__/agents.test.ts +++ b/src/loop/__tests__/agents.test.ts @@ -86,11 +86,12 @@ describe('agents', () => { .mockResolvedValueOnce({ stdout: '1.0.0', exitCode: 0 } as any) // claude-code .mockRejectedValueOnce(new Error('not found')) // cursor .mockRejectedValueOnce(new Error('not found')) // codex - .mockRejectedValueOnce(new Error('not found')); // opencode + .mockRejectedValueOnce(new Error('not found')) // opencode + .mockRejectedValueOnce(new Error('not found')); // openclaw const agents = await detectAvailableAgents(); - expect(agents).toHaveLength(4); + expect(agents).toHaveLength(5); expect(agents.find((a) => a.type === 'claude-code')?.available).toBe(true); expect(agents.find((a) => a.type === 'cursor')?.available).toBe(false); }); diff --git a/src/loop/agents.ts b/src/loop/agents.ts index 26627073..930c9b2c 100644 --- a/src/loop/agents.ts +++ b/src/loop/agents.ts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; import chalk from 'chalk'; import { execa } from 'execa'; -export type AgentType = 'claude-code' | 'cursor' | 'codex' | 'opencode' | 'unknown'; +export type AgentType = 'claude-code' | 'cursor' | 'codex' | 'opencode' | 'openclaw' | 'unknown'; export interface Agent { type: AgentType; @@ -47,6 +47,11 @@ const AGENTS: Record<AgentType, { name: string; command: string; checkCmd: strin command: 'opencode', checkCmd: ['opencode', '--version'], }, + openclaw: { + name: 'OpenClaw', + command: 'openclaw', + checkCmd: ['openclaw', '--version'], + }, unknown: { name: 'Unknown', command: '', @@ -89,7 +94,7 @@ export async function detectBestAgent(): Promise<Agent | null> { if (available.length === 0) return null; // Prefer Claude Code, then others - const preferred = ['claude-code', 'cursor', 'codex', 'opencode']; + const preferred = ['claude-code', 'cursor', 'codex', 'opencode', 'openclaw']; for (const type of preferred) { const agent = available.find((a) => a.type === type); if (agent) return agent; @@ -139,6 +144,13 @@ export async function runAgent( } break; + case 'openclaw': + args.push('agent', '--message', options.task); + if (options.timeoutMs) { + args.push('--timeout', String(Math.floor(options.timeoutMs / 1000))); + } + break; + default: throw new Error(`Unknown agent type: ${agent.type}`); } diff --git a/src/loop/batch-fetcher.ts b/src/loop/batch-fetcher.ts index c392255f..93c43398 100644 --- a/src/loop/batch-fetcher.ts +++ b/src/loop/batch-fetcher.ts @@ -2,10 +2,13 @@ * Batch Task Fetcher * * Fetches multiple tasks from GitHub/Linear for batch processing. + * Uses WritableIntegration for bidirectional task management. */ import { execa } from 'execa'; +import type { IntegrationOptions, WritableIntegration } from '../integrations/base.js'; + /** * A task to be processed in auto mode */ @@ -44,6 +47,18 @@ export interface BatchFetchOptions { status?: string; } +/** + * Get a WritableIntegration instance for the given source + */ +async function getIntegration(source: 'github' | 'linear'): Promise<WritableIntegration> { + if (source === 'github') { + const { GitHubIntegration } = await import('../integrations/github/source.js'); + return new GitHubIntegration(); + } + const { LinearIntegration } = await import('../integrations/linear/source.js'); + return new LinearIntegration(); +} + /** * Fetch multiple tasks from a source */ @@ -107,44 +122,29 @@ async function fetchGitHubTasks(options: BatchFetchOptions): Promise<BatchTask[] * Fetch tasks from Linear */ async function fetchLinearTasks(options: BatchFetchOptions): Promise<BatchTask[]> { - // For now, use the Linear CLI if available - // TODO: Add direct API support + // Use the WritableIntegration to list tasks via GraphQL try { - const args = ['issue', 'list', '--format', 'json']; - - if (options.project) { - args.push('--project', options.project); - } - - if (options.label) { - args.push('--label', options.label); - } - - const result = await execa('linear', args); - const issues = JSON.parse(result.stdout) as Array<{ - id: string; - identifier: string; - title: string; - description?: string; - url: string; - priority: number; - labels?: Array<{ name: string }>; - }>; - - return issues.slice(0, options.limit || 10).map((issue) => ({ - id: issue.identifier, - title: issue.title, - description: issue.description || '', + const integration = await getIntegration('linear'); + const intOpts: IntegrationOptions = { + project: options.project, + label: options.label, + status: options.status, + limit: options.limit || 10, + }; + + const tasks = await integration.listTasks(intOpts); + return tasks.map((t) => ({ + id: t.identifier, + title: t.title, + description: '', source: 'linear' as const, - url: issue.url, - priority: issue.priority, - labels: issue.labels?.map((l) => l.name), + url: t.url, + priority: t.priority, + labels: t.labels, project: options.project, })); } catch { - throw new Error( - 'Linear CLI not found or not authenticated. Install: npm i -g @linear/cli && linear auth' - ); + throw new Error('Linear not authenticated. Run: ralph-starter config set linear.apiKey <key>'); } } @@ -152,33 +152,25 @@ async function fetchLinearTasks(options: BatchFetchOptions): Promise<BatchTask[] * Claim a task (mark as in-progress) */ export async function claimTask(task: BatchTask): Promise<void> { - switch (task.source) { - case 'github': - // Add "in-progress" label or assign to bot - if (task.project) { - try { - await execa('gh', [ - 'issue', - 'edit', - task.id, - '-R', - task.project, - '--add-label', - 'in-progress', - ]); - } catch { - // Label might not exist, that's ok - } - } - break; - case 'linear': - // Update status to "In Progress" - try { - await execa('linear', ['issue', 'update', task.id, '--status', 'In Progress']); - } catch { - // Status might not exist, that's ok - } - break; + try { + const integration = await getIntegration(task.source); + + switch (task.source) { + case 'github': + // Add "in-progress" label + await integration.updateTask( + task.id, + { labels: ['in-progress'] }, + { project: task.project } + ); + break; + case 'linear': + // Update status to "In Progress" + await integration.updateTask(task.id, { status: 'In Progress' }, { project: task.project }); + break; + } + } catch { + // Non-critical — label/status might not exist } } @@ -189,48 +181,42 @@ export async function completeTask( task: BatchTask, result: { success: boolean; prUrl?: string } ): Promise<void> { - switch (task.source) { - case 'github': - if (task.project && result.success) { + if (!result.success) return; + + try { + const integration = await getIntegration(task.source); + + switch (task.source) { + case 'github': + // Add comment with PR link + if (result.prUrl) { + await integration.addComment( + task.id, + `Automated PR created: ${result.prUrl}\n\n*Generated by ralph-starter auto mode*`, + { project: task.project } + ); + } + // Remove in-progress label try { - // Remove in-progress label if it exists await execa('gh', [ 'issue', 'edit', task.id, '-R', - task.project, + task.project || '', '--remove-label', 'in-progress', ]); } catch { // Label might not exist } - - // Add comment with PR link - if (result.prUrl) { - await execa('gh', [ - 'issue', - 'comment', - task.id, - '-R', - task.project, - '--body', - `Automated PR created: ${result.prUrl}\n\n*Generated by ralph-starter auto mode*`, - ]); - } - } - break; - case 'linear': - if (result.success) { - try { - // Update status to "Done" or link PR - await execa('linear', ['issue', 'update', task.id, '--status', 'Done']); - } catch { - // Status might not exist - } - } - break; + break; + case 'linear': + await integration.closeTask(task.id, undefined, { project: task.project }); + break; + } + } catch { + // Non-critical } } @@ -238,26 +224,12 @@ export async function completeTask( * Skip a task (mark as skipped/blocked) */ export async function skipTask(task: BatchTask, reason: string): Promise<void> { - switch (task.source) { - case 'github': - if (task.project) { - try { - await execa('gh', [ - 'issue', - 'comment', - task.id, - '-R', - task.project, - '--body', - `Skipped by ralph-starter auto mode: ${reason}`, - ]); - } catch { - // Comment failed, that's ok - } - } - break; - case 'linear': - // Add comment - break; + try { + const integration = await getIntegration(task.source); + await integration.addComment(task.id, `Skipped by ralph-starter auto mode: ${reason}`, { + project: task.project, + }); + } catch { + // Non-critical } } diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 049a2cca..db471cc4 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -49,6 +49,34 @@ const toolSchemas = { .describe('Filter by category (development, debugging, review, documentation, specialized)'), }), + ralph_task: z.object({ + action: z.enum(['list', 'create', 'update', 'close', 'comment']).describe('Task action'), + source: z + .enum(['github', 'linear', 'all']) + .optional() + .describe('Source platform (default: all for list, required for create)'), + project: z + .string() + .optional() + .describe('Project filter (owner/repo for GitHub, team name for Linear)'), + id: z + .string() + .optional() + .describe('Task ID for update/close/comment (#123 for GitHub, RAL-42 for Linear)'), + title: z.string().optional().describe('Task title (for create)'), + description: z.string().optional().describe('Task description (for create)'), + status: z.string().optional().describe('Status filter or new status (for list/update)'), + comment: z.string().optional().describe('Comment text (for close/comment)'), + labels: z.array(z.string()).optional().describe('Labels (for create)'), + priority: z.string().optional().describe('Priority: P0, P1, P2, P3'), + assignee: z + .string() + .optional() + .describe('Assignee (GitHub username or Linear display name, for create/update)'), + label: z.string().optional().describe('Label filter (for list)'), + limit: z.number().optional().describe('Max tasks to fetch (default: 50)'), + }), + ralph_fetch_spec: z.object({ path: z.string().min(1).describe('Project directory path'), source: z @@ -201,6 +229,51 @@ export function getTools(): Tool[] { }, }, }, + { + name: 'ralph_task', + description: + 'Manage tasks across GitHub and Linear. List tasks from both platforms, create new issues, update status, close tasks, and add comments. Detects the platform from the task ID format: #123 for GitHub, RAL-42 for Linear.', + inputSchema: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Task action: list, create, update, close, comment', + enum: ['list', 'create', 'update', 'close', 'comment'], + }, + source: { + type: 'string', + description: 'Source platform: github, linear, or all (default: all for list)', + enum: ['github', 'linear', 'all'], + }, + project: { + type: 'string', + description: 'Project filter (owner/repo for GitHub, team name for Linear)', + }, + id: { + type: 'string', + description: 'Task ID for update/close/comment (#123 for GitHub, RAL-42 for Linear)', + }, + title: { type: 'string', description: 'Task title (for create)' }, + description: { type: 'string', description: 'Task description (for create)' }, + status: { type: 'string', description: 'Status filter or new status' }, + comment: { type: 'string', description: 'Comment text' }, + labels: { + type: 'array', + items: { type: 'string' }, + description: 'Labels (for create)', + }, + priority: { type: 'string', description: 'Priority: P0, P1, P2, P3' }, + assignee: { + type: 'string', + description: 'Assignee (GitHub username or Linear display name)', + }, + label: { type: 'string', description: 'Label filter (for list)' }, + limit: { type: 'number', description: 'Max tasks (default: 50)' }, + }, + required: ['action'], + }, + }, { name: 'ralph_fetch_spec', description: @@ -272,6 +345,9 @@ export async function handleToolCall( case 'ralph_fetch_spec': return await handleFetchSpec(args); + case 'ralph_task': + return await handleTask(args); + default: return { content: [ @@ -522,3 +598,124 @@ function formatRunResult(result: RunCoreResult): string { } return `Loop failed: ${result.error}`; } + +async function handleTask( + args: Record<string, unknown> | undefined +): Promise<{ content: Array<{ type: 'text'; text: string }> }> { + const parsed = toolSchemas.ralph_task.parse(args); + + const { isWritableIntegration } = await import('../integrations/base.js'); + + // Helper to get integrations for a source + async function getIntegrations(source: string) { + const integrations = []; + if (source === 'github' || source === 'all') { + const { GitHubIntegration } = await import('../integrations/github/source.js'); + const gh = new GitHubIntegration(); + if (isWritableIntegration(gh) && (await gh.isAvailable())) { + integrations.push(gh); + } + } + if (source === 'linear' || source === 'all') { + const { LinearIntegration } = await import('../integrations/linear/source.js'); + const linear = new LinearIntegration(); + if (isWritableIntegration(linear) && (await linear.isAvailable())) { + integrations.push(linear); + } + } + return integrations; + } + + const priorityMap: Record<string, number> = { P0: 1, P1: 2, P2: 3, P3: 4 }; + + switch (parsed.action) { + case 'list': { + const source = parsed.source || 'all'; + const integrations = await getIntegrations(source); + const allTasks = []; + for (const integration of integrations) { + const tasks = await integration.listTasks({ + project: parsed.project, + label: parsed.label, + status: parsed.status, + limit: parsed.limit || 50, + }); + allTasks.push(...tasks); + } + return { + content: [{ type: 'text', text: JSON.stringify(allTasks, null, 2) }], + }; + } + + case 'create': { + if (!parsed.title) throw new Error('title is required for create'); + const source = parsed.source || 'github'; + if (source === 'all') throw new Error('Specify source (github or linear) for create'); + const integrations = await getIntegrations(source); + if (integrations.length === 0) throw new Error(`${source} not available`); + const task = await integrations[0].createTask( + { + title: parsed.title, + description: parsed.description, + labels: parsed.labels, + priority: parsed.priority ? priorityMap[parsed.priority.toUpperCase()] : undefined, + assignee: parsed.assignee, + project: parsed.project, + }, + { project: parsed.project } + ); + return { + content: [{ type: 'text', text: JSON.stringify(task, null, 2) }], + }; + } + + case 'update': { + if (!parsed.id) throw new Error('id is required for update'); + const source = parsed.source || (/^#?\d+$/.test(parsed.id) ? 'github' : 'linear'); + const integrations = await getIntegrations(source); + if (integrations.length === 0) throw new Error(`${source} not available`); + const task = await integrations[0].updateTask( + parsed.id.replace(/^#/, ''), + { + status: parsed.status, + comment: parsed.comment, + priority: parsed.priority ? priorityMap[parsed.priority.toUpperCase()] : undefined, + assignee: parsed.assignee, + }, + { project: parsed.project } + ); + return { + content: [{ type: 'text', text: JSON.stringify(task, null, 2) }], + }; + } + + case 'close': { + if (!parsed.id) throw new Error('id is required for close'); + const source = parsed.source || (/^#?\d+$/.test(parsed.id) ? 'github' : 'linear'); + const integrations = await getIntegrations(source); + if (integrations.length === 0) throw new Error(`${source} not available`); + await integrations[0].closeTask(parsed.id.replace(/^#/, ''), parsed.comment, { + project: parsed.project, + }); + return { + content: [{ type: 'text', text: `Closed ${parsed.id}` }], + }; + } + + case 'comment': { + if (!parsed.id || !parsed.comment) throw new Error('id and comment are required'); + const source = parsed.source || (/^#?\d+$/.test(parsed.id) ? 'github' : 'linear'); + const integrations = await getIntegrations(source); + if (integrations.length === 0) throw new Error(`${source} not available`); + await integrations[0].addComment(parsed.id.replace(/^#/, ''), parsed.comment, { + project: parsed.project, + }); + return { + content: [{ type: 'text', text: `Comment added to ${parsed.id}` }], + }; + } + + default: + throw new Error(`Unknown task action: ${parsed.action}`); + } +}