Linear
Receive Linear webhooks in Flyte and turn them into runs.
Installation
pip install "flyteplugins-linear[app]"Requires flyteplugins-linear 2.10.7 or later and Python 3.10 or later. Earlier releases read the wrong signature header and reject every delivery. The app extra adds fastapi and uvicorn for serving the receiver.
The receiver
import flyte
from flyte.extras.webhooks import WebhookAppEnvironment, WebhookEvent, run_once
from flyteplugins.linear import LinearProvider, events
# LINEAR_WEBHOOK_SECRET is mounted automatically.
#
# `scopes` lists Linear team IDs. For Comment and Reaction events, the provider
# reads the team ID from the related issue.
app_env = WebhookAppEnvironment(
name="linear-webhooks",
providers=[LinearProvider()],
scopes=["team-000"],
image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-linear[app]"),
resources=flyte.Resources(cpu=1, memory="512Mi"),
)
The provider reads its secret from LINEAR_WEBHOOK_SECRET, which the app mounts automatically. It verifies an HMAC-SHA256 signature in the Linear-Signature header. The header name has no X- prefix.
@app_env.on_event(events.Issue.CREATE)
async def on_issue_created(event: WebhookEvent) -> dict:
"""Launch triage once per new issue.
The constant `Issue.CREATE` matches the `qualified_type` `Issue.create`.
"""
import flyte.remote as remote
task = remote.Task.get(name="linear-triage.triage_issue", auto_version="latest")
result = await run_once.aio(
task,
key=event.dedupe_key(),
issue_id=event.resource_id,
title=event.title or "",
)
return {"run": result.run.name, "created": result.created}
Set up the webhook in Linear
Go to Settings → API → Webhooks → New webhook and set:
- URL: the
/webhook/linearURL from the app’s dashboard. - Resource types: the entities your handlers match.
Linear generates a signing secret for the webhook. Store it as the linear-webhook-secret Flyte secret.
Events
Linear separates type and action, so qualified_type has the form Issue.create. Constants live in flyteplugins.linear.events. Every class has ANY, CREATE, UPDATE, and REMOVE.
| Class | Covers |
|---|---|
Issue |
Issues |
Comment |
Comments on issues |
IssueLabel |
Labels |
Project |
Projects |
ProjectUpdate |
Project updates |
Cycle |
Cycles |
Reaction |
Reactions |
Attachment |
Attachments |
Scope and deduplication
scope is the team ID. Comment and Reaction payloads have no top-level team ID, so the provider reads it from the issue the comment or reaction belongs to.
resource_id is the entity’s UUID. occurred_at is the entity’s updatedAt, or the payload’s createdAt for entities without one, so each edit to an issue gets its own dedupe key.
The task it launches
Linear’s API is a single GraphQL endpoint. The example calls it with the gql client:
env = flyte.TaskEnvironment(
name="linear-triage",
image=flyte.Image.from_debian_base().with_pip_packages("gql[httpx]"),
secrets=[flyte.Secret(key="linear-api-key", as_env_var="LINEAR_API_KEY")],
resources=flyte.Resources(cpu=1, memory="512Mi"),
)
@env.task
async def triage_issue(issue_id: str, title: str) -> str:
"""Comment on an issue."""
import os
from gql import Client, gql
from gql.transport.httpx import HTTPXAsyncTransport
transport = HTTPXAsyncTransport(
url="https://api.linear.app/graphql",
# Linear expects the API key with no "Bearer " prefix.
headers={"Authorization": os.environ["LINEAR_API_KEY"]},
)
mutation = gql(
"""
mutation Comment($issueId: String!, $body: String!) {
commentCreate(input: {issueId: $issueId, body: $body}) { success }
}
"""
)
async with Client(transport=transport) as session:
result = await session.execute(
mutation,
variable_values={"issueId": issue_id, "body": f"Triaged by Flyte: {title}"},
)
return str(result["commentCreate"]["success"])
Pass the API key in the Authorization header without a Bearer prefix. Create a key under Settings → API → Personal API keys.
Test without a Linear workspace
# A separate environment with no secrets, so the replay runs before any
# secret is created.
replay_env = flyte.TaskEnvironment(
name="linear-replay",
image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-linear"),
resources=flyte.Resources(cpu=1, memory="512Mi"),
)
@replay_env.task
async def replay_sample_delivery() -> dict[str, str]:
"""Verify and parse the sample delivery bundled with the plugin."""
import flyteplugins.linear as plugin
secret = "a-test-signing-secret"
sign, body = plugin.SAMPLE_DELIVERY
headers = sign(body, secret)
assert plugin.verify(body, headers, secret), "a correctly signed delivery must verify"
assert not plugin.verify(body, headers, "wrong-secret"), "a bad signature must not"
# Check the header name too. The sample's headers come from the plugin, so
# the round trip above passes whatever the header is called. Linear sends
# `Linear-Signature`, with no `X-` prefix.
assert list(headers) == ["Linear-Signature"], f"unexpected signature header: {list(headers)}"
assert not plugin.verify(body, {"X-Linear-Signature": headers["Linear-Signature"]}, secret), (
"X-Linear-Signature must not verify"
)
event = plugin.parse(headers, body)
return {
# `Issue.create`: Linear sends the type and action separately.
"qualified_type": event.qualified_type,
"scope": event.scope or "",
"title": event.title or "",
"url": event.url or "",
"dedupe_key": event.dedupe_key(),
# The header that carries the signature.
"signature_header": next(iter(headers)),
}
flyte run --local linear_tasks.py replay_sample_deliveryThe replay also asserts that the signature arrives in Linear-Signature. A sign-and-verify round trip alone can’t detect a wrong header name, because the sample’s headers come from the same plugin. If you write your own provider, add the same check.
Examples
Both files are in v2/integrations/flyte-plugins/linear:
linear_webhooks.py: the receiver and an issue-created handler.linear_tasks.py: commenting over GraphQL, and the offline replay.
See also
- Software development tools for the event model,
run_once, and scopes. - Event-driven automation for the pattern across Linear, Jira, and ClickUp.
- Linear API reference.