flyteplugins-github 2.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flyteplugins/github/__init__.py +113 -0
- flyteplugins/github/_provider.py +99 -0
- flyteplugins/github/_review.py +310 -0
- flyteplugins/github/events.py +167 -0
- flyteplugins_github-2.7.0.dist-info/METADATA +127 -0
- flyteplugins_github-2.7.0.dist-info/RECORD +8 -0
- flyteplugins_github-2.7.0.dist-info/WHEEL +5 -0
- flyteplugins_github-2.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""GitHub webhooks for Flyte.
|
|
2
|
+
|
|
3
|
+
Hand a `GitHubProvider()` to a `WebhookAppEnvironment` and register handlers with the
|
|
4
|
+
typed constants in `events`:
|
|
5
|
+
|
|
6
|
+
```python
|
|
7
|
+
import flyte
|
|
8
|
+
from flyte.extras.webhooks import WebhookAppEnvironment, run_once
|
|
9
|
+
from flyteplugins.github import GitHubProvider, events
|
|
10
|
+
|
|
11
|
+
app_env = WebhookAppEnvironment(
|
|
12
|
+
name="github-webhooks",
|
|
13
|
+
providers=[GitHubProvider()],
|
|
14
|
+
secrets=[flyte.Secret("GITHUB_WEBHOOK_SECRET", as_env_var="GITHUB_WEBHOOK_SECRET")],
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app_env.on_event(events.PullRequest.OPENED)
|
|
19
|
+
async def triage(event):
|
|
20
|
+
import flyte.remote as remote
|
|
21
|
+
|
|
22
|
+
task = remote.Task.get(name="github-triage.triage_pr", auto_version="latest")
|
|
23
|
+
result = await run_once.aio(task, key=event.dedupe_key(), repo=event.scope)
|
|
24
|
+
if not result.created:
|
|
25
|
+
return {"skipped": result.run.name, "url": result.run.url}
|
|
26
|
+
return {"run": result.run.name}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Human review gates
|
|
30
|
+
|
|
31
|
+
`review_pr` parks a run on a `flyte.new_condition` carrying the pull request's
|
|
32
|
+
metadata as JSON, waits for a human to answer in the Flyte UI, and returns a
|
|
33
|
+
typed decision:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from flyteplugins.github import review_pr
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@env.task
|
|
40
|
+
async def gated_merge(repo: str, number: int) -> str:
|
|
41
|
+
decision = await review_pr(repo, number)
|
|
42
|
+
if decision.is_approved:
|
|
43
|
+
... # merge, with PyGithub
|
|
44
|
+
return f"blocked: {decision.summary}"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
It lives here because the condition is the part only Flyte can do. Reading the
|
|
48
|
+
pull request is `PyGithub`'s job, and this calls it directly rather than
|
|
49
|
+
wrapping it — install `flyteplugins-github[review]` for that extra.
|
|
50
|
+
|
|
51
|
+
Calling the GitHub API for anything else is not this plugin's job either; use
|
|
52
|
+
`PyGithub` from your tasks. See `examples/external_saas_integrations`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
import hashlib
|
|
56
|
+
import hmac
|
|
57
|
+
|
|
58
|
+
from . import events
|
|
59
|
+
from ._provider import GitHubProvider, handshake, parse, verify
|
|
60
|
+
from ._review import (
|
|
61
|
+
DEFAULT_TOKEN_ENV_VAR,
|
|
62
|
+
ReviewComment,
|
|
63
|
+
ReviewContext,
|
|
64
|
+
ReviewDecision,
|
|
65
|
+
Verdict,
|
|
66
|
+
build_review_prompt,
|
|
67
|
+
collect_review_context,
|
|
68
|
+
condition_name_for,
|
|
69
|
+
parse_review_payload,
|
|
70
|
+
review_pr,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
__all__ = [
|
|
74
|
+
"DEFAULT_TOKEN_ENV_VAR",
|
|
75
|
+
"SAMPLE_DELIVERY",
|
|
76
|
+
"GitHubProvider",
|
|
77
|
+
"ReviewComment",
|
|
78
|
+
"ReviewContext",
|
|
79
|
+
"ReviewDecision",
|
|
80
|
+
"Verdict",
|
|
81
|
+
"build_review_prompt",
|
|
82
|
+
"collect_review_context",
|
|
83
|
+
"condition_name_for",
|
|
84
|
+
"events",
|
|
85
|
+
"handshake",
|
|
86
|
+
"parse",
|
|
87
|
+
"parse_review_payload",
|
|
88
|
+
"review_pr",
|
|
89
|
+
"verify",
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _sample_headers(body: bytes, secret: str) -> dict[str, str]:
|
|
94
|
+
signature = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
|
|
95
|
+
return {
|
|
96
|
+
"X-GitHub-Event": "pull_request",
|
|
97
|
+
"X-GitHub-Delivery": "00000000-0000-0000-0000-000000000000",
|
|
98
|
+
"X-Hub-Signature-256": f"sha256={signature}",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
#: A real `pull_request.opened` delivery, trimmed to the fields the parser reads.
|
|
103
|
+
#: The conformance harness signs and replays it, so `verify` and `parse` are
|
|
104
|
+
#: checked against an actual payload rather than against each other.
|
|
105
|
+
SAMPLE_DELIVERY = (
|
|
106
|
+
_sample_headers,
|
|
107
|
+
(
|
|
108
|
+
b'{"action": "opened", "number": 7,'
|
|
109
|
+
b' "pull_request": {"number": 7, "title": "Add a feature",'
|
|
110
|
+
b' "html_url": "https://github.com/octo/repo/pull/7", "updated_at": "2024-01-01T00:00:00Z"},'
|
|
111
|
+
b' "repository": {"full_name": "octo/repo"}, "sender": {"login": "octocat"}}'
|
|
112
|
+
),
|
|
113
|
+
)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""GitHub webhook verification and payload normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar, Mapping
|
|
6
|
+
|
|
7
|
+
from flyte.extras.webhooks import (
|
|
8
|
+
Provider,
|
|
9
|
+
SignatureError,
|
|
10
|
+
WebhookEvent,
|
|
11
|
+
constant_time_equals,
|
|
12
|
+
hex_hmac_sha256,
|
|
13
|
+
json_body,
|
|
14
|
+
lower_headers,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
|
|
19
|
+
"""Verify the `X-Hub-Signature-256` HMAC over the raw body."""
|
|
20
|
+
signature = lower_headers(headers).get("x-hub-signature-256")
|
|
21
|
+
if not signature or not signature.startswith("sha256="):
|
|
22
|
+
return False
|
|
23
|
+
return constant_time_equals(hex_hmac_sha256(secret, body), signature.removeprefix("sha256="))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def handshake(headers: Mapping[str, str], body: bytes) -> dict[str, Any] | None:
|
|
27
|
+
"""Answer the `ping` GitHub sends when a webhook is created."""
|
|
28
|
+
if lower_headers(headers).get("x-github-event") == "ping":
|
|
29
|
+
return {"ok": True, "ping": True}
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
|
|
34
|
+
"""Normalize a GitHub delivery into a `WebhookEvent`."""
|
|
35
|
+
lowered = lower_headers(headers)
|
|
36
|
+
event_type = lowered.get("x-github-event")
|
|
37
|
+
if not event_type:
|
|
38
|
+
raise SignatureError("missing X-GitHub-Event header")
|
|
39
|
+
payload = json_body(body)
|
|
40
|
+
|
|
41
|
+
repo = payload.get("repository") or {}
|
|
42
|
+
issue_or_pr = payload.get("pull_request") or payload.get("issue") or {}
|
|
43
|
+
# `comment` covers issue_comment / commit_comment / review comments; `review`
|
|
44
|
+
# covers pull_request_review. Either identifies the event within its issue, so
|
|
45
|
+
# two comments on one issue do not collapse onto a single dedupe key.
|
|
46
|
+
comment = payload.get("comment") or payload.get("review") or {}
|
|
47
|
+
number = issue_or_pr.get("number")
|
|
48
|
+
|
|
49
|
+
resource = None
|
|
50
|
+
if repo.get("full_name") and number is not None:
|
|
51
|
+
resource = f"{repo['full_name']}#{number}"
|
|
52
|
+
if comment.get("id") is not None:
|
|
53
|
+
resource = f"{resource}:{comment['id']}"
|
|
54
|
+
|
|
55
|
+
return WebhookEvent(
|
|
56
|
+
provider="github",
|
|
57
|
+
event_type=event_type,
|
|
58
|
+
action=payload.get("action"),
|
|
59
|
+
delivery_id=lowered.get("x-github-delivery", ""),
|
|
60
|
+
resource_id=resource,
|
|
61
|
+
occurred_at=issue_or_pr.get("updated_at"),
|
|
62
|
+
scope=repo.get("full_name"),
|
|
63
|
+
title=issue_or_pr.get("title"),
|
|
64
|
+
url=comment.get("html_url") or issue_or_pr.get("html_url"),
|
|
65
|
+
actor=(payload.get("sender") or {}).get("login"),
|
|
66
|
+
payload=payload,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class GitHubProvider(Provider):
|
|
71
|
+
"""GitHub's webhook provider, with its defaults pre-wired.
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from flyte.extras.webhooks import WebhookAppEnvironment
|
|
75
|
+
from flyteplugins.github import GitHubProvider
|
|
76
|
+
|
|
77
|
+
app_env = WebhookAppEnvironment(name="webhooks", providers=[GitHubProvider()])
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
`WebhookAppEnvironment` mounts `default_secret_env` for you, so it does not
|
|
81
|
+
need naming again in `secrets=`.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
secret_env: Environment variable holding the secret. Pass one only to
|
|
85
|
+
point this provider at a secret stored under a different name;
|
|
86
|
+
otherwise `default_secret_env` applies.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
default_secret_env: ClassVar[str] = "GITHUB_WEBHOOK_SECRET"
|
|
90
|
+
|
|
91
|
+
def __init__(self, *, secret_env: str | None = None) -> None:
|
|
92
|
+
super().__init__(
|
|
93
|
+
name="github",
|
|
94
|
+
secret_env=secret_env or self.default_secret_env,
|
|
95
|
+
verify=verify,
|
|
96
|
+
parse=parse,
|
|
97
|
+
handshake=handshake,
|
|
98
|
+
setup_hint="repository Settings -> Webhooks -> Add webhook",
|
|
99
|
+
)
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Pull-request review gates built on `flyte.new_condition`.
|
|
2
|
+
|
|
3
|
+
The pattern: a task collects review metadata from a pull request, embeds it as
|
|
4
|
+
JSON in a markdown condition prompt, parks the run until a human responds in the
|
|
5
|
+
Flyte UI, and parses the structured response back into a typed `ReviewDecision`
|
|
6
|
+
the workflow can branch on.
|
|
7
|
+
|
|
8
|
+
This belongs in the plugin rather than in an example because the condition is
|
|
9
|
+
the part that only Flyte can do. Reading the pull request is PyGithub's job, and
|
|
10
|
+
this module calls it directly rather than wrapping it.
|
|
11
|
+
|
|
12
|
+
`PyGithub` is an optional extra, so a webhook-only install stays lean:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install "flyteplugins-github[review]"
|
|
16
|
+
```
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
from datetime import timedelta
|
|
25
|
+
from typing import Any, Literal
|
|
26
|
+
|
|
27
|
+
from pydantic import BaseModel, Field
|
|
28
|
+
|
|
29
|
+
#: Environment variable holding the token used to read the pull request.
|
|
30
|
+
DEFAULT_TOKEN_ENV_VAR = "GITHUB_TOKEN"
|
|
31
|
+
|
|
32
|
+
#: Condition names are action names on the backend; keep them comfortably short.
|
|
33
|
+
_MAX_CONDITION_NAME = 60
|
|
34
|
+
|
|
35
|
+
Verdict = Literal["approve", "request_changes", "comment"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class ReviewComment(BaseModel):
|
|
39
|
+
"""A single inline review comment."""
|
|
40
|
+
|
|
41
|
+
path: str
|
|
42
|
+
line: int | None = None
|
|
43
|
+
body: str
|
|
44
|
+
severity: Literal["info", "warning", "blocking"] = "info"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ReviewDecision(BaseModel):
|
|
48
|
+
"""Structured decision parsed from a reviewer's condition response."""
|
|
49
|
+
|
|
50
|
+
verdict: Verdict
|
|
51
|
+
summary: str = ""
|
|
52
|
+
comments: list[ReviewComment] = Field(default_factory=list)
|
|
53
|
+
reviewer: str | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def is_approved(self) -> bool:
|
|
57
|
+
"""True when the reviewer approved the change."""
|
|
58
|
+
return self.verdict == "approve"
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def blocking_comments(self) -> list[ReviewComment]:
|
|
62
|
+
"""Comments the reviewer flagged as blocking."""
|
|
63
|
+
return [c for c in self.comments if c.severity == "blocking"]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ReviewContext(BaseModel):
|
|
67
|
+
"""Review metadata collected from a pull request.
|
|
68
|
+
|
|
69
|
+
This is the payload embedded in the condition prompt, so the reviewer sees
|
|
70
|
+
everything needed to decide without leaving the Flyte UI.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
repo: str
|
|
74
|
+
number: int
|
|
75
|
+
title: str
|
|
76
|
+
author: str | None = None
|
|
77
|
+
body: str = ""
|
|
78
|
+
base: str | None = None
|
|
79
|
+
head: str | None = None
|
|
80
|
+
url: str | None = None
|
|
81
|
+
additions: int | None = None
|
|
82
|
+
deletions: int | None = None
|
|
83
|
+
changed_files: int | None = None
|
|
84
|
+
files: list[dict[str, Any]] = Field(default_factory=list)
|
|
85
|
+
prior_reviews: list[dict[str, Any]] = Field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def to_json(self, max_file_patches: int = 20) -> str:
|
|
88
|
+
"""Serialize to JSON for embedding in a prompt.
|
|
89
|
+
|
|
90
|
+
Patches dominate the size of a large diff, so only the first
|
|
91
|
+
`max_file_patches` files keep theirs — the rest keep their stats.
|
|
92
|
+
"""
|
|
93
|
+
data = self.model_dump()
|
|
94
|
+
for i, f in enumerate(data["files"]):
|
|
95
|
+
if i >= max_file_patches:
|
|
96
|
+
f.pop("patch", None)
|
|
97
|
+
return json.dumps(data, indent=2)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _github(token: str | None = None):
|
|
101
|
+
"""Build a PyGithub client, with a useful error when the extra is missing."""
|
|
102
|
+
try:
|
|
103
|
+
from github import Auth, Github
|
|
104
|
+
except ModuleNotFoundError as exc: # pragma: no cover - depends on extras
|
|
105
|
+
raise ModuleNotFoundError(
|
|
106
|
+
"PyGithub is not installed. Install 'flyteplugins-github[review]' to use the review gate."
|
|
107
|
+
) from exc
|
|
108
|
+
|
|
109
|
+
resolved = token if token is not None else os.environ.get(DEFAULT_TOKEN_ENV_VAR)
|
|
110
|
+
if not resolved:
|
|
111
|
+
raise ValueError(
|
|
112
|
+
f"{DEFAULT_TOKEN_ENV_VAR} is not set. Create the secret and request it on the task's environment: "
|
|
113
|
+
f"secrets=[flyte.Secret('{DEFAULT_TOKEN_ENV_VAR}', as_env_var='{DEFAULT_TOKEN_ENV_VAR}')]"
|
|
114
|
+
)
|
|
115
|
+
return Github(auth=Auth.Token(resolved))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _collect_sync(repo: str, number: int, max_files: int, token: str | None) -> ReviewContext:
|
|
119
|
+
with _github(token) as gh:
|
|
120
|
+
pull = gh.get_repo(repo).get_pull(number)
|
|
121
|
+
files = [
|
|
122
|
+
{
|
|
123
|
+
"filename": f.filename,
|
|
124
|
+
"status": f.status,
|
|
125
|
+
"additions": f.additions,
|
|
126
|
+
"deletions": f.deletions,
|
|
127
|
+
"changes": f.changes,
|
|
128
|
+
"patch": f.patch,
|
|
129
|
+
}
|
|
130
|
+
for f in pull.get_files()[:max_files]
|
|
131
|
+
]
|
|
132
|
+
try:
|
|
133
|
+
prior = [
|
|
134
|
+
{"user": r.user.login if r.user else None, "state": r.state, "body": r.body or ""}
|
|
135
|
+
for r in pull.get_reviews()
|
|
136
|
+
]
|
|
137
|
+
except Exception:
|
|
138
|
+
# Prior reviews are context, not a requirement — a token without
|
|
139
|
+
# permission to list them should not block the gate.
|
|
140
|
+
prior = []
|
|
141
|
+
return ReviewContext(
|
|
142
|
+
repo=repo,
|
|
143
|
+
number=number,
|
|
144
|
+
title=pull.title,
|
|
145
|
+
author=pull.user.login if pull.user else None,
|
|
146
|
+
body=pull.body or "",
|
|
147
|
+
base=pull.base.ref,
|
|
148
|
+
head=pull.head.ref,
|
|
149
|
+
url=pull.html_url,
|
|
150
|
+
additions=pull.additions,
|
|
151
|
+
deletions=pull.deletions,
|
|
152
|
+
changed_files=pull.changed_files,
|
|
153
|
+
files=files,
|
|
154
|
+
prior_reviews=prior,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def collect_review_context(
|
|
159
|
+
repo: str,
|
|
160
|
+
number: int,
|
|
161
|
+
*,
|
|
162
|
+
max_files: int = 50,
|
|
163
|
+
token: str | None = None,
|
|
164
|
+
) -> ReviewContext:
|
|
165
|
+
"""Fetch a pull request and assemble the metadata a reviewer needs.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
repo: Repository full name (`owner/repo`).
|
|
169
|
+
number: Pull request number.
|
|
170
|
+
max_files: Cap on how many changed files to include.
|
|
171
|
+
token: Explicit token; otherwise read from `GITHUB_TOKEN`.
|
|
172
|
+
"""
|
|
173
|
+
# PyGithub is synchronous, so keep it off the caller's event loop.
|
|
174
|
+
return await asyncio.to_thread(_collect_sync, repo, number, max_files, token)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_review_prompt(context: ReviewContext, instructions: str = "") -> str:
|
|
178
|
+
"""Build the markdown prompt shown to the reviewer in the Flyte UI.
|
|
179
|
+
|
|
180
|
+
The metadata is embedded as a fenced JSON block so it renders verbatim and
|
|
181
|
+
can be machine-parsed downstream.
|
|
182
|
+
"""
|
|
183
|
+
instructions = instructions or (
|
|
184
|
+
"Review this pull request. Respond with a JSON object of the form:\n"
|
|
185
|
+
'`{"verdict": "approve" | "request_changes" | "comment", '
|
|
186
|
+
'"summary": "...", "comments": [{"path": "...", "line": 1, '
|
|
187
|
+
'"body": "...", "severity": "info" | "warning" | "blocking"}]}`'
|
|
188
|
+
)
|
|
189
|
+
return (
|
|
190
|
+
f"## Review requested: {context.repo}#{context.number}\n\n"
|
|
191
|
+
f"**{context.title}** (by {context.author or 'unknown'})\n\n"
|
|
192
|
+
f"{context.body}\n\n"
|
|
193
|
+
f"{instructions}\n\n"
|
|
194
|
+
"### Pull request metadata\n\n"
|
|
195
|
+
"```json\n"
|
|
196
|
+
f"{context.to_json()}\n"
|
|
197
|
+
"```\n"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _normalize_verdict(value: str) -> Verdict:
|
|
202
|
+
v = value.strip().lower().replace(" ", "_").replace("-", "_")
|
|
203
|
+
if v in ("approve", "approved", "lgtm", "accept"):
|
|
204
|
+
return "approve"
|
|
205
|
+
if v in ("request_changes", "changes_requested", "reject", "blocked"):
|
|
206
|
+
return "request_changes"
|
|
207
|
+
if v in ("comment", "comments", "neutral", "note"):
|
|
208
|
+
return "comment"
|
|
209
|
+
raise ValueError(f"unknown verdict: {value!r}")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def parse_review_payload(payload: str) -> ReviewDecision:
|
|
213
|
+
"""Parse a reviewer's condition response into a `ReviewDecision`.
|
|
214
|
+
|
|
215
|
+
Accepts raw JSON, JSON inside a fenced code block, or prose with a JSON
|
|
216
|
+
object somewhere in it — reviewers paste all three. Verdict synonyms
|
|
217
|
+
(`approved`, `changes_requested`, `lgtm`, ...) are normalized.
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
ValueError: when no JSON object with a recognizable verdict can be
|
|
221
|
+
extracted from the payload.
|
|
222
|
+
"""
|
|
223
|
+
text = (payload or "").strip()
|
|
224
|
+
if not text:
|
|
225
|
+
raise ValueError("empty review payload")
|
|
226
|
+
|
|
227
|
+
# Scan every `{` and let raw_decode tolerate trailing content, so a JSON
|
|
228
|
+
# object wrapped in prose or a fenced block is still found.
|
|
229
|
+
decoder = json.JSONDecoder()
|
|
230
|
+
idx = text.find("{")
|
|
231
|
+
while idx != -1:
|
|
232
|
+
try:
|
|
233
|
+
obj, _ = decoder.raw_decode(text[idx:])
|
|
234
|
+
except json.JSONDecodeError:
|
|
235
|
+
obj = None
|
|
236
|
+
if isinstance(obj, dict) and "verdict" in obj:
|
|
237
|
+
comments = obj.get("comments") or []
|
|
238
|
+
return ReviewDecision(
|
|
239
|
+
verdict=_normalize_verdict(str(obj["verdict"])),
|
|
240
|
+
summary=str(obj.get("summary") or ""),
|
|
241
|
+
comments=[ReviewComment.model_validate(c) for c in comments if isinstance(c, dict)],
|
|
242
|
+
reviewer=obj.get("reviewer"),
|
|
243
|
+
)
|
|
244
|
+
idx = text.find("{", idx + 1)
|
|
245
|
+
raise ValueError(f"could not extract a review decision from payload: {text[:200]!r}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def condition_name_for(repo: str, number: int) -> str:
|
|
249
|
+
"""Derive a condition name from a pull request, within the length limit."""
|
|
250
|
+
name = f"review-{repo.replace('/', '-')}-{number}"
|
|
251
|
+
if len(name) <= _MAX_CONDITION_NAME:
|
|
252
|
+
return name
|
|
253
|
+
# Keep the number, which is what distinguishes one review from the next.
|
|
254
|
+
suffix = f"-{number}"
|
|
255
|
+
return name[: _MAX_CONDITION_NAME - len(suffix)] + suffix
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def review_pr(
|
|
259
|
+
repo: str,
|
|
260
|
+
number: int,
|
|
261
|
+
*,
|
|
262
|
+
condition_name: str | None = None,
|
|
263
|
+
instructions: str = "",
|
|
264
|
+
timeout: timedelta | int | float | None = None,
|
|
265
|
+
max_files: int = 50,
|
|
266
|
+
token: str | None = None,
|
|
267
|
+
) -> ReviewDecision:
|
|
268
|
+
"""Park the run on a human review condition and return the decision.
|
|
269
|
+
|
|
270
|
+
Collects the pull request's metadata, raises a markdown condition carrying
|
|
271
|
+
it as JSON, waits for a human to respond in the Flyte UI, and parses the
|
|
272
|
+
response into a typed decision:
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
@env.task
|
|
276
|
+
async def gated_merge(repo: str, number: int) -> str:
|
|
277
|
+
decision = await review_pr(repo, number)
|
|
278
|
+
if decision.is_approved:
|
|
279
|
+
...
|
|
280
|
+
return f"blocked: {decision.summary}"
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
repo: Repository full name (`owner/repo`).
|
|
285
|
+
number: Pull request number.
|
|
286
|
+
condition_name: Name of the condition action; defaults to one derived
|
|
287
|
+
from the repo and number.
|
|
288
|
+
instructions: Override for the reviewer instructions in the prompt.
|
|
289
|
+
timeout: Forwarded to `flyte.new_condition`. On expiry `wait()` raises
|
|
290
|
+
`flyte.errors.ConditionTimedoutError`.
|
|
291
|
+
max_files: Cap on how many changed files to include in the prompt.
|
|
292
|
+
token: Explicit token; otherwise read from `GITHUB_TOKEN`.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
The parsed `ReviewDecision`.
|
|
296
|
+
|
|
297
|
+
Raises:
|
|
298
|
+
ValueError: when the reviewer's response carries no recognizable verdict.
|
|
299
|
+
"""
|
|
300
|
+
import flyte
|
|
301
|
+
|
|
302
|
+
context = await collect_review_context(repo, number, max_files=max_files, token=token)
|
|
303
|
+
condition = await flyte.new_condition.aio(
|
|
304
|
+
condition_name or condition_name_for(repo, number),
|
|
305
|
+
prompt=build_review_prompt(context, instructions=instructions),
|
|
306
|
+
prompt_type="markdown",
|
|
307
|
+
data_type=str,
|
|
308
|
+
timeout=timeout,
|
|
309
|
+
)
|
|
310
|
+
return parse_review_payload(await condition.wait.aio())
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""GitHub webhook events (the `X-GitHub-Event` header plus the payload action)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flyte.extras.webhooks import EventType
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"CheckRun",
|
|
9
|
+
"CheckSuite",
|
|
10
|
+
"Create",
|
|
11
|
+
"Delete",
|
|
12
|
+
"Fork",
|
|
13
|
+
"IssueComment",
|
|
14
|
+
"Issues",
|
|
15
|
+
"PullRequest",
|
|
16
|
+
"PullRequestReview",
|
|
17
|
+
"PullRequestReviewComment",
|
|
18
|
+
"Push",
|
|
19
|
+
"Release",
|
|
20
|
+
"Star",
|
|
21
|
+
"WorkflowRun",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PullRequest(EventType):
|
|
26
|
+
"""`pull_request` events."""
|
|
27
|
+
|
|
28
|
+
ANY = "pull_request"
|
|
29
|
+
OPENED = "pull_request.opened"
|
|
30
|
+
CLOSED = "pull_request.closed"
|
|
31
|
+
"""Fires on merge and on close-without-merge; check `payload["pull_request"]["merged"]`."""
|
|
32
|
+
REOPENED = "pull_request.reopened"
|
|
33
|
+
EDITED = "pull_request.edited"
|
|
34
|
+
ASSIGNED = "pull_request.assigned"
|
|
35
|
+
UNASSIGNED = "pull_request.unassigned"
|
|
36
|
+
LABELED = "pull_request.labeled"
|
|
37
|
+
UNLABELED = "pull_request.unlabeled"
|
|
38
|
+
SYNCHRONIZE = "pull_request.synchronize"
|
|
39
|
+
"""New commits were pushed to the PR's head branch."""
|
|
40
|
+
READY_FOR_REVIEW = "pull_request.ready_for_review"
|
|
41
|
+
CONVERTED_TO_DRAFT = "pull_request.converted_to_draft"
|
|
42
|
+
REVIEW_REQUESTED = "pull_request.review_requested"
|
|
43
|
+
REVIEW_REQUEST_REMOVED = "pull_request.review_request_removed"
|
|
44
|
+
LOCKED = "pull_request.locked"
|
|
45
|
+
UNLOCKED = "pull_request.unlocked"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Issues(EventType):
|
|
49
|
+
"""`issues` events. Note GitHub's event type is plural."""
|
|
50
|
+
|
|
51
|
+
ANY = "issues"
|
|
52
|
+
OPENED = "issues.opened"
|
|
53
|
+
CLOSED = "issues.closed"
|
|
54
|
+
REOPENED = "issues.reopened"
|
|
55
|
+
EDITED = "issues.edited"
|
|
56
|
+
ASSIGNED = "issues.assigned"
|
|
57
|
+
UNASSIGNED = "issues.unassigned"
|
|
58
|
+
LABELED = "issues.labeled"
|
|
59
|
+
UNLABELED = "issues.unlabeled"
|
|
60
|
+
MILESTONED = "issues.milestoned"
|
|
61
|
+
DEMILESTONED = "issues.demilestoned"
|
|
62
|
+
PINNED = "issues.pinned"
|
|
63
|
+
UNPINNED = "issues.unpinned"
|
|
64
|
+
LOCKED = "issues.locked"
|
|
65
|
+
UNLOCKED = "issues.unlocked"
|
|
66
|
+
TRANSFERRED = "issues.transferred"
|
|
67
|
+
DELETED = "issues.deleted"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class IssueComment(EventType):
|
|
71
|
+
"""`issue_comment` events, on both issues and pull requests."""
|
|
72
|
+
|
|
73
|
+
ANY = "issue_comment"
|
|
74
|
+
CREATED = "issue_comment.created"
|
|
75
|
+
EDITED = "issue_comment.edited"
|
|
76
|
+
DELETED = "issue_comment.deleted"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class PullRequestReview(EventType):
|
|
80
|
+
"""`pull_request_review` events."""
|
|
81
|
+
|
|
82
|
+
ANY = "pull_request_review"
|
|
83
|
+
SUBMITTED = "pull_request_review.submitted"
|
|
84
|
+
EDITED = "pull_request_review.edited"
|
|
85
|
+
DISMISSED = "pull_request_review.dismissed"
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PullRequestReviewComment(EventType):
|
|
89
|
+
"""`pull_request_review_comment` events — inline comments on a diff."""
|
|
90
|
+
|
|
91
|
+
ANY = "pull_request_review_comment"
|
|
92
|
+
CREATED = "pull_request_review_comment.created"
|
|
93
|
+
EDITED = "pull_request_review_comment.edited"
|
|
94
|
+
DELETED = "pull_request_review_comment.deleted"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Push(EventType):
|
|
98
|
+
"""`push` events. GitHub sends no action, so there is only `ANY`."""
|
|
99
|
+
|
|
100
|
+
ANY = "push"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Create(EventType):
|
|
104
|
+
"""`create` events — a branch or tag was created. No action."""
|
|
105
|
+
|
|
106
|
+
ANY = "create"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class Delete(EventType):
|
|
110
|
+
"""`delete` events — a branch or tag was deleted. No action."""
|
|
111
|
+
|
|
112
|
+
ANY = "delete"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class Fork(EventType):
|
|
116
|
+
"""`fork` events. No action."""
|
|
117
|
+
|
|
118
|
+
ANY = "fork"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Release(EventType):
|
|
122
|
+
"""`release` events."""
|
|
123
|
+
|
|
124
|
+
ANY = "release"
|
|
125
|
+
PUBLISHED = "release.published"
|
|
126
|
+
UNPUBLISHED = "release.unpublished"
|
|
127
|
+
CREATED = "release.created"
|
|
128
|
+
EDITED = "release.edited"
|
|
129
|
+
DELETED = "release.deleted"
|
|
130
|
+
PRERELEASED = "release.prereleased"
|
|
131
|
+
RELEASED = "release.released"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class WorkflowRun(EventType):
|
|
135
|
+
"""`workflow_run` events — GitHub Actions run lifecycle."""
|
|
136
|
+
|
|
137
|
+
ANY = "workflow_run"
|
|
138
|
+
REQUESTED = "workflow_run.requested"
|
|
139
|
+
IN_PROGRESS = "workflow_run.in_progress"
|
|
140
|
+
COMPLETED = "workflow_run.completed"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class CheckRun(EventType):
|
|
144
|
+
"""`check_run` events."""
|
|
145
|
+
|
|
146
|
+
ANY = "check_run"
|
|
147
|
+
CREATED = "check_run.created"
|
|
148
|
+
COMPLETED = "check_run.completed"
|
|
149
|
+
REREQUESTED = "check_run.rerequested"
|
|
150
|
+
REQUESTED_ACTION = "check_run.requested_action"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class CheckSuite(EventType):
|
|
154
|
+
"""`check_suite` events."""
|
|
155
|
+
|
|
156
|
+
ANY = "check_suite"
|
|
157
|
+
COMPLETED = "check_suite.completed"
|
|
158
|
+
REQUESTED = "check_suite.requested"
|
|
159
|
+
REREQUESTED = "check_suite.rerequested"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class Star(EventType):
|
|
163
|
+
"""`star` events."""
|
|
164
|
+
|
|
165
|
+
ANY = "star"
|
|
166
|
+
CREATED = "star.created"
|
|
167
|
+
DELETED = "star.deleted"
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-github
|
|
3
|
+
Version: 2.7.0
|
|
4
|
+
Summary: Receive GitHub webhooks in Flyte.
|
|
5
|
+
Author: Flyte Contributors
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: flyte
|
|
9
|
+
Provides-Extra: app
|
|
10
|
+
Requires-Dist: fastapi>=0.115; extra == "app"
|
|
11
|
+
Requires-Dist: uvicorn>=0.30; extra == "app"
|
|
12
|
+
Provides-Extra: review
|
|
13
|
+
Requires-Dist: PyGithub>=2; extra == "review"
|
|
14
|
+
|
|
15
|
+
# flyteplugins-github
|
|
16
|
+
|
|
17
|
+
Receive GitHub webhooks in Flyte.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install "flyteplugins-github[app]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Using it
|
|
24
|
+
|
|
25
|
+
Hand a `GitHubProvider()` to a `WebhookAppEnvironment` and register handlers with the
|
|
26
|
+
typed constants in `events`:
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import flyte
|
|
30
|
+
from flyte.extras.webhooks import WebhookAppEnvironment, run_once
|
|
31
|
+
from flyteplugins.github import GitHubProvider, events
|
|
32
|
+
|
|
33
|
+
# GitHubProvider.default_secret_env is mounted for you.
|
|
34
|
+
app_env = WebhookAppEnvironment(name="github-webhooks", providers=[GitHubProvider()])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app_env.on_event(events.PullRequest.OPENED)
|
|
38
|
+
async def handle(event):
|
|
39
|
+
import flyte.remote as remote
|
|
40
|
+
|
|
41
|
+
task = remote.Task.get(name="my-env.my_task", auto_version="latest")
|
|
42
|
+
result = await run_once.aio(task, key=event.dedupe_key(), resource=event.resource_id)
|
|
43
|
+
if not result.created:
|
|
44
|
+
return {"skipped": result.run.name, "url": result.run.url}
|
|
45
|
+
return {"run": result.run.name}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
flyte.serve(app_env)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Handlers must `await run_once.aio(...)`. The blocking form stalls the
|
|
52
|
+
app's event loop, and GitHub times deliveries out in seconds.
|
|
53
|
+
|
|
54
|
+
One app can serve several products at once — hand it one provider per product.
|
|
55
|
+
|
|
56
|
+
## Human review gates
|
|
57
|
+
|
|
58
|
+
`review_pr` parks a run on a `flyte.new_condition` carrying the pull request's
|
|
59
|
+
metadata as JSON, waits for a human to answer in the Flyte UI, and returns a
|
|
60
|
+
typed decision the workflow branches on:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from flyteplugins.github import review_pr
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@env.task
|
|
67
|
+
async def gated_merge(repo: str, number: int) -> str:
|
|
68
|
+
decision = await review_pr(repo, number)
|
|
69
|
+
if not decision.is_approved:
|
|
70
|
+
return f"blocked: {decision.summary}"
|
|
71
|
+
... # merge, with PyGithub
|
|
72
|
+
return "merged"
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The reviewer answers in markdown; `parse_review_payload` accepts raw JSON, a
|
|
76
|
+
fenced block, or JSON buried in prose, and normalizes verdict synonyms
|
|
77
|
+
(`lgtm`, `approved`, `changes_requested`, ...) — because people paste all of
|
|
78
|
+
those.
|
|
79
|
+
|
|
80
|
+
This lives in the plugin because the condition is the part only Flyte can do.
|
|
81
|
+
Reading the pull request is `PyGithub`'s job, which the gate calls directly
|
|
82
|
+
rather than wrapping:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install "flyteplugins-github[review]"
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Try it
|
|
89
|
+
|
|
90
|
+
`examples/github_webhooks.py` runs two ways. The first needs no GitHub account:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python examples/github_webhooks.py --local # replay a real sample delivery in-process
|
|
94
|
+
python examples/github_webhooks.py # deploy the receiver to Flyte
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
|
|
98
|
+
test client, so you see a delivery verified, normalized, and dispatched — plus
|
|
99
|
+
an unsigned one refused with a 401, and the same delivery replayed to show the
|
|
100
|
+
dedupe key is stable.
|
|
101
|
+
|
|
102
|
+
## Setup
|
|
103
|
+
|
|
104
|
+
1. Store the secret and mount it on the app:
|
|
105
|
+
```bash
|
|
106
|
+
flyte create secret GITHUB_WEBHOOK_SECRET --value <secret>
|
|
107
|
+
```
|
|
108
|
+
2. Point GitHub at `<app-url>/webhook/github`, from
|
|
109
|
+
repository Settings → Webhooks → Add webhook, content type `application/json`.
|
|
110
|
+
|
|
111
|
+
GitHub sends a `ping` when the webhook is created; it is answered automatically, so a green check in *Recent Deliveries* means the app is reachable.
|
|
112
|
+
|
|
113
|
+
**Verification:** HMAC-SHA256 over the raw body (`X-Hub-Signature-256`).
|
|
114
|
+
|
|
115
|
+
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.
|
|
116
|
+
|
|
117
|
+
## Event constants
|
|
118
|
+
|
|
119
|
+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
|
|
120
|
+
event type, so a typo fails at import rather than by silently never matching.
|
|
121
|
+
Raw strings still work, for events the constants do not cover yet.
|
|
122
|
+
|
|
123
|
+
## What this plugin does not do
|
|
124
|
+
|
|
125
|
+
Call the GitHub API. Use `PyGithub` directly from your tasks — see
|
|
126
|
+
`examples/external_saas_integrations`. This plugin owns only the part that is
|
|
127
|
+
Flyte's: authenticating an inbound delivery and turning it into a run.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
flyteplugins/github/__init__.py,sha256=rNlGTRndjurjvDCKmaGqj1hiQGpS9woDlTYmRmGmJDk,3352
|
|
2
|
+
flyteplugins/github/_provider.py,sha256=3mK9p-avlwR7G4j2l5FikwzaxRZVyuTR-9lHeGSI5Po,3556
|
|
3
|
+
flyteplugins/github/_review.py,sha256=6XwWI-lOZ3DIshIRU8i-FrzepJtvXmaVm_wbFAA8Iuw,11037
|
|
4
|
+
flyteplugins/github/events.py,sha256=_SghpvwL_m7RjPzVjMx04G-1OorNAI1275vVyuFSmkY,4358
|
|
5
|
+
flyteplugins_github-2.7.0.dist-info/METADATA,sha256=ftkbcxjo2m22AivfHTpAv7hucezCNsnX2no5soIZba8,4231
|
|
6
|
+
flyteplugins_github-2.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
flyteplugins_github-2.7.0.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
8
|
+
flyteplugins_github-2.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|