feat(extras): receive SaaS webhooks, at flyte.extras.webhooks - #1512
Merged
Conversation
This was referenced Sep 1, 2026
cosmicBboy
marked this pull request as ready for review
September 1, 2026 17:54
cosmicBboy
force-pushed
the
nielsb/webhooks-core
branch
3 times, most recently
from
September 2, 2026 00:55
46ac7e6 to
b9e6cd6
Compare
cosmicBboy
force-pushed
the
nielsb/webhooks-core
branch
from
September 2, 2026 01:03
b9e6cd6 to
033e469
Compare
Product-agnostic machinery for turning an inbound webhook into a Flyte run, so
each flyteplugins-<product> package only has to say how to verify and parse its
own deliveries.
- WebhookAppEnvironment: one app serving a setup dashboard and a verified
receiver at /webhook/{provider}, for whichever providers it is handed.
- WebhookEvent: the normalized event every provider parses into. Its dedupe key
folds in the product's own timestamp, so a later change to one resource gets
its own key rather than collapsing onto the first.
- idempotent_run: launch once per event key. Identity lives on a run *label*,
never a name -- names race against concurrent launches and cap how many runs
one key can ever have.
- Provider plus the verification primitives, written once because they are the
part that is easy to get subtly wrong. constant_time_equals compares bytes:
hmac.compare_digest raises TypeError on non-ASCII str operands, and these
headers come off the wire, so a crafted one would turn a clean 401 into a 500.
- testing.assert_provider_conforms: the conformance check every product plugin
runs, which replays a real sample delivery through verify and parse rather
than trusting them to agree with each other.
No new dependencies. pydantic is already a runtime dependency; fastapi stays
behind the same lazy import flyte.app.extras._fastapi uses, so importing this
package never requires it -- only building the app does. Product plugins
declare their own [app] extra.
Tested against a stub provider, so the SDK depends on no product package.
examples/apps/webhook_custom_provider.py is a complete provider you can run
with no account at all.
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>
cosmicBboy
force-pushed
the
nielsb/webhooks-core
branch
from
September 2, 2026 01:32
033e469 to
8f47dd7
Compare
cosmicBboy
added a commit
that referenced
this pull request
Sep 2, 2026
…1518) Last of the webhook plugin stack (#1512–#1517), based on #1517 so every import resolves. Worked recipes for driving GitHub, Slack, Linear, ClickUp, and Jira from Flyte, plus one app receiving webhooks from all five. ## The argument these examples make There is deliberately **no Flyte client plugin** for these products. Each vendor already ships (or the community maintains) a Python client tested against the live API by people who get deprecation notices first, and a task is just a function — so calling `PyGithub` or `slack_sdk` from a task needs nothing in between. A wrapper would only add a surface to keep in sync with someone else's release calendar. What Flyte contributes, and what these use: - `flyteplugins-webhooks-*` — authenticate an inbound delivery and normalize it - `idempotent_run` — launch a run once per event key - `flyte.new_condition` — park a run on a human decision with a typed payload back | File | Shows | Client | | --- | --- | --- | | `webhook_receiver.py` | One app receiving from all five products | `flyteplugins-webhooks-*` | | `github_pr_review_gate.py` | Human-gated merge: a condition carrying JSON, parsed into a typed decision | `PyGithub` | | `github_triage_pr.py` | Label, comment, report a check run | `PyGithub` | | `slack_notify.py` | Post, thread, react, answer a mention | `slack_sdk` | | `linear_triage_issue.py` | Query a backlog and comment, over GraphQL | `gql` | | `clickup_manage_ticket.py` | Open and close tickets, with a status pre-check | `httpx` | | `jira_manage_ticket.py` | Open, transition, and search issues | `jira` | Linear and ClickUp ship no official Python SDK — Linear's API is a single GraphQL endpoint so `gql` is the maintained client, and ClickUp's is a handful of REST calls where `httpx` directly beats a thin third-party wrapper. The review gate is here rather than in a plugin because `flyte.new_condition` is the only part of it that needed inventing; the rest is PyGithub calls. ## One thing I fixed while writing them Task names are **environment-qualified** — `triage_pr` in `github_triage_pr.py` deploys as `github-triage.triage_pr`, which is what the receiver looks up. Bare names never resolve, and both the Linear and Jira recipes define a `triage_issue`, so the qualifier is also what keeps them apart. There is a check that every name the receiver launches is actually defined; the original per-product examples this replaces had bare names throughout and would have failed at the first real event. ## Verification Verified mechanically rather than by eye: every referenced example path resolves, every `flyte create secret` name matches the actual provider `secret_env`, every `flyte deploy <file> <env>` names a real `TaskEnvironment`, every `flyte run` task is defined, and every example file executes (the receiver registers all five handlers). `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass. Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy
added a commit
that referenced
this pull request
Sep 2, 2026
Part of the webhook plugin stack. Requires #1512 (`flyteplugins-webhooks-core`), which is this PR's base — the diff here is just this package. Receive Jira webhooks in Flyte. ## What it implements The `Provider` contract from 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 sends. **Verification:** **none** — Jira Cloud does not sign its webhooks. 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. This is the plugin that made the conformance harness better: it caught that a shared token, unlike an HMAC, is **not bound to the request body**, so a tampered payload still verifies. That is inherent to an unsigned webhook rather than a bug here, and `signed=False` now opts out of that one check explicitly instead of the harness quietly passing. ## Conformance Runs the shared `assert_provider_conforms`, which replays this plugin's `SAMPLE_DELIVERY` — a real Jira payload — through `verify` and `parse` rather than trusting them to agree with each other. It also asserts the verifier returns False rather than raising on a hostile header, that event constants render as wire values rather than enum names, and that the sample parses to something the constants actually spell. 6 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass. ## What it does not do Call the Jira API. Use the `jira` package directly from your tasks — the recipes are in the examples PR at the end of this stack. This plugin owns only the part that is Flyte's: authenticating an inbound delivery and turning it into a run. --------- Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy
added a commit
that referenced
this pull request
Sep 2, 2026
Part of the webhook plugin stack. Requires #1512 (`flyteplugins-webhooks-core`), which is this PR's base — the diff here is just this package. Receive ClickUp webhooks in Flyte. ## What it implements The `Provider` contract from 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 sends. **Verification:** HMAC-SHA256 over the raw body (`X-Clickup-Signature`). The list id sits at the top level on list-scoped events and on the nested task for task-scoped ones; the parser reads both, or a `scopes` allowlist cannot attribute task events at all. ## Conformance Runs the shared `assert_provider_conforms`, which replays this plugin's `SAMPLE_DELIVERY` — a real ClickUp payload — through `verify` and `parse` rather than trusting them to agree with each other. It also asserts the verifier returns False rather than raising on a hostile header, that event constants render as wire values rather than enum names, and that the sample parses to something the constants actually spell. 5 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass. ## What it does not do Call the ClickUp API. Use `httpx` — ClickUp ships no Python SDK and its API is a handful of REST calls directly from your tasks — the recipes are in the examples PR at the end of this stack. This plugin owns only the part that is Flyte's: authenticating an inbound delivery and turning it into a run. --------- Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy
added a commit
that referenced
this pull request
Sep 2, 2026
Part of the webhook plugin stack. Requires #1512 (`flyteplugins-webhooks-core`), which is this PR's base — the diff here is just this package. Receive Linear webhooks in Flyte. ## What it implements The `Provider` contract from 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 Linear sends. **Verification:** HMAC-SHA256 over the raw body (`X-Linear-Signature`). The dedupe key folds in the entity's `updatedAt`. Keyed on the entity alone, every `Issue.update` after the first would collapse onto one key and never launch. Comment and reaction payloads carry the team id only on the nested issue, which the parser follows so a `scopes` allowlist can still attribute them. ## Conformance Runs the shared `assert_provider_conforms`, which replays this plugin's `SAMPLE_DELIVERY` — a real Linear payload — through `verify` and `parse` rather than trusting them to agree with each other. It also asserts the verifier returns False rather than raising on a hostile header, that event constants render as wire values rather than enum names, and that the sample parses to something the constants actually spell. 5 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass. ## What it does not do Call the Linear API. Use `gql` — Linear ships no Python SDK and its API is a single GraphQL endpoint directly from your tasks — the recipes are in the examples PR at the end of this stack. This plugin owns only the part that is Flyte's: authenticating an inbound delivery and turning it into a run. --------- Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
cosmicBboy
added a commit
that referenced
this pull request
Sep 2, 2026
Part of the webhook plugin stack. Requires #1512 (`flyteplugins-webhooks-core`), which is this PR's base — the diff here is just this package. Receive Slack webhooks in Flyte. ## What it implements The `Provider` contract from 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 Slack sends. **Verification:** HMAC-SHA256 over `v0:{timestamp}:{body}`, with a five-minute replay window. Echoes the `url_verification` challenge, so Slack's Request URL field verifies itself. The signature covers the **raw bytes**. Decoding the body and re-encoding it would corrupt any byte Slack signed but Python cannot decode, and running the timestamp through `int()` would drop whatever formatting Slack signed — a test pins both. ## Conformance Runs the shared `assert_provider_conforms`, which replays this plugin's `SAMPLE_DELIVERY` — a real Slack payload — through `verify` and `parse` rather than trusting them to agree with each other. It also asserts the verifier returns False rather than raising on a hostile header, that event constants render as wire values rather than enum names, and that the sample parses to something the constants actually spell. 6 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass. ## What it does not do Call the Slack API. Use `slack_sdk` directly from your tasks — the recipes are in the examples PR at the end of this stack. This plugin owns only the part that is Flyte's: authenticating an inbound delivery and turning it into a run. --------- Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
) Part of the webhook plugin stack. Requires #1512 (`flyteplugins-webhooks-core`), which is this PR's base — the diff here is just this package. Receive GitHub webhooks in Flyte. ## What it implements The `Provider` contract from 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 GitHub sends. **Verification:** HMAC-SHA256 over the raw body (`X-Hub-Signature-256`). Answers GitHub's `ping` automatically, so a green check in *Recent Deliveries* means the app is reachable. Comment and review events fold the comment id into `resource_id`, so two comments on one issue are two events rather than a redelivery of the first — the bug that prompted this whole restructure. ## Conformance Runs the shared `assert_provider_conforms`, which replays this plugin's `SAMPLE_DELIVERY` — a real GitHub payload — through `verify` and `parse` rather than trusting them to agree with each other. It also asserts the verifier returns False rather than raising on a hostile header, that event constants render as wire values rather than enum names, and that the sample parses to something the constants actually spell. 7 tests. `make fmt`, `make mypy`, `make ty`, ruff, and codespell pass. ## What it does not do Call the GitHub API. Use `PyGithub` directly from your tasks — the recipes are in the examples PR at the end of this stack. This plugin owns only the part that is Flyte's: authenticating an inbound delivery and turning it into a run. --------- Signed-off-by: Niels Bantilan <niels.bantilan@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
default_secret_env is now a class property on the provider, and
WebhookAppEnvironment mounts it. Wiring up an app no longer repeats the
environment variable the provider already knows:
app_env = WebhookAppEnvironment(
name="acme-webhooks",
providers=[AcmeProvider()],
image=...,
)
Anything the caller does declare wins, which is how you point a provider at a
secret stored under a different key:
secrets=[flyte.Secret("my-gh-key", as_env_var="GITHUB_WEBHOOK_SECRET")]
Detection matches on the environment variable a secret actually lands as, not
on its key, so `secrets=["GITHUB_WEBHOOK_SECRET"]` and an explicit as_env_var
both count as declared and neither produces a duplicate mount. Unrelated
secrets are kept alongside.
The module-level DEFAULT_SECRET_ENV constants are gone; the value lives on the
class that owns it, and instances still take a secret_env override.
Conformance now requires a plugin to declare default_secret_env and to default
to it, because the app mounting the value means a plugin that omits it hands
users an app with no secret and no error -- verified to fail loudly.
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>
Anything else a launched run needs -- queue, env_vars, interruptible,
service_account, notifications -- now reaches with_runcontext:
await idempotent_run.aio(
task,
key=event.dedupe_key(),
runcontext_kwargs={"queue": "webhooks", "labels": {"team": "platform"}},
)
Labels merge with the dedupe label rather than replacing it, so extra labels
are fine. Setting dedupe yourself is refused: honouring it silently would be
indistinguishable from turning idempotency off, and the error points at the
key= argument instead. Passing copy_style both directly and in the dict is
refused for the same reason -- one of them would have to lose quietly.
The caller's dict is copied, since a handler is likely to reuse one across
events.
This also removes the branch that existed only because with_runcontext takes
copy_style as a literal rather than an Optional, so None could not be passed
through.
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>
The name promised idempotency the function does not provide. An idempotent
operation returns the same result when repeated -- HTTP PUT, or a Stripe
idempotency key replaying the original response. This one raises DuplicateRun
instead, which is a uniqueness constraint, not idempotency. Nine call sites
wrap it in try/except; a genuinely idempotent call would need no branch.
It was also the only symbol outside the module's own vocabulary, which is
otherwise consistent: the label is dedupe, the event method is dedupe_key(),
the error is DuplicateRun.
run_once reads correctly at the call site and sits next to flyte.run:
try:
run = await run_once.aio(task, key=event.dedupe_key())
except DuplicateRun as exc:
return {"skipped": exc.url}
Behavior is unchanged. Raising is kept deliberately: the duplicate case is
meaningful to a webhook handler, which wants to answer "already handled" with
a link. Returning the existing run instead would make callers compare run
identities to notice. The module docstring now says so.
Prose keeps the word idempotent where it describes the system property --
delivering an event twice yields one run -- and drops it where it described
the function.
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>
A duplicate delivery no longer raises. run_once returns the run that covers
the key either way, paired with a flag saying whether this call launched it:
result = await run_once.aio(task, key=event.dedupe_key())
if not result.created:
return {"skipped": result.run.name, "url": result.run.url}
return {"run": result.run.name}
RunOnceResult is a NamedTuple, so it also unpacks as run, created = ... . A
caller that does not care which happened can use result.run unconditionally,
which the exception did not allow -- the old contract forced every call site
through try/except just to reach the run it already knew about.
The flag exists because dropping the signal entirely would cost the handlers
something real: each one answers "already handled" and links to the run, and
without a flag they cannot tell a fresh launch from a found one. Run phase
cannot substitute -- a just-launched run and an existing one both report a
live phase.
DuplicateRun is removed rather than kept unraised. All 13 call sites across
the plugins, examples and docs move to the flag check.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
First of a stack that replaces the six per-product integration plugins (#1502–#1507) with the parts that are genuinely Flyte's. Structured like
plugins/agents: core holds the contract, each product is its own thin package.Stack: this → github → slack → linear → clickup → jira → examples. Each PR's diff is just its own package.
What core owns
WebhookAppEnvironment/webhook/{provider}, for whichever providers it is handedProviderWebhookEventrun_onceEventTypeconstant_time_equals,hex_hmac_sha256,json_body,lower_headerstesting.assert_provider_conformsA product plugin owns only what is specific to it — usually under 150 lines. Core is tested against a stub provider, so it depends on no product package.
Two decisions worth reviewing
Identity lives on a run label, never a name.
run_oncelaunches only when no run carrying the samededupelabel is live or succeeded; when one is, it returns that run withcreated=Falserather than launching a second or raising, so a handler can answer "already handled" and link to it. Names race against concurrent launches and cap how many runs one key can ever have, so the control plane assigns them. It is async-first behind@syncify— the blocking form stalls an app's event loop, and webhook senders time out in seconds. A test asserts four concurrent launches overlap; against a blocking implementation it measures 0.81s instead of 0.42s and fails.constant_time_equalscompares bytes.hmac.compare_digestraisesTypeErroronstroperands containing non-ASCII, and these headers come off the wire — ASGI hands Starlette raw bytes which it decodes latin-1, so a crafted header would turn a clean 401 into a 500. Writing it once here is the main argument for a shared core.The conformance harness
Each product plugin ships
SAMPLE_DELIVERY, a real payload. The harness signs and replays it, soverifyandparseare checked against an actual payload rather than against each other — without one, every other check is vacuous.It earned its keep during development twice:
signed=Falsenow opts out of the body-tampering check and makes the dashboard say the product does not sign.str-basedcompare_digestfails conformance as intended.I verified it discriminates by injecting three regressions (plain
str, Enumconstants, astrcomparison, a constant that no longer matches the parser); each one fails with a specific message.Notes
No new dependencies land in
flyte: the shared core lives inflyte.extras.webhooksbehind the same soft-import pattern the otherflyte.extrasapps use, and each product plugin ships its own provider.46 core tests plus each plugin's suite.
make fmt,make mypy(864 files),make ty, ruff, and codespell pass.The caveat from the review that prompted this: the parsers still encode payload-shape assumptions I cannot verify without live accounts. That surface is much smaller now — the six API clients and their ~213 field assumptions are gone — and
SAMPLE_DELIVERYpins what remains, but one real pass per product is still the thing that would confirm it.