Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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
8 changes: 7 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

119 changes: 119 additions & 0 deletions docs/linear-tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# Linear Tracker Plugin

Use Linear as a task tracker for Ralph TUI. Tasks are managed as child issues under a parent (epic) issue in Linear.

## Setup

### 1. Get a Linear API Key

Create a personal API key at: Settings > API > Personal API Keys

### 2. Configure Authentication

Set the `LINEAR_API_KEY` environment variable:

```bash
export LINEAR_API_KEY="lin_api_..."
```

Or add it to your project config (`.ralph-tui/config.toml`):

```toml
[[trackers]]
name = "linear"
plugin = "linear"

[trackers.options]
apiKey = "lin_api_..."
```

Auth precedence: explicit config `apiKey` overrides `LINEAR_API_KEY` env var.

## Converting a PRD to Linear Issues

Use `convert --to linear` to import a PRD into Linear as a parent issue with child issues:

```bash
# Basic: create parent + children in the ENG team
ralph-tui convert --to linear --team ENG ./prd.md

# Use an existing parent issue
ralph-tui convert --to linear --team ENG --parent ENG-123 ./prd.md

# With project and labels
ralph-tui convert --to linear --team ENG --project "Q1 Sprint" --labels "backend,mvp" ./prd.md
```

### Options

| Flag | Required | Description |
|------|----------|-------------|
| `--team <key>` | Yes | Linear team key (e.g., `ENG`) |
| `--parent <issue>` | No | Existing parent issue key or UUID. Auto-creates if omitted. |
| `--project <name>` | No | Linear project name or UUID |
| `--labels <list>` | No | Comma-separated labels to apply |

Each PRD user story becomes a child issue with:
- Title: `<story-id>: <story-title>`
- Structured markdown body with Ralph metadata, description, and acceptance criteria
- Native Linear blocking relations from PRD `dependsOn` fields

## Running Tasks from Linear

Run Ralph against child issues of a parent (epic) issue:

```bash
ralph-tui run --tracker linear --epic ENG-123
```

The `--epic` flag is required for the Linear tracker in MVP. It accepts either an issue key (`ENG-123`) or a UUID.

### How It Works

1. Ralph fetches all child issues under the specified parent
2. Tasks are ordered by `Ralph Priority` metadata embedded in issue bodies (ascending, lower = higher priority)
3. In-progress tasks are preferred over open tasks
4. Dependency-blocked tasks are excluded from selection
5. On task completion, Ralph moves the issue to the "completed" workflow state and posts a comment

### Status Mapping

| Linear State Type | Ralph Status |
|-------------------|-------------|
| `triage`, `backlog`, `unstarted` | `open` |
| `started` | `in_progress` |
| `completed` | `completed` |
| `canceled` | `cancelled` |

### Priority Model

PRD story priorities are preserved as unbounded integers in the issue body metadata (`Ralph Priority`). These are mapped to Linear's coarse 0-4 scale for compatibility:

```text
coarse_priority = min(4, max(0, ralph_priority - 1))
```

Task selection uses the full `Ralph Priority` value for fine-grained ordering.

## Configuration Reference

```toml
# .ralph-tui/config.toml

# Set linear as default tracker
tracker = "linear"

# Or configure with options
[[trackers]]
name = "linear"
plugin = "linear"

[trackers.options]
apiKey = "lin_api_..." # Optional if LINEAR_API_KEY is set
```

Run with:

```bash
ralph-tui run --tracker linear --epic ENG-123
```
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,12 @@
"typescript": "^5.9.3"
},
"dependencies": {
"@linear/sdk": "^76.0.0",
"@opentui/core": "^0.1.72",
"@opentui/react": "^0.1.72",
"react": "^19.2.3",
"handlebars": "^4.7.8",
"node-notifier": "^8.0.2",
"react": "^19.2.3",
"smol-toml": "^1.6.0",
"yaml": "^2.8.2",
"zod": "^4.3.5"
Expand Down
207 changes: 207 additions & 0 deletions src/commands/convert.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
/**
* ABOUTME: Unit tests for convert command argument parsing and Linear conversion logic.
* Covers Linear-specific argument validation, format support, and parent resolution modes.
*/

import { describe, expect, test } from 'bun:test';
import { parseConvertArgs } from './convert.js';

describe('parseConvertArgs', () => {
describe('format support', () => {
test('accepts --to json', () => {
const result = parseConvertArgs(['--to', 'json', 'input.md']);
expect(result).not.toBeNull();
expect(result!.to).toBe('json');
});

test('accepts --to beads', () => {
const result = parseConvertArgs(['--to', 'beads', 'input.md']);
expect(result).not.toBeNull();
expect(result!.to).toBe('beads');
});

test('accepts --to linear', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', 'input.md']);
expect(result).not.toBeNull();
expect(result!.to).toBe('linear');
});

test('rejects unsupported format', () => {
const result = parseConvertArgs(['--to', 'jira', 'input.md']);
expect(result).toBeNull();
});

test('accepts -t shorthand for format', () => {
const result = parseConvertArgs(['-t', 'json', 'input.md']);
expect(result).not.toBeNull();
expect(result!.to).toBe('json');
});
});

describe('required arguments', () => {
test('requires --to flag', () => {
const result = parseConvertArgs(['input.md']);
expect(result).toBeNull();
});

test('requires input file', () => {
const result = parseConvertArgs(['--to', 'json']);
expect(result).toBeNull();
});
});

describe('Linear-specific options', () => {
test('--team is required for linear format', () => {
const result = parseConvertArgs(['--to', 'linear', 'input.md']);
expect(result).toBeNull();
});

test('accepts --team flag', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', 'input.md']);
expect(result).not.toBeNull();
expect(result!.team).toBe('ENG');
});

test('accepts --project flag', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', '--project', 'Q1 Sprint', 'input.md']);
expect(result).not.toBeNull();
expect(result!.project).toBe('Q1 Sprint');
});

test('accepts --parent flag with issue key', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', '--parent', 'ENG-123', 'input.md']);
expect(result).not.toBeNull();
expect(result!.parent).toBe('ENG-123');
});

test('accepts --parent flag with UUID', () => {
const result = parseConvertArgs([
'--to', 'linear', '--team', 'ENG',
'--parent', 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'input.md',
]);
expect(result).not.toBeNull();
expect(result!.parent).toBe('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
});

test('rejects --team with missing value (next token is a flag)', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', '--project', 'input.md']);
expect(result).toBeNull();
});

test('rejects --team with no subsequent arg', () => {
const result = parseConvertArgs(['--to', 'linear', '--team']);
expect(result).toBeNull();
});

test('rejects --project with missing value (next token is a flag)', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', '--project', '--parent', 'input.md']);
expect(result).toBeNull();
});

test('rejects --parent with missing value (next token is a flag)', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', '--parent', '--verbose', 'input.md']);
expect(result).toBeNull();
});

test('--team not required for non-linear formats', () => {
const result = parseConvertArgs(['--to', 'json', 'input.md']);
expect(result).not.toBeNull();
expect(result!.team).toBeUndefined();
});

test('accepts all Linear options together', () => {
const result = parseConvertArgs([
'--to', 'linear',
'--team', 'ENG',
'--project', 'Sprint 1',
'--parent', 'ENG-42',
'--labels', 'backend,mvp',
'--verbose',
'prd.md',
]);
expect(result).not.toBeNull();
expect(result!.to).toBe('linear');
expect(result!.team).toBe('ENG');
expect(result!.project).toBe('Sprint 1');
expect(result!.parent).toBe('ENG-42');
expect(result!.labels).toEqual(['backend', 'mvp']);
expect(result!.verbose).toBe(true);
expect(result!.input).toBe('prd.md');
});
});

describe('common options', () => {
test('parses --labels as comma-separated list', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', '--labels', 'a,b,c', 'input.md']);
expect(result).not.toBeNull();
expect(result!.labels).toEqual(['a', 'b', 'c']);
});

test('trims whitespace from labels', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', '--labels', ' a , b ', 'input.md']);
expect(result).not.toBeNull();
expect(result!.labels).toEqual(['a', 'b']);
});

test('filters empty labels', () => {
const result = parseConvertArgs(['--to', 'linear', '--team', 'ENG', '--labels', 'a,,b', 'input.md']);
expect(result).not.toBeNull();
expect(result!.labels).toEqual(['a', 'b']);
});

test('parses --force flag', () => {
const result = parseConvertArgs(['--to', 'json', '--force', 'input.md']);
expect(result).not.toBeNull();
expect(result!.force).toBe(true);
});

test('parses --verbose flag', () => {
const result = parseConvertArgs(['--to', 'json', '--verbose', 'input.md']);
expect(result).not.toBeNull();
expect(result!.verbose).toBe(true);
});

test('parses -f shorthand for force', () => {
const result = parseConvertArgs(['-t', 'json', '-f', 'input.md']);
expect(result).not.toBeNull();
expect(result!.force).toBe(true);
});

test('parses -v shorthand for verbose', () => {
const result = parseConvertArgs(['-t', 'json', '-v', 'input.md']);
expect(result).not.toBeNull();
expect(result!.verbose).toBe(true);
});

test('parses --output flag', () => {
const result = parseConvertArgs(['--to', 'json', '--output', 'out.json', 'input.md']);
expect(result).not.toBeNull();
expect(result!.output).toBe('out.json');
});

test('parses --branch flag', () => {
const result = parseConvertArgs(['--to', 'json', '--branch', 'feature/test', 'input.md']);
expect(result).not.toBeNull();
expect(result!.branch).toBe('feature/test');
});

test('positional argument is the input file', () => {
const result = parseConvertArgs(['--to', 'json', 'my-prd.md']);
expect(result).not.toBeNull();
expect(result!.input).toBe('my-prd.md');
});

test('defaults for optional flags', () => {
const result = parseConvertArgs(['--to', 'json', 'input.md']);
expect(result).not.toBeNull();
expect(result!.force).toBe(false);
expect(result!.verbose).toBe(false);
expect(result!.output).toBeUndefined();
expect(result!.branch).toBeUndefined();
expect(result!.labels).toBeUndefined();
expect(result!.project).toBeUndefined();
expect(result!.parent).toBeUndefined();
});
});
});
Loading
Loading