Receive Jira Cloud webhooks as Flyte runs, authenticated with a shared token.

Jira

Receive Jira Cloud webhooks in Flyte and turn them into runs.

Jira Cloud doesn’t sign its webhooks. This plugin authenticates deliveries with a shared token instead, which requires a proxy or a Jira Automation rule to add the token. Read Authentication before you expose the route.

Installation

pip install "flyteplugins-jira[app]"

Requires Python 3.10 or later. The app extra adds fastapi and uvicorn for serving the receiver.

Authentication

The provider checks for a shared token in the X-Webhook-Token header, using a constant-time comparison. JiraProvider reports signed=False, and the setup dashboard shows that the route isn’t signature-verified.

A shared token is weaker than a signature:

  • The same token is sent with every request. Anyone who obtains it can forge deliveries.
  • It doesn’t cover the body. Anything between Jira and the app can change the payload undetected.

Jira webhooks can’t send custom headers, so you need one of these to add X-Webhook-Token:

  • An API gateway or ingress rule in front of the app that adds the header to requests from Jira’s IP ranges.
  • A Jira Automation rule that uses the Send web request action, which can set custom headers. This is the simpler option if your events are available as Automation triggers.

Without the header, the app rejects every delivery, because require_signature defaults to True.

Don’t disable verification in production

Setting require_signature=False makes the route launch runs for any request. Use it only for local development.

Set a narrow scopes allowlist as well.

The receiver

jira_webhooks.py
import flyte
from flyte.extras.webhooks import WebhookAppEnvironment, WebhookEvent, run_once
from flyteplugins.jira import JiraProvider, events

# JIRA_WEBHOOK_TOKEN is mounted automatically. It's a shared token, sent with
# every request: anyone who has it can post a delivery. Restrict `scopes` to
# the projects the app should act on.
app_env = WebhookAppEnvironment(
    name="jira-webhooks",
    providers=[JiraProvider()],
    # Jira project keys.
    scopes=["PROJ"],
    image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-jira[app]"),
    resources=flyte.Resources(cpu=1, memory="512Mi"),
)

The provider reads the token from JIRA_WEBHOOK_TOKEN, which the app mounts automatically. Jira doesn’t issue this token, so generate one yourself:

flyte create secret jira-webhook-token --value "$(openssl rand -hex 32)"
jira_webhooks.py
@app_env.on_event(events.Issue.CREATED)
async def on_issue_created(event: WebhookEvent) -> dict:
    """Launch triage once per new issue.

    `event.resource_id` is the issue key, such as `PROJ-1`.
    """
    import flyte.remote as remote

    task = remote.Task.get(name="jira-ops.triage_issue", auto_version="latest")
    result = await run_once.aio(
        task,
        key=event.dedupe_key(),
        issue_key=event.resource_id,
    )
    return {"run": result.run.name, "created": result.created}

Set up the webhook in Jira

Go to Settings → System → Webhooks → Create a WebHook and set:

  1. URL: the /webhook/jira URL from the app’s dashboard.
  2. Events: the events your handlers match. Optionally, add a JQL filter.

Then configure your proxy or Automation rule to add X-Webhook-Token.

Events

Constants live in flyteplugins.jira.events. Jira prefixes some event names (jira:issue_created) but not others (comment_created); the constants handle both.

Class Members
Issue CREATED, UPDATED, DELETED
Comment CREATED, UPDATED, DELETED
Worklog CREATED, UPDATED, DELETED
Project CREATED, UPDATED, DELETED
Version CREATED, UPDATED, RELEASED, UNRELEASED, DELETED
Sprint CREATED, UPDATED, STARTED, CLOSED, DELETED

Scope and deduplication

scope is the project key, such as PROJ. resource_id is the issue key, such as PROJ-1. occurred_at is Jira’s timestamp field.

The task it launches

jira_tasks.py
env = flyte.TaskEnvironment(
    name="jira-ops",
    image=flyte.Image.from_debian_base().with_pip_packages("jira"),
    secrets=[
        flyte.Secret(key="jira-url", as_env_var="JIRA_URL"),
        flyte.Secret(key="jira-email", as_env_var="JIRA_EMAIL"),
        flyte.Secret(key="jira-api-token", as_env_var="JIRA_API_TOKEN"),
    ],
    resources=flyte.Resources(cpu=1, memory="512Mi"),
)

@env.task
async def triage_issue(issue_key: str) -> str:
    """Comment on an issue and move it to In Progress.

    Transition IDs differ between workflows, so this looks up the transition
    by name instead of hard-coding an ID.
    """
    import asyncio
    import os

    from jira import JIRA

    def _work() -> str:
        client = JIRA(
            server=os.environ["JIRA_URL"],
            basic_auth=(os.environ["JIRA_EMAIL"], os.environ["JIRA_API_TOKEN"]),
        )
        issue = client.issue(issue_key)
        client.add_comment(issue, "Triaged by Flyte.")
        for transition in client.transitions(issue):
            if transition["name"].lower() == "in progress":
                client.transition_issue(issue, transition["id"])
                return transition["name"]
        return "no matching transition"

    # The `jira` client is synchronous; run it off the event loop.
    return await asyncio.to_thread(_work)

The example looks up transitions by name rather than by ID. Transition IDs differ between workflows, so a hard-coded ID breaks when someone edits the project’s workflow.

The jira client is synchronous, so the example calls it through asyncio.to_thread to avoid blocking the event loop.

Authenticate with an Atlassian account email and an API token, using HTTP basic auth.

Test without a Jira site

jira_tasks.py
# A separate environment with no secrets, so the replay runs before any
# secret is created.
replay_env = flyte.TaskEnvironment(
    name="jira-replay",
    image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-jira"),
    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.

    Jira doesn't sign deliveries, so `verify` compares a shared token in the
    `X-Webhook-Token` header. The sample's sign function only sets that header.
    """
    import flyteplugins.jira as plugin

    secret = "a-shared-webhook-token"
    sign, body = plugin.SAMPLE_DELIVERY
    headers = sign(body, secret)

    assert plugin.verify(body, headers, secret), "the right token must verify"
    assert not plugin.verify(body, headers, "wrong-token"), "a wrong token must not"

    event = plugin.parse(headers, body)
    return {
        # For example, `jira:issue_created`. Some Jira event names have the
        # `jira:` prefix and some don't.
        "qualified_type": event.qualified_type,
        "scope": event.scope or "",
        "resource_id": event.resource_id or "",
        "title": event.title or "",
        "dedupe_key": event.dedupe_key(),
        # False for Jira. The setup dashboard shows this too.
        "provider_signs_deliveries": str(plugin.JiraProvider().signed),
    }
flyte run --local jira_tasks.py replay_sample_delivery

In this replay, verify compares the shared token; there’s no signature to check. The output’s provider_signs_deliveries is False.

Examples

Both files are in v2/integrations/flyte-plugins/jira:

  • jira_webhooks.py: the receiver and an issue-created handler.
  • jira_tasks.py: commenting, transitioning, and the offline replay.

See also