Skip to content

Commit b5752ad

Browse files
cosmicBboyclaude
andcommitted
docs(examples): SaaS integration recipes over the vendors' own SDKs
Worked examples for driving GitHub, Slack, Linear, ClickUp, and Jira from Flyte, plus one app receiving webhooks from all five: providers=[GitHubProvider(), SlackProvider(), LinearProvider(), ClickUpProvider(), JiraProvider()] The API calls go through each vendor's own maintained client -- PyGithub, slack_sdk, jira, gql -- because a task is just a function and those are tested against the live API by people who get deprecation notices first. What Flyte contributes is the receiver, idempotent launching, and flyte.new_condition; the review-gate example is here because the condition is the only part of it that needed inventing. Each plugin also ships its own single-product example; these are the multi-product receiver and the task recipes it launches. Task names are environment-qualified (github-triage.triage_pr). Bare names never resolve, and both the Linear and Jira recipes define a triage_issue -- the qualifier is what keeps them apart. 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 c64d70f commit b5752ad

8 files changed

Lines changed: 813 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
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.
21+
22+
## The examples
23+
24+
| File | What it shows | Client |
25+
| --- | --- | --- |
26+
| `webhook_receiver.py` | One app receiving from all five products, launching a task per event | `flyteplugins-webhooks-*` |
27+
| `github_pr_review_gate.py` | Human-gated merge: a condition carrying JSON, parsed into a typed decision | `PyGithub` |
28+
| `github_triage_pr.py` | Label, comment, and report a check run | `PyGithub` |
29+
| `slack_notify.py` | Post, thread, react, answer a mention | `slack_sdk` |
30+
| `linear_triage_issue.py` | Query a backlog and comment, over GraphQL | `gql` |
31+
| `clickup_manage_ticket.py` | Open and close tickets, with a status pre-check | `httpx` |
32+
| `jira_manage_ticket.py` | Open, transition, and search issues | `jira` |
33+
34+
Linear and ClickUp ship no official Python SDK. Linear's API is a single GraphQL
35+
endpoint, so `gql` is the maintained client; ClickUp's is a handful of REST
36+
calls, so `httpx` directly beats a thin third-party wrapper.
37+
38+
## Putting it together
39+
40+
The receiver and the tasks are separate on purpose: the app authenticates and
41+
dispatches, the tasks do the work and can be run, tested, and retried on their
42+
own.
43+
44+
```bash
45+
# 1. deploy the tasks the receiver will launch
46+
flyte deploy examples/external_saas_integrations/github_triage_pr.py env
47+
flyte deploy examples/external_saas_integrations/slack_notify.py env
48+
49+
# 2. run one directly, to confirm credentials work before any webhook is involved
50+
flyte run examples/external_saas_integrations/github_triage_pr.py triage_pr \
51+
--repo <owner>/<repo> --number <pr>
52+
53+
# 3. deploy the receiver and point each provider at the URL its dashboard shows
54+
python examples/external_saas_integrations/webhook_receiver.py
55+
```
56+
57+
Task names are qualified by their environment when deployed — `triage_pr` in
58+
`github_triage_pr.py` becomes `github-triage.triage_pr`, which is what the
59+
receiver looks up. That qualifier is also what keeps the two `triage_issue`
60+
tasks here (Linear's and Jira's) from colliding.
61+
62+
`plugins/webhooks/README.md` has the full end-to-end testing guide, including
63+
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: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""Human-gated PR merging: a condition carrying a JSON payload.
2+
3+
The headline pattern. A task collects review metadata from a pull request,
4+
embeds it as JSON in a markdown condition prompt, parks the run until a human
5+
responds in the Flyte UI, and parses the structured response into a typed
6+
decision the workflow branches on. Approved PRs get merged.
7+
8+
Conditions are Flyte's; talking to GitHub is PyGithub's. Nothing here wraps the
9+
GitHub API — `flyte.new_condition` is the only part that needed inventing.
10+
11+
Requirements:
12+
pip install flyte PyGithub
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 json
24+
import os
25+
from datetime import timedelta
26+
from typing import Any, Literal
27+
28+
from pydantic import BaseModel, Field
29+
30+
import flyte
31+
32+
image = flyte.Image.from_debian_base(python_version=(3, 12)).with_pip_packages("PyGithub", "pydantic")
33+
34+
env = flyte.TaskEnvironment(
35+
name="github-review-gate",
36+
image=image,
37+
secrets=[flyte.Secret("GITHUB_TOKEN", as_env_var="GITHUB_TOKEN")],
38+
)
39+
40+
Verdict = Literal["approve", "request_changes", "comment"]
41+
42+
43+
class ReviewComment(BaseModel):
44+
"""A single inline review comment."""
45+
46+
path: str
47+
line: int | None = None
48+
body: str
49+
severity: Literal["info", "warning", "blocking"] = "info"
50+
51+
52+
class ReviewDecision(BaseModel):
53+
"""The reviewer's answer, parsed out of their condition response."""
54+
55+
verdict: Verdict
56+
summary: str = ""
57+
comments: list[ReviewComment] = Field(default_factory=list)
58+
59+
@property
60+
def is_approved(self) -> bool:
61+
return self.verdict == "approve"
62+
63+
@property
64+
def blocking_comments(self) -> list[ReviewComment]:
65+
return [c for c in self.comments if c.severity == "blocking"]
66+
67+
68+
def _normalize_verdict(value: str) -> Verdict:
69+
v = value.strip().lower().replace(" ", "_").replace("-", "_")
70+
if v in ("approve", "approved", "lgtm", "accept"):
71+
return "approve"
72+
if v in ("request_changes", "changes_requested", "reject", "blocked"):
73+
return "request_changes"
74+
if v in ("comment", "comments", "neutral", "note"):
75+
return "comment"
76+
raise ValueError(f"unknown verdict: {value!r}")
77+
78+
79+
def parse_review_payload(payload: str) -> ReviewDecision:
80+
"""Parse a reviewer's response into a `ReviewDecision`.
81+
82+
Accepts raw JSON, JSON inside a fenced code block, or prose with a JSON
83+
object somewhere in it — people paste all three. Verdict synonyms are
84+
normalized.
85+
86+
Raises:
87+
ValueError: when no JSON object with a recognizable verdict is found.
88+
"""
89+
text = (payload or "").strip()
90+
if not text:
91+
raise ValueError("empty review payload")
92+
93+
decoder = json.JSONDecoder()
94+
idx = text.find("{")
95+
while idx != -1:
96+
try:
97+
obj, _ = decoder.raw_decode(text[idx:])
98+
except json.JSONDecodeError:
99+
obj = None
100+
if isinstance(obj, dict) and "verdict" in obj:
101+
comments = obj.get("comments") or []
102+
return ReviewDecision(
103+
verdict=_normalize_verdict(str(obj["verdict"])),
104+
summary=str(obj.get("summary") or ""),
105+
comments=[ReviewComment.model_validate(c) for c in comments if isinstance(c, dict)],
106+
)
107+
idx = text.find("{", idx + 1)
108+
raise ValueError(f"could not extract a review decision from payload: {text[:200]!r}")
109+
110+
111+
def _collect_context(repo: str, number: int, max_files: int = 50) -> dict[str, Any]:
112+
"""Gather what a reviewer needs, using PyGithub."""
113+
from github import Auth, Github
114+
115+
with Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) as gh:
116+
pr = gh.get_repo(repo).get_pull(number)
117+
files = [
118+
{
119+
"filename": f.filename,
120+
"status": f.status,
121+
"additions": f.additions,
122+
"deletions": f.deletions,
123+
# Patches dominate the prompt; keep them for the first handful only.
124+
"patch": f.patch if i < 20 else None,
125+
}
126+
for i, f in enumerate(pr.get_files()[:max_files])
127+
]
128+
return {
129+
"repo": repo,
130+
"number": number,
131+
"title": pr.title,
132+
"author": pr.user.login if pr.user else None,
133+
"body": pr.body or "",
134+
"base": pr.base.ref,
135+
"head": pr.head.ref,
136+
"url": pr.html_url,
137+
"additions": pr.additions,
138+
"deletions": pr.deletions,
139+
"changed_files": pr.changed_files,
140+
"files": files,
141+
"prior_reviews": [{"user": r.user.login if r.user else None, "state": r.state} for r in pr.get_reviews()],
142+
}
143+
144+
145+
def build_review_prompt(context: dict[str, Any], instructions: str = "") -> str:
146+
"""Build the markdown prompt the reviewer sees in the Flyte UI.
147+
148+
The metadata goes in a fenced JSON block so it renders verbatim and can be
149+
machine-read downstream.
150+
"""
151+
instructions = instructions or (
152+
"Review this pull request. Respond with a JSON object of the form:\n"
153+
'`{"verdict": "approve" | "request_changes" | "comment", '
154+
'"summary": "...", "comments": [{"path": "...", "line": 1, '
155+
'"body": "...", "severity": "info" | "warning" | "blocking"}]}`'
156+
)
157+
return (
158+
f"## Review requested: {context['repo']}#{context['number']}\n\n"
159+
f"**{context['title']}** (by {context.get('author') or 'unknown'})\n\n"
160+
f"{context.get('body', '')}\n\n"
161+
f"{instructions}\n\n"
162+
"### Pull request metadata\n\n"
163+
"```json\n"
164+
f"{json.dumps(context, indent=2)}\n"
165+
"```\n"
166+
)
167+
168+
169+
@env.task
170+
async def review_pr(repo: str, number: int, timeout: timedelta | None = None) -> ReviewDecision:
171+
"""Park the run on a human review condition and return the decision."""
172+
context = await asyncio.to_thread(_collect_context, repo, number)
173+
condition = await flyte.new_condition.aio(
174+
f"review-{repo.replace('/', '-')}-{number}"[:60],
175+
prompt=build_review_prompt(context),
176+
prompt_type="markdown",
177+
data_type=str,
178+
timeout=timeout,
179+
)
180+
return parse_review_payload(await condition.wait.aio())
181+
182+
183+
@env.task
184+
async def gated_merge(repo: str, number: int) -> str:
185+
"""Wait for a human review, then merge if approved."""
186+
decision = await review_pr(repo, number)
187+
188+
if not decision.is_approved:
189+
await asyncio.to_thread(_comment, repo, number, f"Review gate blocked this merge: {decision.summary}")
190+
return f"blocked: {decision.summary}"
191+
192+
return await asyncio.to_thread(_merge, repo, number)
193+
194+
195+
def _comment(repo: str, number: int, body: str) -> None:
196+
from github import Auth, Github
197+
198+
with Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) as gh:
199+
gh.get_repo(repo).get_issue(number).create_comment(body)
200+
201+
202+
def _merge(repo: str, number: int) -> str:
203+
from github import Auth, Github
204+
205+
with Github(auth=Auth.Token(os.environ["GITHUB_TOKEN"])) as gh:
206+
result = gh.get_repo(repo).get_pull(number).merge(merge_method="squash")
207+
return f"merged {result.sha}"
208+
209+
210+
if __name__ == "__main__":
211+
flyte.init_from_config()
212+
run = flyte.with_runcontext().run(gated_merge, repo="octocat/hello-world", number=1)
213+
print(run.url)

0 commit comments

Comments
 (0)