-
Notifications
You must be signed in to change notification settings - Fork 207
feat: Linear Task Tracker #343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 2 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7af97d5
feat: add Linear format support to convert command and update validat…
omer9564 7e63a2d
fix(linear): correct blocking relation direction and parallelize chil…
fac9585
merge: resolve conflict with main (beads-rust-bv + linear coexist)
07eb60d
test(linear): add client.ts tests and getPrdContext coverage
c543d4f
fix(linear): address code review findings
e3705c9
fix(linear): address review - extract ensureIssueIdMap, fix afterEach…
0dc446c
ci: add Linear plugin and convert tests to coverage batches
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: | ||
|
|
||
| ``` | ||
| 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 | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| /** | ||
| * 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('--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(); | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.