Skip to content

Commit d1b3007

Browse files
cosmicBboyclaude
andcommitted
feat(webhooks): add flyteplugins-webhooks-clickup
Receive ClickUp 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 ClickUp 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 ClickUp 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 68f65cb commit d1b3007

8 files changed

Lines changed: 1839 additions & 0 deletions

File tree

plugins/webhooks/clickup/README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
# flyteplugins-webhooks-clickup
2+
3+
Receive ClickUp webhooks in Flyte.
4+
5+
```bash
6+
pip install "flyteplugins-webhooks-core[app]" flyteplugins-webhooks-clickup
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.clickup import PROVIDER, events
18+
19+
app_env = WebhookAppEnvironment(
20+
name="clickup-webhooks",
21+
providers=[PROVIDER],
22+
secrets=[flyte.Secret("CLICKUP_WEBHOOK_SECRET", as_env_var="CLICKUP_WEBHOOK_SECRET")],
23+
)
24+
25+
26+
@app_env.on_event(events.Task.STATUS_UPDATED)
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 ClickUp 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 CLICKUP_WEBHOOK_SECRET --value <secret>
51+
```
52+
2. Point ClickUp at `<app-url>/webhook/clickup`, from
53+
Space Settings → Integrations → Webhooks (it shows the signing secret on creation).
54+
55+
**Verification:** HMAC-SHA256 over the raw body (`X-Clickup-Signature`).
56+
57+
The list id is at the top level on list-scoped events and on the nested task for task-scoped ones; the parser reads both.
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 ClickUp API. Use `httpx` — ClickUp ships no Python SDK, and its API is a handful of REST calls 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-clickup"
3+
dynamic = ["version"]
4+
description = "Receive ClickUp 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+
"""ClickUp webhooks for Flyte.
2+
3+
Hand `PROVIDER` to a `WebhookAppEnvironment` and register handlers with the
4+
typed constants in `events`. Calling the ClickUp API is not this plugin's job —
5+
it is a handful of REST calls, so use `httpx` from your tasks. See
6+
`examples/external_saas_integrations`.
7+
"""
8+
9+
import hashlib
10+
import hmac
11+
12+
from . import events
13+
from ._provider import PROVIDER, parse, verify
14+
15+
__all__ = ["PROVIDER", "SAMPLE_DELIVERY", "events", "parse", "verify"]
16+
17+
18+
def _sample_headers(body: bytes, secret: str) -> dict[str, str]:
19+
return {"X-Clickup-Signature": hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()}
20+
21+
22+
#: A real `taskCreated` delivery, trimmed to the fields the parser reads.
23+
SAMPLE_DELIVERY = (
24+
_sample_headers,
25+
(
26+
b'{"event": "taskCreated", "task_id": "abc123", "list_id": "9000",'
27+
b' "webhook_id": "wh-000", "timestamp": 1700000000000,'
28+
b' "task": {"id": "abc123", "name": "Fix the thing",'
29+
b' "url": "https://app.clickup.com/t/abc123"}}'
30+
),
31+
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
"""ClickUp webhook verification and payload normalization."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Mapping
6+
7+
from flyteplugins.webhooks.core import (
8+
Provider,
9+
WebhookEvent,
10+
constant_time_equals,
11+
hex_hmac_sha256,
12+
json_body,
13+
lower_headers,
14+
)
15+
16+
17+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
18+
"""Verify the `X-Clickup-Signature` HMAC over the raw body."""
19+
signature = lower_headers(headers).get("x-clickup-signature")
20+
return bool(signature) and constant_time_equals(hex_hmac_sha256(secret, body), signature.strip())
21+
22+
23+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
24+
"""Normalize a ClickUp delivery into a `WebhookEvent`."""
25+
payload = json_body(body)
26+
task = payload.get("task") or {}
27+
# ClickUp puts the list id at the top level on list-scoped events and only on
28+
# the nested task for task-scoped ones; read both or a `scopes` allowlist
29+
# cannot attribute task events.
30+
list_id = payload.get("list_id") or (task.get("list") or {}).get("id")
31+
task_id = payload.get("task_id") or task.get("id")
32+
return WebhookEvent(
33+
provider="clickup",
34+
event_type=payload.get("event", "unknown"),
35+
delivery_id=str(payload.get("webhook_id") or ""),
36+
resource_id=str(task_id) if task_id is not None else None,
37+
occurred_at=str(payload.get("timestamp")) if payload.get("timestamp") is not None else None,
38+
scope=str(list_id) if list_id is not None else None,
39+
title=task.get("name"),
40+
url=task.get("url"),
41+
payload=payload,
42+
)
43+
44+
45+
#: The contract core needs to accept ClickUp webhooks.
46+
PROVIDER = Provider(
47+
name="clickup",
48+
secret_env="CLICKUP_WEBHOOK_SECRET",
49+
verify=verify,
50+
parse=parse,
51+
setup_hint="Space Settings -> Integrations -> Webhooks",
52+
)
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""ClickUp webhook events. Names are flat — ClickUp sends no separate action."""
2+
3+
from __future__ import annotations
4+
5+
from flyteplugins.webhooks.core import EventType
6+
7+
__all__ = ["Folder", "Goal", "KeyResult", "List", "Space", "Task"]
8+
9+
10+
class Task(EventType):
11+
"""Task events."""
12+
13+
CREATED = "taskCreated"
14+
UPDATED = "taskUpdated"
15+
DELETED = "taskDeleted"
16+
PRIORITY_UPDATED = "taskPriorityUpdated"
17+
STATUS_UPDATED = "taskStatusUpdated"
18+
ASSIGNEE_UPDATED = "taskAssigneeUpdated"
19+
DUE_DATE_UPDATED = "taskDueDateUpdated"
20+
TAG_UPDATED = "taskTagUpdated"
21+
MOVED = "taskMoved"
22+
COMMENT_POSTED = "taskCommentPosted"
23+
COMMENT_UPDATED = "taskCommentUpdated"
24+
TIME_ESTIMATE_UPDATED = "taskTimeEstimateUpdated"
25+
TIME_TRACKED_UPDATED = "taskTimeTrackedUpdated"
26+
27+
28+
class List(EventType):
29+
"""List events."""
30+
31+
CREATED = "listCreated"
32+
UPDATED = "listUpdated"
33+
DELETED = "listDeleted"
34+
35+
36+
class Folder(EventType):
37+
"""Folder events."""
38+
39+
CREATED = "folderCreated"
40+
UPDATED = "folderUpdated"
41+
DELETED = "folderDeleted"
42+
43+
44+
class Space(EventType):
45+
"""Space events."""
46+
47+
CREATED = "spaceCreated"
48+
UPDATED = "spaceUpdated"
49+
DELETED = "spaceDeleted"
50+
51+
52+
class Goal(EventType):
53+
"""Goal events."""
54+
55+
CREATED = "goalCreated"
56+
UPDATED = "goalUpdated"
57+
DELETED = "goalDeleted"
58+
59+
60+
class KeyResult(EventType):
61+
"""Key-result (goal target) events."""
62+
63+
CREATED = "keyResultCreated"
64+
UPDATED = "keyResultUpdated"
65+
DELETED = "keyResultDeleted"
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
"""ClickUp-specific normalization, beyond conformance."""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import hmac
7+
import json
8+
9+
from flyteplugins.webhooks.clickup import events, parse
10+
11+
SECRET = "clickup-secret"
12+
13+
14+
def _parse(payload: dict):
15+
body = json.dumps(payload).encode()
16+
return parse({"X-Clickup-Signature": hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest()}, body)
17+
18+
19+
def test_the_event_name_is_the_qualified_type():
20+
assert _parse({"event": "taskStatusUpdated", "task_id": "t1"}).qualified_type == events.Task.STATUS_UPDATED
21+
22+
23+
def test_the_list_id_falls_back_to_the_nested_task():
24+
"""Task-scoped events carry it only there; a scopes allowlist needs it."""
25+
event = _parse({"event": "taskCreated", "task_id": "t1", "task": {"id": "t1", "list": {"id": "l7"}}})
26+
assert event.scope == "l7"
27+
28+
29+
def test_a_top_level_list_id_wins():
30+
event = _parse({"event": "listCreated", "list_id": "l1", "task": {"list": {"id": "l7"}}})
31+
assert event.scope == "l1"
32+
33+
34+
def test_later_updates_to_one_task_get_their_own_keys():
35+
def update(timestamp: int):
36+
return _parse({"event": "taskUpdated", "task_id": "t1", "timestamp": timestamp})
37+
38+
assert update(1700000000000).dedupe_key() != update(1700000009999).dedupe_key()
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.clickup as plugin
13+
14+
15+
def test_conformance():
16+
assert_provider_conforms(plugin)

0 commit comments

Comments
 (0)