Skip to content

New connector source linear - #5264

Merged
jonwihl merged 14 commits into
mainfrom
wihl/source-linear
Oct 2, 2026
Merged

jonwihl merged 14 commits into
mainfrom
wihl/source-linear

Conversation

@jonwihl

@jonwihl jonwihl commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Description:

(Describe the high level scope of new or changed features)

Workflow steps:

(How does one use this feature, and how has it changed)

Notes for reviewers:

  • Archival. Archiving does not advance updatedAt, so Issues run a second archivedAt-cursored pass. Projects/Initiatives/Labels have no such filter and cannot capture archival at all — a permanent limitation to sign off on.
  • Backfill checkpoints. The checkpoint is the previous cursor value, not the last one seen, so it can never land mid-tie-group. Linear bulk edits stamp one timestamp across many rows, so this is load-bearing.

Checklist:

  • If fixing a regression, I have linked the PR that introduced the regression: [insert link]
  • If there are user-visible changes, CHANGELOG.md has been updated (see CONTRIBUTING.md)
  • If there are user-facing behavior changes, documentation has been updated (see documentation conventions), or the docs PR is linked here: [insert link]

@strix-security

strix-security Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Strix Security Review

No security issues found.

Review summary

Reviewed the new source-linear capture connector end-to-end (all 56 files in the diff, with every line of the five production source modules read plus static analysis via Bandit, Semgrep, and Gitleaks). The connector authenticates to Linear via a personal API key sent as a bare Authorization header, POSTs GraphQL queries to the hardcoded https://api.linear.app/graphql endpoint, and captures issues, projects, initiatives, and labels using timestamp cursors and per-page checkpoints. No security issues were found: GraphQL query documents are built solely from hardcoded selection/field constants with all variable data passed as parameterized GraphQL variables (no GraphQL or other injection), the API endpoint is fixed (no SSRF), the configured access_token is sops-encrypted in config.yaml and correctly flagged secret: true for log redaction (no hardcoded secret or credential exposure — the lowercase authorization header name only suppresses a warning and does not bypass value-based redaction), and error handling never echoes the credential. Bandit findings were all false positives (assertions on internal invariants, the secret: True schema flag, and the intentional empty token-type string), and Gitleaks reported no secrets. The backfill/checkpoint and archival logic is thorough and does not introduce an attacker-controllable data-flow path into any dangerous sink.

Updated for 059972c.


Reviewed by Strix
Re-run review · Configure security review settings

Adds the estuary-cdk skeleton for a Linear capture connector, plus its
authentication.

Linear is GraphQL-only over a single POST endpoint, so `api.py` carries just the
endpoint constant — streams differ by query document, not by path.

Auth is a personal API key modelled as an `ApiKey(AccessToken)` subclass behind a
`credentials_title` discriminator, so adding an OAuth arm later is not a breaking
change. Linear expects the key as a bare `Authorization: <key>` with no `Bearer`
prefix; `_token_source` sets both an empty token type and a lowercase header name
to get that on the wire without the CDK logging a missing-prefix warning on every
request.

`validate_credentials` probes `{ viewer { id } }`, which returns HTTP 401 on a bad
key, so the status code alone is a sufficient signal.
All four are Relay connections on the same endpoint taking an identical argument
set, so they share one parameterised engine; per-stream identity (root field,
selection set, cursor fields, sort capability) rides on the document model as
ClassVars.

Two API behaviours shape the design, both established by probe rather than docs:

`orderBy` is descending-only and its enum members carry null descriptions, so
direction was undocumented. Three of the four root fields also accept `sort`,
which permits an ascending walk; `issueLabels` rejects it. Windows are therefore
expressed as `(cursor, horizon]` over a fully-elapsed tick, which keeps the
exclusive lower bound safe in both directions: every row sharing the boundary
tick is drained before the checkpoint moves, so Linear's common bulk-stamped
`updatedAt` ties cannot be split across a resume.

Archiving does NOT advance `updatedAt`, so archival is invisible to an
`updatedAt`-cursored sweep. `IssueFilter` is the only filter type exposing an
`archivedAt` comparator, so Issues runs a second pass over that clock; both share
one checkpoint, which is sound because the checkpoint is a horizon and means
"both clocks swept through H". Projects, Initiatives and Labels have no such
escape and cannot observe archival at all — a documented limitation, not an
oversight.

Backfills resume from a value watermark rather than a positional offset, so a row
deleted mid-sweep renumbers nothing and cannot cause an untouched row to be
skipped. Boundaries are enforced server-side.

Selection sets contain only universally-available fields: in GraphQL an
un-entitled field errors on every page rather than arriving absent, so
`identifier` is omitted from Projects and Initiatives, where it sits behind a paid
add-on. Nested connections are excluded to stay well inside the 10,000-point
per-query cap; issue<->label membership belongs in its own child stream.
The Bruno collection is the evidence record behind the connector's design
decisions: 21 read-only requests covering pagination, ordering direction, cursor
filter boundaries, the archival capability matrix and the page-size ceiling, plus
the seeding mutations used to populate an otherwise-empty test workspace.
Account-wide constraints live in collection.bru; per-endpoint constraints sit on
the request that proves them. The pre-request script decrypts the sops config per
request and never persists the key.

The README carries the limitations a user would otherwise meet as data
corruption: archival of Projects, Initiatives and Labels is not captured and
those records will remain in the destination indefinitely with a null
archivedAt; Issues archival is captured but must be read from the tombstone
rather than inferred from cursor movement; Initiatives requires a paid plan.
Also documents the rate-limit model, including that Linear signals throttling as
HTTP 400 rather than 429 and that HTTP 200 does not imply success.
The backfill drained its entire window inside one invocation and yielded a single
watermark only after the walk completed, so a crash at any point restarted from
`start_date`. Labels was worse: its watermark update was gated behind the
ascending branch, so `backfill_labels` never yielded a cursor at all.

Split the pager out of `_walk` so page boundaries are visible, and checkpoint at
each one. The checkpoint is the last cursor value proven complete — the previous
value once a different one has been seen — because ordering only guarantees a tie
group is fully drained once a row outside it arrives. Checkpointing the last-seen
value instead would split a tie group and drop its remainder, and Linear ties are
common: a bulk edit stamps one timestamp across every row it touches. A page whose
rows all share one value checkpoints nothing and the walk continues, so a tie group
wider than one page cannot stall it.

Resumption bounds whichever end of the window the walk consumes from, so descending
streams resume correctly too rather than restarting.

Verified against a simulated pager: no rows dropped and termination reached for a
tie group straddling pages, a window that is entirely one tie value, 20 distinct
values, a single row, and an empty window. Twenty rows at three per page now
checkpoint ten times instead of once.
Cuts explanation that restated what the code already shows, or that belonged in
the connector's documentation rather than beside its field lists. The facts that
are not recoverable from reading the code are kept: which fields are excluded and
why, and that the tombstone is the only archival signal because archiving does not
advance `updatedAt`.
Adds the connector's reference page and indexes it, so the archival limitations
reach the people affected by them: a Project, Initiative or Label archived in
Linear stays in the destination indefinitely with a null `archivedAt`, because
Linear exposes no `archivedAt` filter for those types and archiving does not
advance `updatedAt`. Issues are unaffected and carry a real tombstone.

Also documents the two-budget rate limit model and that Initiatives need a paid
plan. The connector README now points at the reference page instead of repeating
it, and keeps only development and design notes.
Registers the connector in the Python workflow's path filters and build matrix.
Without this it is never built or published, so the connector cannot ship.
@estuary-docs-previews

estuary-docs-previews Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Docs preview: https://connectors-pr-5264-estuary-docs.mike-39e.workers.dev

Changed pages:

Rebuilt on every push, and the link stays the same.
Not a deploy: production is untouched until this merges.

The throttle watched only the request budget, but Linear charges complexity per
row returned rather than per request, so which of the two hourly budgets binds
depends on the workspace: sparse records exhaust requests first, records with many
populated relations exhaust complexity first while thousands of requests remain.
Airbyte's Linear connector documents complexity as the usual constraint, and our
own measurement was taken on a workspace whose rows have almost no relations set,
so it could not have surfaced this.

Exceeding either budget returns HTTP 400, which the CDK treats as terminal, so
the pre-emptive pause is the only real defence and it has to cover both.

Also drops the `ascending` argument threaded through the query builder and both
walkers. It was always `entity.supports_sort`, so it was a parameter that could
never vary independently of the entity already being passed.
@Alex-Bair
Alex-Bair requested review from Alex-Bair and removed request for Alex-Bair September 24, 2026 13:43
Applying a label stamps its `lastAppliedAt` but never advances its
`updatedAt`, so an `updatedAt` cursor missed label activity and every label
untouched since `start_date`. Labels are now re-read in full each interval,
keyed on `/_meta/row_id`, which also captures archival and deletion.

With labels gone from the incremental set, every remaining stream sorts
ascending, so the descending `orderBy` walk in the query builder and the
backfill is removed.
A page holding two or more distinct `updatedAt` values checkpointed its
second-to-last value even when no page followed. The next invocation then
re-fetched and re-emitted that page's final tie group before observing an
empty window. Every backfill finished with one redundant request and a
duplicate of its newest rows.

`_walk_pages` now reports whether another page follows, and the backfill
checkpoints only between pages. All three incremental streams share this
backfill.
@jonwihl

jonwihl commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

@strix-security

main moved the Python connector build matrix out of python.yaml and into
.github/python-connectors.yaml (c47b34a). The conflict is resolved by
taking main's python.yaml and listing source-linear in the new data file,
at the position the old matrix entry held.
…rink oversized pages

Review follow-ups on #5264:

- `pageInfo` and `hasNextPage` are required, so a response missing them
  fails validation instead of ending a walk after one page.
- A `RATELIMITED` rejection waits for the latest budget reset Linear
  announced, falling back to exponential backoff, for up to five attempts.
  One 60s retry against an hourly budget would fail again.
- A page rejected as too complex is retried at half the size. Linear
  documents no distinct error for its per-query cap, so detection matches
  the message and is marked unverified.
- Any GraphQL error now raises. No selected field is gated behind a paid
  add-on, so no partial error is expected, and emitting the rows that
  resolved would hide a resolver failure.
- Issues carry `labelIds`, so they join to the `labels` collection.
- The fetcher wiring map uses a frozen dataclass, the archival query
  asserts its clock is set, and private helpers take `log` before `http`.
- The docs description no longer says every stream is incremental.
- The docs say what a backfill does and does not reconcile, how deleted
  issues behave, which relationships are not captured, and that the
  Initiatives response on an unentitled workspace is unverified.
- The changelog's "Known limitations" section is not a Keep a Changelog
  category; its notes move to a Limitations section in the docs.
- The Bruno README and MANIFEST describe the collection where it now
  lives, with correct request counts and no links to files outside the PR.
The repo's GraphQL guidance is to start with a minimal field set and add
fields as users ask for them. Issues drop the sort orders, triage, snooze,
SLA, auto-close/archive and added-to timestamps, customerTicketCount,
branchName, and the delegate, milestone, snoozedBy and template relations.
Projects and Initiatives drop `content`, which can be large.
@midianyoseph

Copy link
Copy Markdown
Contributor

LGTM

@Alex-Bair Alex-Bair left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stamp

@jonwihl
jonwihl merged commit f601666 into main Oct 2, 2026
76 checks passed
@jonwihl
jonwihl deleted the wihl/source-linear branch October 2, 2026 17:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants