Receive Linear issue, comment, and project webhooks as Flyte runs.

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

linear_webhooks.py
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.

linear_webhooks.py
@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:

  1. URL: the /webhook/linear URL from the app’s dashboard.
  2. 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:

linear_tasks.py
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

linear_tasks.py
# 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_delivery

The 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