@h-rig/linear-plugin
v0.0.6-alpha.286
Published
Rig package
Readme
@rig/linear-plugin
Linear as a Rig task source — and the canonical example of a plugin that adapts an external HTTP API into Rig's task-source contract.
The application graph includes the GitHub Issues source. This package is the canonical project-extra example for a remote HTTP API: credentials, pagination, write-back mutations, and tests that never touch the network. If you are writing a Jira/Asana/Shortcut/ internal-queue adapter, copy this package.
packages/linear-plugin/
├── package.json ← workspace plugin package
├── tsconfig.json
└── src/
├── index.ts ← definePlugin wiring (metadata + executable fields)
├── linear-issues-source.ts ← the adapter: GraphQL client, mapping, write-back
├── fixture-transport.ts ← fixture-driven fake transport for tests
├── fixtures.ts ← canned Linear GraphQL payloads
└── linear-issues-source.test.ts ← mapping, lifecycle, pagination, error surfacesUsing it
// rig.config.ts
import { defineConfig } from "@rig/core";
import linear from "@rig/linear-plugin";
export default defineConfig({
plugins: [linear()],
taskSource: {
kind: "linear",
options: {
teamKey: "ENG", // required — the "ENG" in ENG-123
states: ["Todo", "In Progress"], // optional: workflow state names
assignee: "[email protected]", // optional: email or display name
labels: ["rig"], // optional: issue must carry ALL listed labels
// pageSize: 50, // optional: issues per GraphQL page (max 250)
// listLimit: 1000, // optional: fail-loud ceiling, no silent truncation
},
},
// ... workspace, runtime, etc.
});Authentication comes from the environment, never from the committed config:
export LINEAR_API_KEY=lin_api_... # personal API key (Settings → API)Personal API keys (lin_api_…) are sent raw in the Authorization header;
OAuth access tokens (lin_oauth_…) are automatically sent as Bearer <token>.
You can also pass the key programmatically: linear({ apiKey }).
Anatomy of an external-API task source
1. Two channels, one definePlugin call
Rig plugins declare what they contribute as Effect-Schema-validated metadata
and ship executable code as fields on the same explicit plugin object —
schemas can't carry functions. src/index.ts:
export default function linearPlugin(opts: LinearPluginOptions = {}) {
return definePlugin(
// Channel 1: metadata. Validated against the RigPlugin schema at load time.
{
name: "rig-linear",
version: "0.1.0",
contributes: {
taskSources: [{ id: "linear:issues", kind: "linear", description: "…" }],
},
},
// Executable fields. The factory turns a TaskSourceConfig into a live source.
{
taskSources: [{ id: "linear:issues", kind: "linear", factory(config) { … } }],
},
);
}definePlugin enforces referential integrity: every factory must match a
metadata entry by id with the same kind, and (once a plugin opts into
executable fields) every metadata entry must be backed by a factory. Drift
throws at config-load time, not at dispatch time.
At boot, buildTaskSourceRegistry(config, pluginHost) in @rig/runtime reads
config.taskSource.kind ("linear"), resolves the factory through the plugin
host (which also rejects duplicate kinds across plugins), and instantiates the
source.
2. Plugin-specific config rides in options
TaskSourceConfig's top-level fields (path, owner, repo, …) are
reserved for the built-in adapters. Everything Linear-specific lives in the
documented options extension bag — the factory pulls its own fields out and
validates them with actionable errors:
const teamKey = optionString(config, "teamKey");
if (!teamKey) {
throw new Error('task source linear: options.teamKey is required — …');
}Process-level concerns (API key, endpoint override, transport injection) arrive through the plugin function's own options instead, because they belong to the environment, not to the committed project config.
3. An injectable transport is the whole testing story
The adapter never calls fetch directly. It calls a fetch-shaped function
it was constructed with:
export type LinearTransport = (
url: string,
init: { method: "POST"; headers: Record<string, string>; body: string },
) => Promise<{ ok: boolean; status: number; text(): Promise<string> }>;
const transport: LinearTransport = opts.transport ?? fetch; // global fetch satisfies itThat one seam is why this package's test suite makes zero live API calls.
fixture-transport.ts exports createFixtureTransport(routes), which routes
requests by GraphQL operation name (every query/mutation in the adapter is
deliberately named: RigListIssues, RigTeamStates, RigIssueUpdate, …),
records every call for payload assertions, and fails loudly on any operation
a test didn't explicitly allow:
const fixture = createFixtureTransport({
RigListIssues: () => listIssuesPage(issueFixtures),
RigIssueUpdate: () => ({ issueUpdate: { success: true } }),
});
const source = createLinearIssuesTaskSource({
teamKey: "ENG",
apiKey: "lin_api_test",
transport: fixture.transport,
});
// …act…
expect(fixture.callsFor("RigIssueUpdate")[0].variables.input).toEqual({ stateId: … });graphqlErrors([...]) and httpFailure(status, body) simulate the two
failure layers a GraphQL API has (200-with-errors vs. non-200 HTTP).
4. Mapping external records to TaskRecord
TaskRecord requires id, deps, status and is open-ended beyond that.
The mapping decisions here:
| TaskRecord field | Source |
|---|---|
| id | Linear identifier (ENG-123) — human-facing, like GitHub issue numbers |
| status | Linear workflow-state type (see table below) |
| title, body/description | issue title/description (body is Rig convention; description mirrors Linear) |
| priority, priorityLabel | Linear priority (0–4) and its label |
| labels | label names; scope:/role:/validator: prefixes get the same treatment as the GitHub adapter |
| externalRef | linear:<identifier> |
| sourceIssueId | the identifier |
| linearIssueId | Linear's internal UUID — kept so mutations can skip a lookup |
| url, raw | issue URL, full raw node |
Status mapping is keyed off Linear's fixed state-type taxonomy, not the user-renamable state names:
| Linear state type | Rig status |
|---|---|
| triage, backlog, unstarted | ready |
| started | in_progress |
| completed | completed |
| canceled | cancelled |
| anything unknown | open (fail-soft) |
5. Explicit source-field write-back
Rig drives source mutation through updateTask(id, update) with projected
source fields plus title/body/comment/metadata. Workflow-state changes are only
performed when update.sourceFields.status is present. The Linear adapter then
resolves the team's workflow states (RigTeamStates, cached per source
instance), picks the lowest-position state whose type matches the projected
status (running/prOpen/ciFixing/merging → started, done →
completed, cancelled → canceled), and sends issueUpdate with stateId.
needsAttention deliberately leaves the state untouched; use comments or
metadata for that signal.
- Title / body / metadata → folded into the same single
issueUpdatecall.update.metadatais rendered into the Rig-owned<!-- rig:metadata:start/end -->block (same markers as the GitHub adapter, so operator surfaces parse both identically), replacing a previous block in the existing description when present. - Comment →
commentCreateafter the update. - Task ids are accepted as either the identifier (
ENG-123) or Linear's UUID; identifiers are resolved with one extraissue(id:)query.
Every write fires the optional onTaskChanged callback so the host can
refresh snapshots — same pattern as the GitHub adapter.
6. Error surfaces are part of the contract
- No key configured →
"Linear task source: no API key configured. Pass apiKey in plugin options or set the LINEAR_API_KEY environment variable."— thrown before any request. - Bad token (HTTP 401 or a GraphQL
AUTHENTICATION_ERROR) →"Linear API authentication failed (…). Check that the configured API key (options.apiKey / LINEAR_API_KEY) is a valid Linear key…". - Other GraphQL errors → surfaced with the server's message.
- Non-200 HTTP → status + body excerpt.
- More matching issues than
listLimit→ a loud refusal to truncate, telling you exactly which knob to turn — never a silently short task list.
Running the tests
cd packages/linear-plugin
bun test # 39 tests, no network
bunx tsc --noEmit # typechecklinear-issues-source.test.ts covers mapping, filters, pagination, lifecycle
mutation payloads, and every error surface above. The plugin no longer depends
on a shared testkit package; executable-channel assertions live with the owning
plugin tests.
