Skip to content

Commit 9d513df

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. There is deliberately no Flyte client plugin for these products. PyGithub, slack_sdk, jira, and gql are maintained by people with live API access, and a task is just a function — so calling them directly needs nothing in between. What Flyte contributes is the webhook 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. 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 12f52a8 commit 9d513df

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+
- **`flyteplugins-webhooks-core`** — 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)