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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions codev/plans/719-linear-forge-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Plan: Linear Forge Provider

## Metadata
- **Spec**: 719-linear-forge-provider
- **Protocol**: ASPIR
- **Created**: 2026-05-01

## Phase 1: Fix fallback bug in buildPresetFromScripts

**Goal**: Concepts without scripts should be omitted from the preset (not set to null), so they fall through to GitHub defaults via the resolution logic in `getForgeCommand`.

**Files**:
- `packages/codev/src/lib/forge.ts:99-114`

**Change**: In `buildPresetFromScripts`, remove the `else { preset[concept] = null; }` branch. Only include concepts that have a script file on disk or are explicitly disabled.

**Done when**: A provider with only issue scripts does NOT disable PR concepts. PR concepts resolve to the GitHub default.

## Phase 2: Register linear provider and pass config env vars

**Goal**: Register `linear` in `getProviderPresets()` and enhance `executeForgeCommand` to pass non-concept forge config keys as environment variables.

**Files**:
- `packages/codev/src/lib/forge.ts:127-131` — add `linear` to `_providerPresets`
- `packages/codev/src/lib/forge.ts:305-330` — enhance `executeForgeCommand` to export forge config keys as `CODEV_` env vars

**Changes**:
1. Add `linear: buildPresetFromScripts('linear', ['team-activity', 'on-it-timestamps'])` to `_providerPresets`
2. In `executeForgeCommand`, after resolving `forgeConfig`, extract non-concept keys (keys not in `KNOWN_CONCEPTS` and not `provider`) and export them as uppercased `CODEV_` prefixed env vars. E.g., `forge.linear-team: "ENG"` → `CODEV_LINEAR_TEAM=ENG`.

**Done when**: `getKnownProviders()` includes "linear". Scripts receive `CODEV_LINEAR_TEAM` from forge config.

## Phase 3: Widen issue identifier types

**Goal**: Accept alphanumeric identifiers (e.g., "ENG-123") throughout the agent-farm CLI and type system.

**Files**:
- `packages/codev/src/lib/forge-contracts.ts:33` — `number: number` → `number: number | string`
- `packages/codev/src/agent-farm/types.ts:17` — `issueNumber?: number` → `issueNumber?: number | string`
- `packages/codev/src/agent-farm/types.ts:67` — `issueNumber?: number` → `issueNumber?: number | string`
- `packages/codev/src/agent-farm/cli.ts:195` — change argument description from "Issue number" to "Issue identifier"
- `packages/codev/src/agent-farm/cli.ts:230-231` — accept alphanumeric: if `parseInt` fails but matches `/^[A-Z]+-\d+$/i`, keep as string

**Done when**: `afx spawn ENG-123 --protocol spir` parses without error. TypeScript compiles without type errors.

## Phase 4: Create Linear forge scripts

**Goal**: Implement 6 POSIX sh scripts in `packages/codev/scripts/forge/linear/`.

**Scripts**:

| Script | Concept | Env Vars | Output |
|--------|---------|----------|--------|
| `auth-status.sh` | auth-status | LINEAR_API_KEY | exit code 0 = authenticated |
| `user-identity.sh` | user-identity | LINEAR_API_KEY | plain text display name |
| `issue-view.sh` | issue-view | LINEAR_API_KEY, CODEV_ISSUE_ID | JSON: {title, body, state, comments[]} |
| `issue-list.sh` | issue-list | LINEAR_API_KEY, CODEV_LINEAR_TEAM | JSON: [{number, title, url, labels, createdAt, author, assignees}] |
| `issue-comment.sh` | issue-comment | LINEAR_API_KEY, CODEV_ISSUE_ID, CODEV_COMMENT_BODY | exit code 0 = success |
| `recently-closed.sh` | recently-closed | LINEAR_API_KEY, CODEV_LINEAR_TEAM, CODEV_SINCE_DATE | JSON: [{number, title, url, labels, createdAt, closedAt}] |

All scripts:
- Use `curl -s` for HTTP + `jq` for JSON transformation
- Auth via `Authorization: $LINEAR_API_KEY` header
- Linear GraphQL endpoint: `https://api.linear.app/graphql`
- Map Linear fields to forge-contracts.ts interfaces (e.g., `issue.identifier` → `number`, `issue.team.states` → derive `state`)
- Fail with exit 1 and stderr message if LINEAR_API_KEY is not set

**Done when**: Each script runs successfully with a real LINEAR_API_KEY and produces valid JSON matching the contracts.

## Phase 5: Verification

**Goal**: Confirm everything works end-to-end.

**Checks**:
1. TypeScript compiles: `pnpm run build` from workspace root
2. Existing tests pass: `pnpm test` from workspace root
3. `codev doctor` shows linear as known provider
4. Manual verification: run scripts with LINEAR_API_KEY set, confirm JSON output

**Done when**: All checks pass. Ready for PR.

## Sequencing

Phases 1-3 are code changes (TypeScript). Phase 4 is script creation (shell). Phase 5 is verification. Phases 1-3 must be sequential (each builds on prior). Phase 4 can be done after Phase 2 (needs the directory to exist and provider registered). Phase 5 is last.
55 changes: 55 additions & 0 deletions codev/specs/719-linear-forge-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Specification: Linear Forge Provider

## Metadata
- **ID**: 719-linear-forge-provider
- **Status**: draft
- **Created**: 2026-05-01
- **Protocol**: ASPIR

## Problem Statement

Codev's forge abstraction (spec 589) supports GitHub, GitLab, and Gitea via on-disk scripts. MachineWisdom uses Linear for issue tracking but keeps PRs on GitHub. There is no Linear provider, so MW repos with `forge.provider: "linear"` in `.codev/config.json` fall back to GitHub for everything — including issue concepts, which should resolve against Linear.

Additionally, a bug in `buildPresetFromScripts` (forge.ts:110) sets `null` for concepts without a script file. Since `null` means "disabled" in the resolution logic, a Linear provider that only implements issue scripts would disable all PR concepts rather than letting them fall through to the GitHub default. This fundamentally breaks the hybrid forge model where one provider handles issues and another handles PRs.

## Desired State

- A `linear` provider registered in `getProviderPresets()` with 6 issue-oriented concept scripts
- PR concepts (pr-list, pr-exists, pr-merge, pr-search, pr-view, pr-diff, recently-merged) fall through to GitHub defaults — Linear only handles issues
- `buildPresetFromScripts` skips concepts without scripts (omits them from the preset) instead of setting null, so the resolution logic at `getForgeCommand` falls through to the GitHub default
- Issue identifiers are treated as opaque strings (e.g., "ENG-123") throughout the agent-farm CLI — `afx spawn ENG-123 --protocol spir` works
- Linear scripts authenticate via `LINEAR_API_KEY` env var and filter by team via `CODEV_LINEAR_TEAM` (passed from `forge.linear-team` config)

## Stakeholders

- **Primary**: MachineWisdom team (immediate users)
- **Secondary**: Any team using Linear for project management + GitHub for code
- **Upstream**: cluesmith/codev maintainers (PR target)

## Success Criteria

- [ ] `buildPresetFromScripts` no longer sets null for missing scripts — omitted concepts fall through to GitHub default
- [ ] `linear` provider registered with disabled: `['team-activity', 'on-it-timestamps']`
- [ ] 6 Linear scripts exist and produce valid JSON matching forge-contracts.ts interfaces
- [ ] `IssueListItem.number` accepts `string | number`
- [ ] `SpawnOptions.issueNumber` accepts `string | number`
- [ ] `afx spawn ENG-123 --protocol spir` parses correctly (no "Invalid issue number" error)
- [ ] `codev doctor` shows `linear` as a known provider with issue concepts resolved, PR concepts falling through to GitHub
- [ ] All existing tests pass (no regression)

## Constraints

- Scripts must be POSIX sh (curl + jq only) — no node, no python
- LINEAR_API_KEY is required for auth — scripts fail gracefully with helpful stderr if not set
- Scripts must match the JSON output contracts in forge-contracts.ts
- No new runtime dependencies on the codev package
- Must be backward-compatible — existing GitHub/GitLab/Gitea providers unchanged
- The `executeForgeCommand` function must export non-concept forge config keys (e.g., `linear-team`) as `CODEV_LINEAR_TEAM` environment variable

## Solution Approach

Fix the fallback bug first, then add the Linear provider scripts and register the provider. Widen type definitions to accept alphanumeric issue identifiers. Small enhancement to `executeForgeCommand` to pass `forge.linear-team` as `CODEV_LINEAR_TEAM` env var.

## Open Questions

None — the forge abstraction is well-documented (spec 589) and the Linear GraphQL API is stable.
15 changes: 15 additions & 0 deletions packages/codev/scripts/forge/linear/auth-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/sh
# Forge concept: auth-status (Linear via GraphQL API)
# Output: exit code (0 = authenticated)
set -e

if [ -z "$LINEAR_API_KEY" ]; then
echo "LINEAR_API_KEY is not set" >&2
exit 1
fi

curl -sf -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d '{"query":"{ viewer { id } }"}' \
-o /dev/null
38 changes: 38 additions & 0 deletions packages/codev/scripts/forge/linear/issue-comment.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
#!/bin/sh
# Forge concept: issue-comment (Linear via GraphQL API)
# Input: CODEV_ISSUE_ID, CODEV_COMMENT_BODY
# Output: exit code only
set -e

if [ -z "$LINEAR_API_KEY" ]; then
echo "LINEAR_API_KEY is not set" >&2
exit 1
fi

if [ -z "$CODEV_ISSUE_ID" ]; then
echo "CODEV_ISSUE_ID is not set" >&2
exit 1
fi

ISSUE_UUID=$(curl -sf -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n --arg id "$CODEV_ISSUE_ID" '{
query: "query($id: String!) { issues(filter: { identifier: { eq: $id } }) { nodes { id } } }",
variables: { id: $id }
}')" \
| jq -r '.data.issues.nodes[0].id')

if [ -z "$ISSUE_UUID" ] || [ "$ISSUE_UUID" = "null" ]; then
echo "Issue not found: $CODEV_ISSUE_ID" >&2
exit 1
fi

curl -sf -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n --arg issueId "$ISSUE_UUID" --arg body "$CODEV_COMMENT_BODY" '{
query: "mutation($issueId: String!, $body: String!) { commentCreate(input: { issueId: $issueId, body: $body }) { success } }",
variables: { issueId: $issueId, body: $body }
}')" \
-o /dev/null
35 changes: 35 additions & 0 deletions packages/codev/scripts/forge/linear/issue-list.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/sh
# Forge concept: issue-list (Linear via GraphQL API)
# Input: CODEV_LINEAR_TEAM (team key, e.g. "ENG")
# Output: JSON [{number, title, url, labels, createdAt, author, assignees}]
set -e

if [ -z "$LINEAR_API_KEY" ]; then
echo "LINEAR_API_KEY is not set" >&2
exit 1
fi

FILTER='{ state: { type: { nin: ["completed", "canceled"] } } }'
if [ -n "$CODEV_LINEAR_TEAM" ]; then
FILTER="$(jq -n --arg team "$CODEV_LINEAR_TEAM" '{
team: { key: { eq: $team } },
state: { type: { nin: ["completed", "canceled"] } }
}')"
fi

curl -sf -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n --argjson filter "$FILTER" '{
query: "query($filter: IssueFilter) { issues(filter: $filter, first: 200) { nodes { identifier title url labels { nodes { name } } createdAt assignee { displayName } creator { displayName } } } }",
variables: { filter: $filter }
}')" \
| jq '[.data.issues.nodes[] | {
number: .identifier,
title: .title,
url: .url,
labels: [.labels.nodes[] | { name: .name }],
createdAt: .createdAt,
author: (if .creator then { login: .creator.displayName } else null end),
assignees: (if .assignee then [{ login: .assignee.displayName }] else [] end)
}]'
37 changes: 37 additions & 0 deletions packages/codev/scripts/forge/linear/issue-view.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/bin/sh
# Forge concept: issue-view (Linear via GraphQL API)
# Input: CODEV_ISSUE_ID (e.g. "ENG-123")
# Output: JSON {title, body, state, comments[]}
set -e

if [ -z "$LINEAR_API_KEY" ]; then
echo "LINEAR_API_KEY is not set" >&2
exit 1
fi

if [ -z "$CODEV_ISSUE_ID" ]; then
echo "CODEV_ISSUE_ID is not set" >&2
exit 1
fi

curl -sf -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n --arg id "$CODEV_ISSUE_ID" '{
query: "query($id: String!) { issueVcsByFilter: issues(filter: { identifier: { eq: $id } }) { nodes { title description state { name } comments { nodes { body createdAt user { displayName } } } } } }",
variables: { id: $id }
}')" \
| jq 'if (.data.issueVcsByFilter.nodes | length) == 0 then
error("issue not found: \(env.CODEV_ISSUE_ID)")
else
.data.issueVcsByFilter.nodes[0] | {
title: .title,
body: (.description // ""),
state: .state.name,
comments: [.comments.nodes[] | {
body: .body,
createdAt: .createdAt,
author: { login: .user.displayName }
}]
}
end'
45 changes: 45 additions & 0 deletions packages/codev/scripts/forge/linear/recently-closed.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/bin/sh
# Forge concept: recently-closed (Linear via GraphQL API)
# Input: CODEV_LINEAR_TEAM, CODEV_SINCE_DATE (optional, ISO date)
# Output: JSON [{number, title, url, labels, createdAt, closedAt}]
set -e

if [ -z "$LINEAR_API_KEY" ]; then
echo "LINEAR_API_KEY is not set" >&2
exit 1
fi

FILTER='{ state: { type: { in: ["completed", "canceled"] } } }'
if [ -n "$CODEV_LINEAR_TEAM" ] && [ -n "$CODEV_SINCE_DATE" ]; then
FILTER="$(jq -n --arg team "$CODEV_LINEAR_TEAM" --arg since "$CODEV_SINCE_DATE" '{
team: { key: { eq: $team } },
state: { type: { in: ["completed", "canceled"] } },
completedAt: { gte: $since }
}')"
elif [ -n "$CODEV_LINEAR_TEAM" ]; then
FILTER="$(jq -n --arg team "$CODEV_LINEAR_TEAM" '{
team: { key: { eq: $team } },
state: { type: { in: ["completed", "canceled"] } }
}')"
elif [ -n "$CODEV_SINCE_DATE" ]; then
FILTER="$(jq -n --arg since "$CODEV_SINCE_DATE" '{
state: { type: { in: ["completed", "canceled"] } },
completedAt: { gte: $since }
}')"
fi

curl -sf -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d "$(jq -n --argjson filter "$FILTER" '{
query: "query($filter: IssueFilter) { issues(filter: $filter, first: 200, orderBy: updatedAt) { nodes { identifier title url labels { nodes { name } } createdAt completedAt } } }",
variables: { filter: $filter }
}')" \
| jq '[.data.issues.nodes[] | {
number: .identifier,
title: .title,
url: .url,
labels: [.labels.nodes[] | { name: .name }],
createdAt: .createdAt,
closedAt: .completedAt
}]'
15 changes: 15 additions & 0 deletions packages/codev/scripts/forge/linear/user-identity.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/sh
# Forge concept: user-identity (Linear via GraphQL API)
# Output: plain text display name
set -e

if [ -z "$LINEAR_API_KEY" ]; then
echo "LINEAR_API_KEY is not set" >&2
exit 1
fi

curl -sf -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_KEY" \
-d '{"query":"{ viewer { displayName } }"}' \
| jq -r '.data.viewer.displayName'
40 changes: 39 additions & 1 deletion packages/codev/src/__tests__/forge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,12 @@ describe('loadForgeConfig', () => {
// =============================================================================

describe('provider presets', () => {
it('returns known providers', () => {
it('returns known providers including linear', () => {
const providers = getKnownProviders();
expect(providers).toContain('github');
expect(providers).toContain('gitlab');
expect(providers).toContain('gitea');
expect(providers).toContain('linear');
});

it('uses provider preset when no manual override', () => {
Expand Down Expand Up @@ -471,6 +472,43 @@ describe('provider presets', () => {
expect(results[0].status).toBe('provider');
expect(results[0].message).toContain('gitlab');
});

it('linear provider uses linear scripts for concepts with scripts', () => {
const config = { provider: 'linear' };
const cmd = getForgeCommand('issue-view', config);
expect(cmd).toContain('scripts/forge/linear/issue-view.sh');
});

it('linear provider falls through to github default for concepts without scripts', () => {
const config = { provider: 'linear' };
const cmd = getForgeCommand('pr-list', config);
expect(cmd).toContain('scripts/forge/github/pr-list.sh');
});

it('linear provider omits (not nulls) concepts without scripts so they fall through', () => {
const config = { provider: 'linear' };
const resolutions = resolveAllConcepts(config);
const prList = resolutions.find(r => r.concept === 'pr-list');
// pr-list has no linear script — should fall through to default, NOT be disabled
expect(prList?.source).toBe('default');
expect(prList?.command).toContain('github/pr-list.sh');
});

it('linear provider disables explicitly disabled concepts', () => {
const config = { provider: 'linear' };
const resolutions = resolveAllConcepts(config);
const teamActivity = resolutions.find(r => r.concept === 'team-activity');
expect(teamActivity?.source).toBe('disabled');
expect(teamActivity?.command).toBeNull();
});

it('linear provider resolves issue-view as preset', () => {
const config = { provider: 'linear' };
const resolutions = resolveAllConcepts(config);
const issueView = resolutions.find(r => r.concept === 'issue-view');
expect(issueView?.source).toBe('preset');
expect(issueView?.command).toContain('linear/issue-view.sh');
});
});

// =============================================================================
Expand Down
Loading
Loading