Skip to content

Commit 40cb463

Browse files
cosmicBboyclaude
andauthored
feat(slack): add flyteplugins-slack (#1514)
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>
1 parent 538223a commit 40cb463

45 files changed

Lines changed: 8857 additions & 0 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# External SaaS integrations
2+
3+
Recipes for driving GitHub, Slack, Linear, ClickUp, and Jira from Flyte.
4+
5+
There is deliberately **no Flyte client plugin** for these products. Each vendor
6+
already ships (or the community maintains) a Python client that is tested
7+
against the live API by people who get deprecation notices first, and a task is
8+
just a function — so calling `PyGithub` or `slack_sdk` from a task needs nothing
9+
in between. A wrapper here would only add a surface to keep in sync with someone
10+
else's release calendar.
11+
12+
What *is* Flyte's job, and what these examples use from it:
13+
14+
- **`flyte.extras.webhooks`** — ships with flyte: one app that authenticates an inbound
15+
delivery with the product's own scheme, normalizes it into a single event
16+
model, and launches a run once per event key with `idempotent_run`.
17+
- **`flyteplugins-webhooks-<product>`** — one small package per product,
18+
contributing just its verification and parsing.
19+
- **`flyte.new_condition`** — park a run on a human decision, with a typed
20+
payload coming back. `flyteplugins.github.review_pr` wraps this into a PR
21+
review gate, which is the one place a plugin beats calling the vendor SDK:
22+
the condition is Flyte's, not GitHub's.
23+
24+
## The examples
25+
26+
| File | What it shows | Client |
27+
| --- | --- | --- |
28+
| `webhook_receiver.py` | One app receiving from all five products, launching a task per event | `flyteplugins-webhooks-*` |
29+
| `github_pr_review_gate.py` | Human-gated merge, on `flyteplugins.github.review_pr` | plugin + `PyGithub` |
30+
| `github_triage_pr.py` | Label, comment, and report a check run | `PyGithub` |
31+
| `slack_notify.py` | Post, thread, react, answer a mention | `slack_sdk` |
32+
| `linear_triage_issue.py` | Query a backlog and comment, over GraphQL | `gql` |
33+
| `clickup_manage_ticket.py` | Open and close tickets, with a status pre-check | `httpx` |
34+
| `jira_manage_ticket.py` | Open, transition, and search issues | `jira` |
35+
36+
Linear and ClickUp ship no official Python SDK. Linear's API is a single GraphQL
37+
endpoint, so `gql` is the maintained client; ClickUp's is a handful of REST
38+
calls, so `httpx` directly beats a thin third-party wrapper.
39+
40+
## Putting it together
41+
42+
The receiver and the tasks are separate on purpose: the app authenticates and
43+
dispatches, the tasks do the work and can be run, tested, and retried on their
44+
own.
45+
46+
```bash
47+
# 1. deploy the tasks the receiver will launch
48+
flyte deploy examples/external_saas_integrations/github_triage_pr.py env
49+
flyte deploy examples/external_saas_integrations/slack_notify.py env
50+
51+
# 2. run one directly, to confirm credentials work before any webhook is involved
52+
flyte run examples/external_saas_integrations/github_triage_pr.py triage_pr \
53+
--repo <owner>/<repo> --number <pr>
54+
55+
# 3. deploy the receiver and point each provider at the URL its dashboard shows
56+
python examples/external_saas_integrations/webhook_receiver.py
57+
```
58+
59+
Task names are qualified by their environment when deployed — `triage_pr` in
60+
`github_triage_pr.py` becomes `github-triage.triage_pr`, which is what the
61+
receiver looks up. That qualifier is also what keeps the two `triage_issue`
62+
tasks here (Linear's and Jira's) from colliding.
63+
64+
`plugins/webhooks/README.md` has the full end-to-end testing guide, including
65+
how to wire up each provider and what to check at every step.
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Read and write ClickUp from tasks, with `httpx` against its REST v2 API.
2+
3+
ClickUp ships no official Python SDK and the community ones are thin and
4+
sporadically maintained, so this calls the REST API directly with `httpx`
5+
rather than adding a dependency that wraps four endpoints.
6+
7+
Requirements:
8+
pip install flyte httpx
9+
10+
Setup:
11+
flyte create secret CLICKUP_TOKEN --value pk_...
12+
13+
Usage:
14+
flyte run examples/external_saas_integrations/clickup_manage_ticket.py \\
15+
open_ticket --list_id <list-id> --name "From Flyte"
16+
"""
17+
18+
import os
19+
20+
import flyte
21+
22+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("httpx")
23+
24+
env = flyte.TaskEnvironment(
25+
name="clickup-tickets",
26+
image=image,
27+
secrets=[flyte.Secret("CLICKUP_TOKEN", as_env_var="CLICKUP_TOKEN")],
28+
)
29+
30+
API = "https://api.clickup.com/api/v2"
31+
32+
33+
def _client():
34+
import httpx
35+
36+
return httpx.AsyncClient(base_url=API, headers={"Authorization": os.environ["CLICKUP_TOKEN"]}, timeout=30)
37+
38+
39+
@env.task
40+
async def open_ticket(list_id: str, name: str, description: str = "") -> str:
41+
"""Create a task and return its URL."""
42+
async with _client() as client:
43+
response = await client.post(f"/list/{list_id}/task", json={"name": name, "description": description})
44+
response.raise_for_status()
45+
return response.json()["url"]
46+
47+
48+
@env.task
49+
async def triage_task(task_id: str) -> str:
50+
"""Comment on a newly created task.
51+
52+
This is what `webhook_receiver.py` launches for every `taskCreated`.
53+
"""
54+
async with _client() as client:
55+
task = await client.get(f"/task/{task_id}")
56+
task.raise_for_status()
57+
status = (task.json().get("status") or {}).get("status")
58+
posted = await client.post(
59+
f"/task/{task_id}/comment", json={"comment_text": f"Flyte triaged this ticket (status: {status})."}
60+
)
61+
posted.raise_for_status()
62+
return f"triaged {task_id}"
63+
64+
65+
@env.task
66+
async def close_ticket(task_id: str, done_status: str = "done") -> str:
67+
"""Move a ticket to a Done-like status, validating it first.
68+
69+
ClickUp rejects transitions to statuses the ticket's list does not define,
70+
with an opaque 400 — so check the list's statuses before trying.
71+
"""
72+
async with _client() as client:
73+
task = await client.get(f"/task/{task_id}")
74+
task.raise_for_status()
75+
list_id = (task.json().get("list") or {}).get("id")
76+
77+
listing = await client.get(f"/list/{list_id}")
78+
listing.raise_for_status()
79+
valid = [s["status"] for s in listing.json().get("statuses", [])]
80+
if done_status not in valid:
81+
raise ValueError(f"status {done_status!r} is not defined on list {list_id}; valid: {valid}")
82+
83+
updated = await client.put(f"/task/{task_id}", json={"status": done_status})
84+
updated.raise_for_status()
85+
return f"{task_id} -> {done_status}"
86+
87+
88+
if __name__ == "__main__":
89+
flyte.init_from_config()
90+
print(flyte.run(open_ticket, list_id="LIST_ID", name="Flyte test ticket").url)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Human-gated PR merging: a condition carrying a JSON payload.
2+
3+
A task collects review metadata from a pull request, embeds it as JSON in a
4+
markdown condition prompt, parks the run until a human answers in the Flyte UI,
5+
and parses the response into a typed decision. Approved PRs get merged.
6+
7+
The gate itself is `flyteplugins.github.review_pr` — it lives in the plugin
8+
because `flyte.new_condition` is the part only Flyte can do. Merging is
9+
`PyGithub`, called directly here.
10+
11+
Requirements:
12+
pip install "flyteplugins-github[review]"
13+
14+
Setup:
15+
flyte create secret GITHUB_TOKEN --value <token-with-repo-scope>
16+
17+
Usage:
18+
flyte run examples/external_saas_integrations/github_pr_review_gate.py \\
19+
gated_merge --repo octocat/hello-world --number 1
20+
"""
21+
22+
import asyncio
23+
import os
24+
25+
from flyteplugins.github import ReviewDecision, review_pr
26+
27+
import flyte
28+
29+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("flyteplugins-github[review]")
30+
31+
env = flyte.TaskEnvironment(
32+
name="github-review-gate",
33+
image=image,
34+
secrets=[flyte.Secret("GITHUB_TOKEN", as_env_var="GITHUB_TOKEN")],
35+
)
36+
37+
38+
def _comment(repo: str, number: int, body: str) -> None:
39+
from github import Auth, Github
40+
41+
with Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) as gh:
42+
gh.get_repo(repo).get_issue(number).create_comment(body)
43+
44+
45+
def _merge(repo: str, number: int) -> str:
46+
from github import Auth, Github
47+
48+
with Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) as gh:
49+
result = gh.get_repo(repo).get_pull(number).merge(merge_method="squash")
50+
return f"merged {result.sha}"
51+
52+
53+
@env.task
54+
async def gated_merge(repo: str, number: int) -> str:
55+
"""Wait for a human review, then merge if approved.
56+
57+
The run parks at `review_pr` until someone answers the condition in the
58+
Flyte UI. Pass `timeout=` to bound that wait.
59+
"""
60+
decision: ReviewDecision = await review_pr(repo, number)
61+
62+
if not decision.is_approved:
63+
# Post the reviewer's reasoning back to the PR before bailing out, so
64+
# the decision is visible where the author is looking.
65+
blockers = "\n".join(f"- `{c.path}`: {c.body}" for c in decision.blocking_comments)
66+
await asyncio.to_thread(
67+
_comment, repo, number, f"Review gate blocked this merge: {decision.summary}\n{blockers}"
68+
)
69+
return f"blocked: {decision.summary}"
70+
71+
# PyGithub is synchronous; keep it off the event loop.
72+
return await asyncio.to_thread(_merge, repo, number)
73+
74+
75+
if __name__ == "__main__":
76+
flyte.init_from_config()
77+
run = flyte.with_runcontext().run(gated_merge, repo="octocat/hello-world", number=1)
78+
print(run.url)
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""Read and write GitHub from tasks, with PyGithub.
2+
3+
The task the webhook receiver launches when a PR opens. There is no Flyte
4+
plugin between you and GitHub here — PyGithub is the maintained client, and a
5+
task is just a function that calls it.
6+
7+
Requirements:
8+
pip install flyte PyGithub
9+
10+
Setup:
11+
flyte create secret GITHUB_TOKEN --value <token-with-repo-scope>
12+
13+
Usage:
14+
flyte run examples/external_saas_integrations/github_triage_pr.py \\
15+
triage_pr --repo octocat/hello-world --number 1
16+
"""
17+
18+
import os
19+
20+
import flyte
21+
22+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("PyGithub")
23+
24+
env = flyte.TaskEnvironment(
25+
name="github-triage",
26+
image=image,
27+
secrets=[flyte.Secret("GITHUB_TOKEN", as_env_var="GITHUB_TOKEN")],
28+
)
29+
30+
31+
def _client():
32+
from github import Auth, Github
33+
34+
return Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"]))
35+
36+
37+
@env.task
38+
def summarize_pr(repo: str, number: int) -> str:
39+
"""Read a pull request and summarize what it changes."""
40+
with _client() as gh:
41+
pr = gh.get_repo(repo).get_pull(number)
42+
lines = [f"- {f.filename} (+{f.additions}/-{f.deletions})" for f in pr.get_files()[:20]]
43+
return f"{pr.title} ({pr.head.ref} -> {pr.base.ref})\n" + "\n".join(lines)
44+
45+
46+
@env.task
47+
def triage_pr(repo: str, number: int) -> str:
48+
"""Label a new PR, comment on it, and report a check run.
49+
50+
This is what `webhook_receiver.py` launches for every newly opened PR.
51+
"""
52+
with _client() as gh:
53+
repository = gh.get_repo(repo)
54+
pr = repository.get_pull(number)
55+
pr.add_to_labels("flyte-triage")
56+
pr.create_issue_comment(
57+
f"Flyte triage: this PR touches {pr.changed_files} files (+{pr.additions}/-{pr.deletions})."
58+
)
59+
repository.create_check_run(
60+
name="flyte-triage",
61+
head_sha=pr.head.sha,
62+
status="completed",
63+
conclusion="success",
64+
output={"title": "flyte-triage", "summary": "Flyte triaged this pull request."},
65+
)
66+
return f"triaged {repo}#{number}"
67+
68+
69+
if __name__ == "__main__":
70+
flyte.init_from_config()
71+
print(flyte.run(triage_pr, repo="octocat/hello-world", number=1).url)
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Read and write Jira from tasks, with the official `jira` package.
2+
3+
Requirements:
4+
pip install flyte jira
5+
6+
Setup:
7+
flyte create secret JIRA_BASE_URL --value https://<site>.atlassian.net
8+
flyte create secret JIRA_EMAIL --value you@example.com
9+
flyte create secret JIRA_API_TOKEN --value <api-token>
10+
11+
Usage:
12+
flyte run examples/external_saas_integrations/jira_manage_ticket.py \\
13+
open_ticket --project_key PROJ --summary "From Flyte"
14+
"""
15+
16+
import os
17+
18+
import flyte
19+
20+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("jira")
21+
22+
env = flyte.TaskEnvironment(
23+
name="jira-tickets",
24+
image=image,
25+
secrets=[
26+
flyte.Secret("JIRA_BASE_URL", as_env_var="JIRA_BASE_URL"),
27+
flyte.Secret("JIRA_EMAIL", as_env_var="JIRA_EMAIL"),
28+
flyte.Secret("JIRA_API_TOKEN", as_env_var="JIRA_API_TOKEN"),
29+
],
30+
)
31+
32+
33+
def _client():
34+
from jira import JIRA
35+
36+
return JIRA(
37+
server=os.environ["JIRA_BASE_URL"],
38+
basic_auth=(os.environ["JIRA_EMAIL"], os.environ["JIRA_API_TOKEN"]),
39+
)
40+
41+
42+
@env.task
43+
def open_ticket(project_key: str, summary: str, description: str = "") -> str:
44+
"""Create an issue and return its key."""
45+
issue = _client().create_issue(
46+
project=project_key, summary=summary, description=description, issuetype={"name": "Task"}
47+
)
48+
return issue.key
49+
50+
51+
@env.task
52+
def triage_issue(issue_key: str) -> str:
53+
"""Comment on a newly created issue.
54+
55+
This is what `webhook_receiver.py` launches for every `jira:issue_created`.
56+
"""
57+
jira = _client()
58+
issue = jira.issue(issue_key)
59+
jira.add_comment(issue, f"Flyte triaged this issue (status: {issue.fields.status.name}).")
60+
return f"triaged {issue_key}"
61+
62+
63+
@env.task
64+
def summarize_open_bugs(project_key: str, limit: int = 20) -> str:
65+
"""Summarize open bugs via JQL."""
66+
issues = _client().search_issues(
67+
f'project = "{project_key}" AND issuetype = Bug AND statusCategory != Done', maxResults=limit
68+
)
69+
return "\n".join(f"- {i.key}: {i.fields.summary}" for i in issues) or "no open bugs"
70+
71+
72+
@env.task
73+
def start_work(issue_key: str, transition: str = "In Progress") -> str:
74+
"""Transition an issue by name, listing the valid ones when it does not apply.
75+
76+
Jira rejects transitions the issue's workflow does not offer, so resolve the
77+
name against what is actually available rather than guessing an id.
78+
"""
79+
jira = _client()
80+
issue = jira.issue(issue_key)
81+
available = {t["name"].lower(): t["id"] for t in jira.transitions(issue)}
82+
target = available.get(transition.lower())
83+
if target is None:
84+
raise ValueError(f"{issue_key} cannot transition to {transition!r}; available: {sorted(available)}")
85+
jira.transition_issue(issue, target)
86+
return f"{issue_key} -> {transition}"
87+
88+
89+
if __name__ == "__main__":
90+
flyte.init_from_config()
91+
print(flyte.run(open_ticket, project_key="PROJ", summary="Flyte test issue").url)

0 commit comments

Comments
 (0)