@h-rig/linear-plugin
v0.0.6-alpha.82
Published
Rig package
Downloads
2,449
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 two first-party adapters in @rig/standard-plugin cover a CLI wrapper
(github-issues shells out to gh) and the local filesystem (files). This
package covers the third shape you'll actually build most often: a remote
API spoken over HTTP, with 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 package, mirrors standard-plugin
├── tsconfig.json
└── src/
├── index.ts ← definePlugin wiring (metadata + runtime channels)
├── 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
├── contract.test.ts ← @rig/plugin-testkit contract suite + factory wiring
└── 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 on a sibling runtime channel — 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: "…" }],
},
},
// Channel 2: runtime. 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 the
runtime channel) 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. Lifecycle write-back
Rig drives the source through updateStatus(id, status) and
updateTask(id, update) (status/title/body/comment/metadata). The Linear
implementation mirrors the GitHub adapter's responsibilities with GraphQL
mutations:
- Status → resolve the team's workflow states (
RigTeamStates, cached per source instance), pick the lowest-positionstate whosetypematches the Rig status (in_progress→started,closed/completed→completed,cancelled→canceled,ready→unstarted), thenissueUpdatewithstateId. Statuses Linear has no workflow concept for (blocked,failed,needs_attention) deliberately leave the state untouched — they surface through comments instead of mangling the board. - 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 # typecheckcontract.test.ts runs pluginContractTests from @rig/plugin-testkit
(schema validity, namespaced ids, duplicate-id detection) plus factory-wiring
assertions; linear-issues-source.test.ts covers mapping, filters,
pagination, lifecycle mutation payloads, and every error surface above.
