Skip to content

Commit 12f52a8

Browse files
cosmicBboyclaude
andcommitted
feat(webhooks): add flyteplugins-webhooks-jira
Receive Jira webhooks in Flyte. Implements the Provider contract from flyteplugins-webhooks-core: which environment variable holds the secret, how to verify a delivery, how to parse one into a WebhookEvent, plus typed constants for every event Jira can send. Runs the shared conformance check, which replays a real SAMPLE_DELIVERY through verify and parse rather than trusting them to agree with each other. Calling the Jira API is deliberately not this plugin's job. 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 d1b3007 commit 12f52a8

8 files changed

Lines changed: 1854 additions & 0 deletions

File tree

plugins/webhooks/jira/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# flyteplugins-webhooks-jira
2+
3+
Receive Jira webhooks in Flyte.
4+
5+
```bash
6+
pip install "flyteplugins-webhooks-core[app]" flyteplugins-webhooks-jira
7+
```
8+
9+
## Using it
10+
11+
Hand `PROVIDER` to a `WebhookAppEnvironment` and register handlers with the
12+
typed constants in `events`:
13+
14+
```python
15+
import flyte
16+
from flyteplugins.webhooks.core import DuplicateRun, WebhookAppEnvironment, idempotent_run
17+
from flyteplugins.webhooks.jira import PROVIDER, events
18+
19+
app_env = WebhookAppEnvironment(
20+
name="jira-webhooks",
21+
providers=[PROVIDER],
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 more than one `PROVIDER`.
45+
46+
## Setup
47+
48+
1. Store the secret and mount it on the app:
49+
```bash
50+
flyte create secret JIRA_WEBHOOK_TOKEN --value <secret>
51+
```
52+
2. Point Jira at `<app-url>/webhook/jira`, from
53+
Jira Settings → System → Webhooks.
54+
55+
**Verification:** **None.** Jira Cloud does not sign its webhooks.
56+
57+
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. `PROVIDER.signed` is 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.
58+
59+
## Event constants
60+
61+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
62+
event type, so a typo fails at import rather than by silently never matching.
63+
Raw strings still work, for events the constants do not cover yet.
64+
65+
## What this plugin does not do
66+
67+
Call the Jira API. Use the `jira` package directly from your tasks — see
68+
`examples/external_saas_integrations`. This plugin owns only the part that is
69+
Flyte's: authenticating an inbound delivery and turning it into a run.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
[project]
2+
name = "flyteplugins-webhooks-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+
dependencies = ["flyteplugins-webhooks-core"]
9+
10+
[build-system]
11+
requires = ["setuptools", "setuptools_scm"]
12+
build-backend = "setuptools.build_meta"
13+
14+
[dependency-groups]
15+
dev = [
16+
"pytest>=8.3.5",
17+
"pytest-asyncio>=0.26.0",
18+
"fastapi>=0.115",
19+
"uvicorn>=0.30",
20+
"httpx>=0.27",
21+
]
22+
23+
[tool.setuptools]
24+
include-package-data = true
25+
26+
[tool.setuptools.packages.find]
27+
where = ["src"]
28+
include = ["flyteplugins*"]
29+
30+
[tool.setuptools_scm]
31+
root = "../../.."
32+
33+
[tool.pytest.ini_options]
34+
norecursedirs = []
35+
log_cli = true
36+
log_cli_level = 20
37+
markers = []
38+
asyncio_mode = "auto"
39+
asyncio_default_fixture_loop_scope = "function"
40+
41+
[tool.coverage.run]
42+
branch = true
43+
44+
[tool.ruff]
45+
line-length = 120
46+
47+
[tool.ruff.lint]
48+
select = ["E", "W", "F", "I", "PLW", "YTT", "ASYNC", "C4", "T10", "EXE", "ISC", "LOG", "PIE", "Q", "RSE", "FLY", "PGH", "PLC", "PLE", "FURB", "RUF"]
49+
ignore = ["PGH003", "PLC0415"]
50+
51+
[tool.ruff.lint.per-file-ignores]
52+
"examples/*" = ["E402"]
53+
54+
[tool.uv.sources]
55+
flyte = { path = "../../..", editable = true }
56+
flyteplugins-webhooks-core = { path = "../core", 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 `PROVIDER` 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 PROVIDER, parse, verify
13+
14+
__all__ = ["PROVIDER", "SAMPLE_DELIVERY", "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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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. `PROVIDER.signed` is 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 flyteplugins.webhooks.core import (
15+
Provider,
16+
WebhookEvent,
17+
constant_time_equals,
18+
json_body,
19+
lower_headers,
20+
)
21+
22+
23+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
24+
"""Compare the `X-Webhook-Token` header against the shared token."""
25+
token = lower_headers(headers).get("x-webhook-token")
26+
return bool(token) and constant_time_equals(token.strip(), secret)
27+
28+
29+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
30+
"""Normalize a Jira delivery into a `WebhookEvent`."""
31+
payload = json_body(body)
32+
issue = payload.get("issue") or {}
33+
fields = issue.get("fields") or {}
34+
user = payload.get("user") or {}
35+
return WebhookEvent(
36+
provider="jira",
37+
event_type=payload.get("webhookEvent", "unknown"),
38+
delivery_id=str(payload.get("timestamp") or ""),
39+
resource_id=issue.get("key"),
40+
occurred_at=str(payload.get("timestamp")) if payload.get("timestamp") is not None else None,
41+
scope=(fields.get("project") or {}).get("key"),
42+
title=fields.get("summary"),
43+
actor=user.get("displayName") or user.get("name"),
44+
payload=payload,
45+
)
46+
47+
48+
#: The contract core needs to accept Jira webhooks.
49+
PROVIDER = Provider(
50+
name="jira",
51+
secret_env="JIRA_WEBHOOK_TOKEN",
52+
verify=verify,
53+
parse=parse,
54+
signed=False,
55+
setup_hint="Jira Settings -> System -> Webhooks (needs a proxy to inject X-Webhook-Token)",
56+
)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Jira webhook events, from the payload's `webhookEvent` field.
2+
3+
Some names take a `jira:` prefix and some do not — that inconsistency is Jira's.
4+
These constants carry the exact wire values so you need not remember which.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from flyteplugins.webhooks.core import EventType
10+
11+
__all__ = ["Comment", "Issue", "Project", "Sprint", "Version", "Worklog"]
12+
13+
14+
class Issue(EventType):
15+
"""Issue events. Note the `jira:` prefix, which comment events lack."""
16+
17+
CREATED = "jira:issue_created"
18+
UPDATED = "jira:issue_updated"
19+
DELETED = "jira:issue_deleted"
20+
21+
22+
class Comment(EventType):
23+
"""Comment events. These carry no `jira:` prefix."""
24+
25+
CREATED = "comment_created"
26+
UPDATED = "comment_updated"
27+
DELETED = "comment_deleted"
28+
29+
30+
class Worklog(EventType):
31+
"""Worklog events."""
32+
33+
CREATED = "worklog_created"
34+
UPDATED = "worklog_updated"
35+
DELETED = "worklog_deleted"
36+
37+
38+
class Project(EventType):
39+
"""Project events."""
40+
41+
CREATED = "project_created"
42+
UPDATED = "project_updated"
43+
DELETED = "project_deleted"
44+
45+
46+
class Version(EventType):
47+
"""Version (release) events."""
48+
49+
CREATED = "jira:version_created"
50+
UPDATED = "jira:version_updated"
51+
RELEASED = "jira:version_released"
52+
UNRELEASED = "jira:version_unreleased"
53+
DELETED = "jira:version_deleted"
54+
55+
56+
class Sprint(EventType):
57+
"""Sprint events (Jira Software)."""
58+
59+
CREATED = "sprint_created"
60+
UPDATED = "sprint_updated"
61+
STARTED = "sprint_started"
62+
CLOSED = "sprint_closed"
63+
DELETED = "sprint_deleted"
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""Every provider plugin runs the same conformance check.
2+
3+
CI fails here if this plugin drifts from the shared format: a verifier that
4+
raises instead of returning False, event constants that render as enum names, a
5+
sample delivery that no constant spells, an unstable dedupe key.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from flyteplugins.webhooks.core.testing import assert_provider_conforms
11+
12+
import flyteplugins.webhooks.jira as plugin
13+
14+
15+
def test_conformance():
16+
assert_provider_conforms(plugin)
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Jira-specific verification and normalization, beyond conformance."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
from flyteplugins.webhooks.jira import PROVIDER, events, parse, verify
8+
9+
TOKEN = "jira-token"
10+
11+
12+
def _parse(payload: dict):
13+
body = json.dumps(payload).encode()
14+
return parse({"X-Webhook-Token": TOKEN}, body)
15+
16+
17+
def test_the_shared_token_is_compared_not_a_signature():
18+
assert verify(b"anything", {"X-Webhook-Token": TOKEN}, TOKEN) is True
19+
assert verify(b"anything", {"X-Webhook-Token": "wrong"}, TOKEN) is False
20+
assert verify(b"anything", {}, TOKEN) is False
21+
22+
23+
def test_the_provider_declares_itself_unsigned():
24+
"""Jira does not sign; the dashboard says so rather than implying otherwise."""
25+
assert PROVIDER.signed is False
26+
27+
28+
def test_the_webhook_event_name_is_the_qualified_type():
29+
assert (
30+
_parse({"webhookEvent": "jira:issue_created", "issue": {"key": "PROJ-1", "fields": {}}}).qualified_type
31+
== events.Issue.CREATED
32+
)
33+
34+
35+
def test_comment_events_carry_no_jira_prefix():
36+
"""That inconsistency is Jira's; the constants carry the exact wire values."""
37+
assert (
38+
_parse({"webhookEvent": "comment_created", "issue": {"key": "PROJ-1", "fields": {}}}).qualified_type
39+
== events.Comment.CREATED
40+
)
41+
42+
43+
def test_the_project_key_comes_from_issue_fields():
44+
event = _parse(
45+
{
46+
"webhookEvent": "jira:issue_created",
47+
"issue": {"key": "PROJ-1", "fields": {"project": {"key": "PROJ"}, "summary": "A bug"}},
48+
}
49+
)
50+
assert event.scope == "PROJ"
51+
assert event.resource_id == "PROJ-1"

0 commit comments

Comments
 (0)