Expand description
A read/write source over Linear’s published GraphQL API.
Linear Issue maps to Task, Project to Project, Document to Document,
IssueLabel and ProjectLabel to Label, and an issue’s WorkflowState.name and a
project’s ProjectStatus.name are preserved as the status’s name while the source’s
status_mapping — the one grammar every source that names its statuses is configured with,
StatusMapping — decides its category. Issue relations/inverseRelations and
project relations provide native dependency traversal in both directions.
Label, workflow-state, project, and orphan filters are sent in the
issues(filter:)/projects(filter:) variables. Pagination uses Relay first and
after.
Every issue, project and document reports its own Linear web address as its
Location, as a link rather than a path — the counterpart of a folder of Markdown
reporting the path of the file behind an item. It does not replace the url field
those types already carry; it is the same address said in the shape a reader can act on.
§What this source declares, field by field
One verdict per field of Capabilities. A field is supported and proven when this
source applies it and a shared journey drives it against the real binary; the shared
table is crates/onetaskgraph-e2e-support/src/fixtures.rs, the journeys are beside it, and
every_row_declares_exactly_what_its_plugin_reports is what keeps this list and
capabilities from parting.
| Field | Verdict |
|---|---|
projects | Supported and proven. issues(filter:{project:{id:{eq:…}}}). |
documents | Supported and proven. Linear’s own first-class Document, read through documents(first:,after:,filter:) and document(id:), written through documentCreate/documentUpdate and taken back by documentDelete. See the ruling below on what a Linear document cannot hold. |
comments | Supported and proven, as the issue’s own comments: read oldest first through issue(id:){comments(last:,before:)}, added with commentCreate, edited with commentUpdate and removed with commentDelete — each of the last two only once comment(id:) has placed the comment on that very issue. See the ruling below on the order and on the author. |
assets | Unsupported — unimplemented. A copy of a record carrying an image asset into a Linear workspace is refused, naming the source, the record and the asset, before anything is written for that record. Uploading the bytes to Linear’s own file storage is tracked in docs/follow-ups.md. |
priority | Supported, as Linear’s own Issue.priority: read on every issue, written by issueCreate/issueUpdate through IssueCreateInput.priority/IssueUpdateInput.priority, and set on its own by an issueUpdate carrying nothing else. See the ruling below on the scale. |
filter_by_priority | Supported and proven. issues(filter:{priority:{in:[…]}}) over Linear’s own 0–4 scale, confirmed against each issue read. |
filter_by_comment_activity | Supported and proven. comments:{some:{or:[{createdAt:{gte:…}},{updatedAt:{gte:…}}]}} — the issues with a comment created or last edited at or after the instant, over the same two fields a comment read reports. |
filter_by_metadata | Supported and proven. description:{contains:"\"<value>\""} for each match — the value as every JSON encoder writes it, which a slot holding it contains however it spaces or spells its keys — and every candidate confirmed over the parsed slot, so prose carrying the phrase and a slot holding another value are both kept out. A value with a character an encoder may escape is not sent, and the confirmation decides alone. |
filter_by_origin | Supported and proven, on the same terms, for the slot’s onetaskgraph.origin. |
orphan_tasks | Supported and proven. issues(filter:{project:{null:true}}). |
filter_by_label | Supported and proven. labels:{some:{name:{eqIgnoreCase:…}}} for what an item must carry — one per label, gathered under or: where any one of them will do — and labels:{every:{name:{neqIgnoreCase:…}}} for what it must not. Linear’s StringComparator has no case-insensitive list operator; see the note beside filter. |
filter_by_status | Supported and proven, and spelled twice. An issue narrows by its workflow state’s name, state:{name:{eqIgnoreCase:…}}; a project by its project status’s name, status:{name:{eqIgnoreCase:…}} — a different member of a different filter over a different vocabulary — each by the names its kind’s half of status_mapping gives, and unknown by every name that half does not give. See the ruling below. |
search_title | Supported and proven. A task query’s text is title:{containsIgnoreCase:…}, every candidate confirmed by the contract’s case-insensitive substring rule; a project or document query’s text is applied by that same rule over the page Linear answered. |
search_content | Supported and proven, on the same terms, description:{containsIgnoreCase:…}, confirmed over the visible content — the trailing metadata slot Linear’s comparator also reads is not part of what the rule confirms. A title-or-content search sends the two under one or. |
task_dependencies | Supported and proven, in both directions: relations and inverseRelations. |
project_dependencies | Supported and proven, in both directions, by the project relations of the same shape. Linear types every one of them dependency; see the ruling below on the edge that has no spelling here. |
max_page_size | Supported and proven. 100; every read pages with Relay first/after. Linear’s connection maximum is 250 and its complexity budget is the tighter bound — see MAX_PAGE_SIZE. |
§Ruling: the follow-up searches are native, and what each rests on
Six predicates a follow-up search sends — metadata, origin, comment activity, priority,
and the two text searches — are each sent to Linear as a narrowing of issues(filter:)
and confirmed in process before a row is returned. Each narrowing is a candidate set that
cannot miss a row the contract’s predicate keeps, which is what makes sending it sound;
the confirmation is what makes the answer exact. Every member below is pinned in
tests/fixtures/schema.graphql, and each rests on one observation of the real API,
against the scratch team TES on 2026-10-02, which drive_follow_ups in tests/live.rs
asserts again — the comparators by name, and every narrowing through this source with a
decoy whose prose carries the searched phrases:
IssueFilter.description.containsreads the whole stored description, the metadata slot included, and is case-sensitive. An issue whose slot held"caller.key":"needle-…"was returned forcontainsof that exact phrase, and of the"onetaskgraph.origin":"…"pair beside it; the same description’s prose, upper-cased, was returned forcontainsIgnoreCaseand not forcontains. So a metadata match or an origin is sent as its value in quotes,"<value>"— the bytes any JSON encoder writes a string as, which a slot holding it contains whether it is the code span this source writes, the multi-line slot it wrote before, or one a person spaced by hand — and confirmed over the parsed slot. A value holding a character an encoder may escape is not sent, and the confirmation decides alone.IssueFilter.title.containsIgnoreCaseanddescription.containsIgnoreCasematch regardless of case — the title… Alpha Titlewas returned foralpha TITLE, and not forcontainsof it. They are the two text searches; the content search is confirmed over the visible content, because the comparator also reads the slot.IssueFilter.priority.innarrows by Linear’s own number — an issue at2was returned forin:[2]and not forin:[3].IssueFilter.comments.somewithcreatedAt/updatedAtgtenarrows by a comment’s own times — an issue whose comment had been edited a moment earlier was returned for an instant before the edit and not for one a day later, and editing the comment moved itsupdatedAtwhile leavingcreatedAt. The comment read reports those same two fields, so the filter and the contract’s rule read one value.
§Ruling: what Linear does to an HTML comment, settled
A follow-up tool marks what it writes with HTML comments, and this source keeps its own
metadata in one, so what survives is a fact this crate records rather than assumes.
Observed on 2026-10-02 against the scratch team TES, each written and read back by id,
and asserted again — the probe text and its stored form exactly — by drive_follow_ups in
tests/live.rs, a leg of its real_linear_applies_every_declared_capability_and_leaves_no_residue:
- A comment’s
bodykeeps every HTML comment byte for byte — on one line or across several, a bare-->closing line, domain-like text and JSON included. - An issue’s
descriptionand a document’scontentdo not. Linear stores both as Markdown and normalizes the text inside an HTML comment exactly as it normalizes prose: a domain-like token or a URL is autolinked —example.comcomes back[example.com](<http://example.com>), and a key such ascaller.livelikewise —[and]come back\[and\],~comes back\~,_y_comes back*y*, the backslash of\"is dropped, a backslash before a letter is doubled, and a line opening-->comes back\-->. Text with none of those in it —{"k":"v"},caller.key,sha256:…,gh:I_kwDO…— comes back as written. An autolink can run on past the token, swallowing what follows it up to the next delimiter. - One thing in those two fields comes back byte for byte: a code span. An HTML comment
on one line whose payload is inside backticks —
<!-- probe `{…}` -->— came back identical with every one of the payloads above inside it, and re-writing what Linear handed back changed nothing more. So this source writes its own slot that way (seeMETADATA_OPEN_SPAN), and a marker meant to survive an issue’s description or a document’s content belongs in one too.
§Ruling: a Linear document carries no label, and that is Linear’s
Unlike the two searches above, this one is a property of the remote service. The
types of Linear’s published schema carrying a labels field are Issue, Project,
Team, Initiative and Organization; Document is not among them, re-observed
2026-09-01 and pinned in tests/fixtures/schema.graphql. So this source reports a
document’s labels as none and refuses by name a document write carrying one, rather
than dropping it or standing a slot up beside a first-class type. The shared journey
table’s row says so, and the shared document journeys drive that claim.
Two predicates therefore reach a fetched page rather than the documents(filter:)
variables, and both are still applied — which is what Native means here, and why
the declaration stays honest. Labels, for the reason above. And orphans, because
DocumentFilter.project is a ProjectFilter where IssueFilter.project is a
NullableProjectFilter: only the nullable one carries null:, so Linear cannot be
asked for the documents belonging to no project. The page-by-page walk asks for only
what is still owed, so neither predicate can make a read return more than the caller
asked for, and neither can drop a document the walk already fetched.
§Ruling: a comment is read backwards, and its author is Linear’s to record
The order. The contract owes a task’s comments oldest first, across pages, and Linear’s
Issue.comments takes no sort direction — only orderBy, whose members are createdAt
(the default) and updatedAt. Linear’s pagination documentation says results are “ordered
by createdAt” and that “to get most recently updated resources, you can alternatively
order by updatedAt”, which reads that ordering as newest first. So this source walks the
connection from its far end: last with before, each page reversed, the next page’s
cursor being startCursor while hasPreviousPage holds. Reversing within a page and
walking backwards across them is what makes the whole walk oldest first rather than each
page alone. That direction is inferred from the documentation’s wording rather than
observed against the real API, which is the one reading here a live run has not yet
confirmed; if Linear is found to list oldest first, the correction is this walk’s
direction and nothing else.
The author. Linear records the user whose credential made the request as a comment’s
author, and this source authenticates with an API key. CommentCreateInput.createAsUser
exists but is, in Linear’s own words, “only available to OAuth applications creating
comments in actor=app mode”, which a key is not. So a comment carrying an author is
refused before any request is sent, naming why and what to do instead, rather than
posted under a name other than the one it was given. An author read back is the user’s
displayName, which Linear keeps unique within a workspace, and is absent when Linear
names no user — a comment an integration or a bot wrote.
What “no such comment” means. An edit or a removal first asks comment(id:) which
issue the comment is on, and answers “no such comment” — no mutation sent — unless it is
the task’s own issue: a comment on another issue, on no issue at all, or trashed, is not a
comment this task has. The body is Linear’s body, which its schema describes as markdown
derived from a rich-text document, so what an add or an edit answers with is what Linear
now holds rather than an echo of what was sent.
§Ruling: a project’s filter is not an issue’s, and neither is its status
Linear’s IssueFilter and ProjectFilter read as one filter over two kinds of row.
They are two input types, and this source built one object for both until 2026-09-04,
which put two members into projects(filter:) that Linear does not have there. It
refused the first outright — Field "team" is not defined by type "ProjectFilter". Did you mean "lead"? — and would have refused the second next.
A project has no team; it has the teams it is accessible from, so the configured team
reaches accessibleTeams:{some:{key:{eqIgnoreCase:…}}}. And a project’s status is not
an issue’s state: the counterpart of IssueFilter.state is ProjectFilter.status,
while ProjectFilter.state exists and is a bare StringComparator over something else.
The two do not even share a vocabulary — a project’s statuses are the workspace’s, Hello
Patient’s Idea, Proposal, Planned, Completed among them, where an issue’s states are
the team’s — which is why status_mapping names each kind’s statuses separately, and why a
filter spelled in the other level’s names matches nothing while being refused by nothing.
Neither of those could be caught by reading a document, and that is the general
lesson. A filter is built at runtime and handed over as $filter, so it appears in no
operation this crate declares, and the two pinned-schema checks that parse those
operations could not see it — Linear was the only reader, one refusal per round trip.
every_variables_object_this_source_sends_conforms_to_the_pinned_schema closes that:
it drives this source’s whole surface, records what really went out, and walks every
variables object against the pinned type of the argument it stands at.
§Ruling: a Linear project relation is always an ordering
This one is Linear’s too, and the validator says so in as many words. Asked on
2026-09-04 for a project relation typed related — and separately blocks and
dependsOn — the real API refused each with Argument Validation Error and
constraints: {"isEnum": "type must be one of the following values: dependency"}. That
enumeration has one member and it is a timeline dependency, which is why the input
carries an anchor at each end at all.
So a project edge carrying no ordering has nowhere here to land, and this source
refuses it by name before the write rather than sending a value Linear will reject
or quietly promoting it to a dependency it does not mean. DependencyKind::Related
keeps its issue-level spelling, related, because IssueRelationCreateInput really
does take it: the two relations are different relations with different vocabularies,
and each level’s read accepts only its own.
Which end of a project relation waits is carried by the two anchors and not by the two
id slots — measured, not reasoned, from Linear’s own ProjectFilter.hasBlockedByRelations
against relations written both ways round. tests/fixtures/README.md records the whole
probe, and write_relations records why the pair this source sends is the oriented one.
Caller metadata is canonical JSON in a trailing
<!-- onetaskgraph.metadata `…` --> Markdown comment in the item’s long form — an
issue’s description, a project’s or a document’s content — on one
line with the JSON in a code span — the one spelling Linear keeps byte for byte, see the
ruling above; the multi-line spelling this source wrote before is still read. The visible
description is returned unchanged without that slot. Writes put the same canonical
encoding back beside the visible description, and use Linear issue/project relations for
same-source dependencies. Only cross-source far ends use the reserved
onetaskgraph.depends_on metadata key.
§Ruling: a status is the name status_mapping gives the item’s kind, and nothing else
Linear has no built-in names: a team’s workflow states and a workspace’s project statuses
are whatever the people who own them called them, and a type says nothing about which of
several states of it a category means — Todo and Queued are both unstarted. So the
source’s status_mapping (StatusMapping) is the whole of what a status is written as
and read by: a task’s names are the configured team’s workflow states, a project’s the
workspace’s project statuses.
A write of a category is the name the mapping gives the kind of the item being written —
set_task_status, the targeted update and write_task for a task, so task create and every
copy; write_project for a project, whose own status name plays no part. A write the mapping
gives that kind no name for — a category it does not mention, one set to null, one a
per-kind object leaves out — is refused before any request, naming the source, the kind, the
category and the key to set; one whose name that kind’s vocabulary does not hold is refused
naming the name, after the resolution and before any mutation. Nothing falls back by type, or
by the name a status was called where it came from, and a source with no mapping refuses
every status write.
A read of an item at a name its kind’s mapping gives is that category, under the name;
every other name reads as unknown, under its own name, whatever its type — the review
states only people write, Triage among them. filter_by_status returns exactly the items
that read as each category asked for: those at the name the kind’s mapping gives it, and for
unknown every item at a name that mapping does not give at all. Each is confirmed in process
as well, so a row reading as another category is never returned.
§Ruling: the resolution is read once per source instance, and a status write reads nothing
A Linear key is shared by every manager of a host, so what a status write costs is counted at
Linear’s endpoint, and crates/onetaskgraph-linear/budgets.yaml holds it. A source resolves
the configured team’s id, its workflow states and the workspace’s project statuses in one
request — graphql::RESOLUTION — the first time a write or sources fields needs them, and
holds the answer for its own lifetime: per instance, never per process, never shared between
sources. Building a source sends nothing. A mapped name the held answer lacks is looked for
once more in a fresh read, so one added in Linear since is found; nothing a failed call
answered is held, and a write that fails while carrying a held id drops the answer, so the
next reads afresh rather than sending that id again. A name sources fields --apply creates is
added to what is held.
set_task_status, and a targeted update naming a status and nothing else, are one
issueUpdate and no read: graphql::ISSUE_UPDATE_READ selects the whole issue, which is
what the answered status — and the engine keeping a delivered task in step — need. So writing
the category an issue already reads as sends the same state again, setting unknown on an
issue at a name the mapping does not give moves it to the mapped unknown name, and an issue
Linear does not hold is no such task from the mutation’s own refusal — Entity not found —
rather than from a read. That spelling is the one Linear documents for its own input
validation; a live run has not yet re-observed it here. A source scoped to one project is no
exception: its status write goes to the issue it names wherever that issue is filed — see
the scope’s ruling below. A targeted update naming anything else
keeps its one read of the issue: the metadata slot is merged into the description Linear
holds, and writing it without that read would overwrite whatever a person wrote there since.
A whole rewrite of an issue or a project reads the relations it replaces in its own answer
(graphql::ISSUE_REWRITE, graphql::PROJECT_REWRITE) rather than in a read of its own.
§Ruling: sources fields --apply creates every name the mapping needs
For parity with a GitHub Projects board, whose --apply adds the Status options it lacks:
--apply creates each name the mapping gives a task that the team lacks, as a workflow state
(workflowStateCreate), and each name it gives a project that the workspace lacks, as a
project status (projectStatusCreate) — a bare name in both. The type of what it creates is
its category’s, by this fixed table:
| Category | Workflow state | Project status |
|---|---|---|
backlog, draft | backlog | backlog |
todo, queued | unstarted | planned |
in-progress, unknown | started | started |
done | completed | completed |
cancelled | canceled | canceled |
Both create inputs also require a colour, and a project status a place in the workspace’s
flow, and nothing in a mapping says either: every name is created in Linear’s neutral grey,
#95a2b3, and a project status after the workspace’s last. Nothing that exists is renamed, retyped or deleted, and a name present
under another type is reported with its type and left as it is. A create Linear refuses stops
the run, and the report names what it created before it. Whether to run it against a
workspace is the operator’s decision.
§Ruling: project scopes a source to one project
With project set, every issue read carries project:{id:{eq:…}} beside the team, a
project read carries id:{eq:…} and a document read the same project, and a read by id
of anything filed elsewhere answers as no such item — so a content, a metadata or a comment
write to it, each of which reads the item first, is answered the same way.
A status-only write is the exception, and goes to the item it names wherever that item is
filed. task status set, and a targeted update naming a status and nothing else, are one
issueUpdate to the issue named, with no read before it — so a scoped source meets the same
request budgets as an unscoped one — and answer the issue as it now reads, even when it is
filed in another project of the team. Linear has no update conditional on where an issue is
filed, so holding a status write to the scope would cost the read the budget refuses; the
item was named outright, and the scope is what this source reads, lists and creates rather
than where a status it is asked to set may land. Reads, listings and creation stay scoped: a
task or a document written with no project is placed in that one, and one naming another is
refused naming both. A project write other than to that project itself is refused before any
request: a project this source created would be one none of its reads could find.
§Ruling: a narrow metadata write moves only the slot, and a task carries delivery
set_task_metadata, set_project_metadata and set_document_metadata read the item and
send one update of its long-form field — description, description and content —
that differs from what Linear holds only inside the trailing metadata slot: every byte
above it is kept as it was. A key already holding the value sends nothing. The answer is
the item read back. set_task_rendering and set_document_rendering replace the content
and the slot’s onetaskgraph.template entry together in one such update, every other slot
entry kept; this source keeps no template answers. So a copy’s onetaskgraph.copies link
is recorded on a Linear item rather than reported unrecorded.
A task’s delivers and delivered_by live in that same slot under
onetaskgraph.delivers and onetaskgraph.delivered_by, each a list of qualified ids,
with the shape and the rules the GitHub Projects plugin keeps: neither may name the task
itself or name one task twice, which is refused by name before anything is sent; a write
lands the typed lists in place of any caller metadata of those names; and
set_delivered_by is one update of the slot. A project or a document naming either key is
refused, because only a task delivers or is delivered.
§Ruling: a priority is Linear’s own, and content shares a field with the slot
A task’s priority is Issue.priority, on Linear’s scale: 0 none, 1 urgent, 2 high,
3 normal — this contract’s medium — and 4 low. Linear declares the field Float!
while IssueCreateInput.priority and IssueUpdateInput.priority are Int, so a read
accepts 2 and 2.0 alike and refuses anything that is not one of the five as a
malformed response naming the field. A copy sends it on a create and on an update, 0
included, so a task moved back to no priority is not left holding its old one.
set_task_priority reads the issue first — no such issue, or a trashed one, is None
with nothing written — then sends issueUpdate with priority alone and answers with
the priority the mutation’s own payload reports.
set_task_content sends issueUpdate with description alone, and that description is
the given content followed by the issue’s metadata slot exactly as it was stored, so the
slot, and every key in it, is untouched. What a later read reports as the content is the
given bytes, trailing whitespace included: a read of an issue carrying a slot takes off only
the one blank line that sets the slot off, and a write whose content would not read back as
itself is refused before it is sent.
Fixture provenance is recorded in tests/fixtures/README.md. The live journey in
tests/live.rs drives every field of the table above against Linear itself: it builds its own fixture
on the scratch team LINEAR_WRITE_TEAM names — two projects, one issue filed under
each, one filed under neither, two labels and two workflow states — because that shape
is what tells an honoured predicate from an ignored one, and a workspace where every
issue carries the label answers a filter the same way either way. Everything the lane
creates it deletes whether its assertions passed or failed, and it clears residue named
the way it names its own before it starts. A failed live cleanup is reported as a test
failure and may require manual deletion from that scratch team.
Modules§
- graphql
- Exact GraphQL query documents issued by this plugin.
Structs§
- Linear
Config - One
linearsource’s configuration. - Mapped
Status Name - One name a
status_mappinggives one kind, and whether that kind’s vocabulary has it. - Plugin
- The Linear plugin factory.
- Refused
Create - A name
sources fields --applycould not create, and why. - Status
Names Report - What
onetaskgraph sources fieldsreports for alinearsource: every name itsstatus_mappinggives a task, checked against the configured team’s workflow states, and every name it gives a project, checked against the workspace’s project statuses.
Enums§
- Found
- Whether a kind’s vocabulary has a name of the one the mapping gives, and whether this run put it there.
Constants§
- KIND
- The plugin kind a
linearsource’splugin:field names. - MAX_
PAGE_ SIZE - The largest page this source will ask Linear for, and the capability it declares.
Functions§
- status_
names - Report every name one
linearsource’sstatus_mappinggives each kind, as present in that kind’s vocabulary with its type or missing from it — and, withapply, create every missing one first.