Skip to content

Commit c43099a

Browse files
cosmicBboyclaude
andcommitted
feat(jira): add flyteplugins-jira
Receive Jira webhooks in Flyte. Exports JiraProvider, a Provider subclass with its defaults pre-wired, so wiring it up reads: WebhookAppEnvironment(providers=[JiraProvider()]) The receiver itself ships with flyte, at flyte.extras.webhooks; this package contributes only what is specific to Jira -- which environment variable holds the secret, how to verify a delivery, how to parse one into a WebhookEvent, and typed constants for every event Jira sends. examples/jira_webhooks.py runs with no Jira account at all: --local replays this plugin's own SAMPLE_DELIVERY through the app, so you can watch a delivery be verified, normalized, and dispatched before wiring anything up. The shared conformance check exercises that same sample. The package is named for the product rather than for webhooks, so client methods can land here later when they earn their place -- returning flyte.io.File instead of an inline megabyte payload, rendering into the task report, or participating in caching and fan-out. Plain API passthrough belongs in the vendor's own SDK, called directly from a task. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VKZrTNjjWVzTZUxDFbn4Nk Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com>
1 parent 3ad4f51 commit c43099a

9 files changed

Lines changed: 2004 additions & 0 deletions

File tree

plugins/jira/README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# flyteplugins-jira
2+
3+
Receive Jira webhooks in Flyte.
4+
5+
```bash
6+
pip install "flyteplugins-jira[app]"
7+
```
8+
9+
## Using it
10+
11+
Hand a `JiraProvider()` to a `WebhookAppEnvironment` and register handlers with the
12+
typed constants in `events`:
13+
14+
```python
15+
import flyte
16+
from flyte.extras.webhooks import DuplicateRun, WebhookAppEnvironment, idempotent_run
17+
from flyteplugins.jira import JiraProvider, events
18+
19+
app_env = WebhookAppEnvironment(
20+
name="jira-webhooks",
21+
providers=[JiraProvider()],
22+
secrets=[flyte.Secret("JIRA_WEBHOOK_TOKEN", as_env_var="JIRA_WEBHOOK_TOKEN")],
23+
)
24+
25+
26+
@app_env.on_event(events.Issue.CREATED)
27+
async def handle(event):
28+
import flyte.remote as remote
29+
30+
task = remote.Task.get(name="my-env.my_task", auto_version="latest")
31+
try:
32+
run = await idempotent_run.aio(task, key=event.dedupe_key(), resource=event.resource_id)
33+
except DuplicateRun as exc:
34+
return {"skipped": str(exc)}
35+
return {"run": run.name}
36+
37+
38+
flyte.serve(app_env)
39+
```
40+
41+
Handlers must `await idempotent_run.aio(...)`. The blocking form stalls the
42+
app's event loop, and Jira times deliveries out in seconds.
43+
44+
One app can serve several products at once — hand it one provider per product.
45+
46+
## Try it
47+
48+
`examples/jira_webhooks.py` runs two ways. The first needs no Jira account:
49+
50+
```bash
51+
python examples/jira_webhooks.py --local # replay a real sample delivery in-process
52+
python examples/jira_webhooks.py # deploy the receiver to Flyte
53+
```
54+
55+
`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
56+
test client, so you see a delivery verified, normalized, and dispatched — plus
57+
an unsigned one refused with a 401, and the same delivery replayed to show the
58+
dedupe key is stable.
59+
60+
## Setup
61+
62+
1. Store the secret and mount it on the app:
63+
```bash
64+
flyte create secret JIRA_WEBHOOK_TOKEN --value <secret>
65+
```
66+
2. Point Jira at `<app-url>/webhook/jira`, from
67+
Jira Settings → System → Webhooks.
68+
69+
**Verification:** **None.** Jira Cloud does not sign its webhooks.
70+
71+
Because there is no signature, this plugin authenticates with a shared token in `X-Webhook-Token` — which something in front of the app has to inject, since Jira cannot send custom headers. `JiraProvider` reports `signed=False`, so the dashboard says the product does not sign rather than implying a guarantee that is absent. A shared token also cannot detect body tampering, only that the sender knew the token.
72+
73+
## Event constants
74+
75+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
76+
event type, so a typo fails at import rather than by silently never matching.
77+
Raw strings still work, for events the constants do not cover yet.
78+
79+
## What this plugin does not do
80+
81+
Call the Jira API. Use the `jira` package directly from your tasks — see
82+
`examples/external_saas_integrations`. This plugin owns only the part that is
83+
Flyte's: authenticating an inbound delivery and turning it into a run.
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Receive Jira webhooks in Flyte, and see one arrive without leaving your laptop.
2+
3+
Two ways to run this. The second needs no Jira account at all:
4+
5+
python jira_webhooks.py --local # replay a real sample delivery in-process
6+
python jira_webhooks.py # deploy the receiver to Flyte
7+
8+
`--local` runs the app through FastAPI's test client and posts this plugin's
9+
`SAMPLE_DELIVERY` — a `jira:issue_created` delivery — signed with a throwaway secret. You see the
10+
delivery verified, normalized, and dispatched to a handler, which is the whole
11+
path a real webhook takes.
12+
13+
To receive real events, deploy it and point Jira at `<app-url>/webhook/jira`
14+
from Jira Settings -> System -> Webhooks.
15+
16+
Setup for the real thing:
17+
flyte create secret JIRA_WEBHOOK_TOKEN --value <secret>
18+
19+
Jira does not sign webhooks, so this is a token you invent. Something in front
20+
of the app has to inject it as `X-Webhook-Token`, since Jira cannot send custom
21+
headers itself.
22+
"""
23+
24+
import os
25+
import sys
26+
27+
import flyte
28+
from flyte.extras.webhooks import WebhookAppEnvironment
29+
30+
from flyteplugins.jira import DEFAULT_SECRET_ENV, SAMPLE_DELIVERY, JiraProvider, events
31+
32+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("flyteplugins-jira[app]")
33+
34+
app_env = WebhookAppEnvironment(
35+
name="jira-webhooks",
36+
providers=[JiraProvider()],
37+
image=image,
38+
secrets=[flyte.Secret(DEFAULT_SECRET_ENV, as_env_var=DEFAULT_SECRET_ENV)],
39+
)
40+
41+
42+
@app_env.on_event(events.Issue.CREATED)
43+
async def on_primary(event):
44+
"""React to the event this plugin's sample delivery carries.
45+
46+
Returning a dict is enough to see the path working. To do real work, launch
47+
a deployed task instead — see `launch_a_task` below.
48+
"""
49+
return {
50+
"saw": event.qualified_type,
51+
"resource": event.resource_id,
52+
"title": event.title,
53+
# The key `idempotent_run` would dedupe on. Replaying the same delivery
54+
# produces the same key, which is what makes a redelivery a no-op.
55+
"dedupe_key": event.dedupe_key(),
56+
}
57+
58+
59+
@app_env.on_event(events.Comment.CREATED)
60+
async def on_secondary(event):
61+
"""A second handler, to show dispatch picking the right one per event."""
62+
return {"saw": event.qualified_type, "resource": event.resource_id}
63+
64+
65+
async def launch_a_task(event):
66+
"""What a handler looks like once it does real work.
67+
68+
Not registered above, because it needs `jira-tickets.triage_issue` deployed first
69+
and a Flyte backend to launch into. Wire it up with:
70+
71+
@app_env.on_event(events.Issue.CREATED)
72+
73+
`idempotent_run` refuses to launch when a run carrying the same dedupe key
74+
is already live or has succeeded, so Jira redelivering an event — which
75+
it does on any non-2xx — never starts a second run.
76+
"""
77+
import flyte.remote as remote
78+
from flyte.extras.webhooks import DuplicateRun, idempotent_run
79+
80+
task = remote.Task.get(name="jira-tickets.triage_issue", auto_version="latest")
81+
try:
82+
# Always `.aio`: the blocking form stalls the app's event loop, and
83+
# webhook senders time deliveries out in seconds.
84+
run = await idempotent_run.aio(task, key=event.dedupe_key(), issue_key=event.resource_id)
85+
except DuplicateRun as exc:
86+
return {"skipped": str(exc)}
87+
return {"run": run.name}
88+
89+
90+
def _try_locally() -> None:
91+
"""Post this plugin's sample delivery to the app, in-process."""
92+
from fastapi.testclient import TestClient
93+
94+
secret = os.environ.setdefault(DEFAULT_SECRET_ENV, "local-trial-secret")
95+
build_headers, body = SAMPLE_DELIVERY
96+
client = TestClient(app_env.app)
97+
98+
print("POST /webhook/jira (signed with a throwaway secret)")
99+
response = client.post("/webhook/jira", content=body, headers=build_headers(body, secret))
100+
print(f" {response.status_code} {response.json()}\n")
101+
102+
print("the same delivery again — note the identical dedupe_key:")
103+
again = client.post("/webhook/jira", content=body, headers=build_headers(body, secret))
104+
print(f" {again.status_code} {again.json()}\n")
105+
106+
print("an unsigned delivery is refused:")
107+
bad = client.post("/webhook/jira", content=body, headers={})
108+
print(f" {bad.status_code} {bad.json()}\n")
109+
110+
print("normalized events the app has seen:")
111+
for seen in client.get("/api/events").json():
112+
print(f" {seen['provider']} {seen['qualified_type']} resource={seen['resource_id']}")
113+
114+
115+
if __name__ == "__main__":
116+
if "--local" in sys.argv:
117+
_try_locally()
118+
else:
119+
flyte.init_from_config()
120+
handle = flyte.serve(app_env)
121+
handle.activate(wait=True)
122+
print(f"Dashboard ready at {handle.endpoint}")
123+
print(f"Point Jira at {handle.endpoint}/webhook/jira")

plugins/jira/pyproject.toml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
[project]
2+
name = "flyteplugins-jira"
3+
dynamic = ["version"]
4+
description = "Receive Jira webhooks in Flyte."
5+
readme = "README.md"
6+
authors = [{ name = "Flyte Contributors" }]
7+
requires-python = ">=3.10"
8+
# The webhook receiver lives in flyte itself, at flyte.extras.webhooks.
9+
dependencies = ["flyte"]
10+
11+
[project.optional-dependencies]
12+
app = ["fastapi>=0.115", "uvicorn>=0.30"]
13+
14+
[build-system]
15+
requires = ["setuptools", "setuptools_scm"]
16+
build-backend = "setuptools.build_meta"
17+
18+
[dependency-groups]
19+
dev = [
20+
"pytest>=8.3.5",
21+
"pytest-asyncio>=0.26.0",
22+
"fastapi>=0.115",
23+
"uvicorn>=0.30",
24+
"httpx>=0.27",
25+
]
26+
27+
[tool.setuptools]
28+
include-package-data = true
29+
30+
[tool.setuptools.packages.find]
31+
where = ["src"]
32+
include = ["flyteplugins*"]
33+
34+
[tool.setuptools_scm]
35+
root = "../../"
36+
37+
[tool.pytest.ini_options]
38+
norecursedirs = []
39+
log_cli = true
40+
log_cli_level = 20
41+
markers = []
42+
asyncio_mode = "auto"
43+
asyncio_default_fixture_loop_scope = "function"
44+
45+
[tool.coverage.run]
46+
branch = true
47+
48+
[tool.ruff]
49+
line-length = 120
50+
51+
[tool.ruff.lint]
52+
select = ["E", "W", "F", "I", "PLW", "YTT", "ASYNC", "C4", "T10", "EXE", "ISC", "LOG", "PIE", "Q", "RSE", "FLY", "PGH", "PLC", "PLE", "FURB", "RUF"]
53+
ignore = ["PGH003", "PLC0415"]
54+
55+
[tool.ruff.lint.per-file-ignores]
56+
"examples/*" = ["E402"]
57+
58+
[tool.uv.sources]
59+
flyte = { path = "../../", editable = true }
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""Jira webhooks for Flyte.
2+
3+
Hand a `JiraProvider()` to a `WebhookAppEnvironment` and register handlers with the
4+
typed constants in `events`. Calling the Jira API is not this plugin's job — use
5+
the `jira` package from your tasks. See `examples/external_saas_integrations`.
6+
7+
Note Jira does not sign its webhooks; see `_provider` for what this plugin does
8+
instead.
9+
"""
10+
11+
from . import events
12+
from ._provider import DEFAULT_SECRET_ENV, JiraProvider, parse, verify
13+
14+
__all__ = ["DEFAULT_SECRET_ENV", "SAMPLE_DELIVERY", "JiraProvider", "events", "parse", "verify"]
15+
16+
17+
def _sample_headers(body: bytes, secret: str) -> dict[str, str]:
18+
# No signature to compute: Jira sends a static shared token.
19+
return {"X-Webhook-Token": secret}
20+
21+
22+
#: A real `jira:issue_created` delivery, trimmed to the fields the parser reads.
23+
SAMPLE_DELIVERY = (
24+
_sample_headers,
25+
(
26+
b'{"webhookEvent": "jira:issue_created", "timestamp": 1700000000000,'
27+
b' "user": {"displayName": "Bob"},'
28+
b' "issue": {"key": "PROJ-1", "id": "10001",'
29+
b' "fields": {"summary": "A bug", "project": {"key": "PROJ"}}}}'
30+
),
31+
)
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Jira webhook verification and payload normalization.
2+
3+
Jira Cloud does **not** sign its webhooks. There is no HMAC to check, so this
4+
plugin authenticates with a shared token in `X-Webhook-Token` — which something
5+
in front of the app has to inject, because Jira itself cannot send custom
6+
headers. `JiraProvider` reports `signed=False` so the dashboard says so plainly rather than
7+
implying a guarantee that is not there.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
from typing import Mapping
13+
14+
from flyte.extras.webhooks import (
15+
Provider,
16+
WebhookEvent,
17+
constant_time_equals,
18+
json_body,
19+
lower_headers,
20+
)
21+
22+
#: Environment variable this provider reads its secret from by default.
23+
DEFAULT_SECRET_ENV = "JIRA_WEBHOOK_TOKEN"
24+
25+
26+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
27+
"""Compare the `X-Webhook-Token` header against the shared token."""
28+
token = lower_headers(headers).get("x-webhook-token")
29+
if not token:
30+
return False
31+
return constant_time_equals(token.strip(), secret)
32+
33+
34+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
35+
"""Normalize a Jira delivery into a `WebhookEvent`."""
36+
payload = json_body(body)
37+
issue = payload.get("issue") or {}
38+
fields = issue.get("fields") or {}
39+
user = payload.get("user") or {}
40+
return WebhookEvent(
41+
provider="jira",
42+
event_type=payload.get("webhookEvent", "unknown"),
43+
delivery_id=str(payload.get("timestamp") or ""),
44+
resource_id=issue.get("key"),
45+
occurred_at=str(payload.get("timestamp")) if payload.get("timestamp") is not None else None,
46+
scope=(fields.get("project") or {}).get("key"),
47+
title=fields.get("summary"),
48+
actor=user.get("displayName") or user.get("name"),
49+
payload=payload,
50+
)
51+
52+
53+
class JiraProvider(Provider):
54+
"""Jira's webhook provider, with its defaults pre-wired.
55+
56+
```python
57+
from flyte.extras.webhooks import WebhookAppEnvironment
58+
from flyteplugins.jira import JiraProvider
59+
60+
app_env = WebhookAppEnvironment(name="webhooks", providers=[JiraProvider()])
61+
```
62+
63+
Jira does not sign its webhooks, so this provider authenticates with a
64+
shared token instead and reports `signed=False` — which is what makes the
65+
dashboard say so rather than implying a guarantee that is absent.
66+
67+
Args:
68+
secret_env: Environment variable holding the secret, mounted from a
69+
`flyte.Secret`. Override only if you store it under a non-standard
70+
name; the default is what the docs and examples assume.
71+
"""
72+
73+
def __init__(self, *, secret_env: str = DEFAULT_SECRET_ENV) -> None:
74+
super().__init__(
75+
name="jira",
76+
secret_env=secret_env,
77+
verify=verify,
78+
parse=parse,
79+
signed=False,
80+
setup_hint="Jira Settings -> System -> Webhooks (needs a proxy to inject X-Webhook-Token)",
81+
)

0 commit comments

Comments
 (0)