flyteplugins-github 2.7.2__py3-none-any.whl → 2.8.1__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 +22 -2
- flyteplugins/github/_app_auth.py +146 -0
- flyteplugins/github/_provider.py +44 -3
- flyteplugins/github/events.py +25 -0
- flyteplugins/github/payloads.py +154 -0
- flyteplugins_github-2.8.1.dist-info/METADATA +297 -0
- flyteplugins_github-2.8.1.dist-info/RECORD +10 -0
- flyteplugins_github-2.7.2.dist-info/METADATA +0 -127
- flyteplugins_github-2.7.2.dist-info/RECORD +0 -8
- {flyteplugins_github-2.7.2.dist-info → flyteplugins_github-2.8.1.dist-info}/WHEEL +0 -0
- {flyteplugins_github-2.7.2.dist-info → flyteplugins_github-2.8.1.dist-info}/top_level.txt +0 -0
flyteplugins/github/__init__.py
CHANGED
|
@@ -48,14 +48,31 @@ It lives here because the condition is the part only Flyte can do. Reading the
|
|
|
48
48
|
pull request is `PyGithub`'s job, and this calls it directly rather than
|
|
49
49
|
wrapping it — install `flyteplugins-github[review]` for that extra.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
## GitHub App tokens
|
|
52
|
+
|
|
53
|
+
Agents that clone, push, or open PRs authenticate best as a GitHub App,
|
|
54
|
+
minting a short-lived installation token per operation instead of holding a
|
|
55
|
+
personal access token:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from flyteplugins.github import clone_url, mint_installation_token
|
|
59
|
+
|
|
60
|
+
token = mint_installation_token() # GITHUB_APP_ID / _INSTALLATION_ID / _PRIVATE_KEY
|
|
61
|
+
url = clone_url("octo/repo", token)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
It lives here because every agent otherwise carries its own copy of the same
|
|
65
|
+
minting logic — install `flyteplugins-github[auth]` for that extra.
|
|
66
|
+
|
|
67
|
+
Wrapping the GitHub API for anything else is not this plugin's job; use
|
|
52
68
|
`PyGithub` from your tasks. See `examples/external_saas_integrations`.
|
|
53
69
|
"""
|
|
54
70
|
|
|
55
71
|
import hashlib
|
|
56
72
|
import hmac
|
|
57
73
|
|
|
58
|
-
from . import events
|
|
74
|
+
from . import events, payloads
|
|
75
|
+
from ._app_auth import clone_url, mint_installation_token
|
|
59
76
|
from ._provider import GitHubProvider, handshake, parse, verify
|
|
60
77
|
from ._review import (
|
|
61
78
|
DEFAULT_TOKEN_ENV_VAR,
|
|
@@ -79,12 +96,15 @@ __all__ = [
|
|
|
79
96
|
"ReviewDecision",
|
|
80
97
|
"Verdict",
|
|
81
98
|
"build_review_prompt",
|
|
99
|
+
"clone_url",
|
|
82
100
|
"collect_review_context",
|
|
83
101
|
"condition_name_for",
|
|
84
102
|
"events",
|
|
85
103
|
"handshake",
|
|
104
|
+
"mint_installation_token",
|
|
86
105
|
"parse",
|
|
87
106
|
"parse_review_payload",
|
|
107
|
+
"payloads",
|
|
88
108
|
"review_pr",
|
|
89
109
|
"verify",
|
|
90
110
|
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""GitHub App installation tokens, for agents that clone, push, or open PRs.
|
|
2
|
+
|
|
3
|
+
The pattern: hold no personal access token. Authenticate as a GitHub App and
|
|
4
|
+
mint a short-lived installation token whenever one is needed. Tokens live one
|
|
5
|
+
hour — plenty for a clone or a `gh pr create`, useless to an attacker who
|
|
6
|
+
exfiltrates one from a log.
|
|
7
|
+
|
|
8
|
+
This belongs in the plugin because every agent otherwise carries its own copy
|
|
9
|
+
of the same fifty lines: sign an RS256 JWT as the app, trade it for an
|
|
10
|
+
installation token, splice it into a clone URL. The webhook side of a GitHub
|
|
11
|
+
agent already imports this package, so the auth side comes from it too.
|
|
12
|
+
|
|
13
|
+
Inputs, all injected as Flyte secrets (or passed explicitly):
|
|
14
|
+
|
|
15
|
+
GITHUB_APP_ID the app's numeric id
|
|
16
|
+
GITHUB_APP_INSTALLATION_ID the installation's numeric id
|
|
17
|
+
GITHUB_APP_PRIVATE_KEY the app's PEM private key
|
|
18
|
+
|
|
19
|
+
The first two come off the app's *General* tab (App ID — not the Client ID
|
|
20
|
+
beside it) and the URL you land on after installing the app,
|
|
21
|
+
`.../settings/installations/<id>`. The third is the `.pem` from *Generate a
|
|
22
|
+
private key*, which GitHub shows exactly once. Store them with:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
flyte create secret github-app-id --value 1234567
|
|
26
|
+
flyte create secret github-app-installation-id --value 87654321
|
|
27
|
+
flyte create secret github-app-private-key --from-file ~/Downloads/app.private-key.pem
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`--from-file` because a PEM is multi-line and a shell that eats the newlines
|
|
31
|
+
yields a key that fails to parse here, at mint time. Mounted on a
|
|
32
|
+
`TaskEnvironment` as `flyte.Secret("github-app-private-key")`, the kebab-case
|
|
33
|
+
name upper-cases into the env var above with no `as_env_var=` needed. The
|
|
34
|
+
README's "GitHub App tokens" section walks through all of it, including
|
|
35
|
+
listing installation ids over the API instead of hunting for the URL.
|
|
36
|
+
|
|
37
|
+
`GITHUB_TOKEN` and `GH_TOKEN` are honored as fallbacks so a deployment can
|
|
38
|
+
migrate one secret at a time; once the app secrets exist the fallback never
|
|
39
|
+
fires.
|
|
40
|
+
|
|
41
|
+
`PyJWT[crypto]` signs the app JWT, so a webhook-only install stays lean:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install "flyteplugins-github[auth]"
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
from __future__ import annotations
|
|
49
|
+
|
|
50
|
+
import json
|
|
51
|
+
import logging
|
|
52
|
+
import os
|
|
53
|
+
import time
|
|
54
|
+
import urllib.request
|
|
55
|
+
from typing import Any
|
|
56
|
+
|
|
57
|
+
logger = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
GITHUB_API = "https://api.github.com"
|
|
60
|
+
#: App tokens authenticate with this literal username in clone URLs.
|
|
61
|
+
GIT_USERNAME = "x-access-token"
|
|
62
|
+
|
|
63
|
+
#: Environment variables the app credentials default to.
|
|
64
|
+
DEFAULT_APP_ID_ENV = "GITHUB_APP_ID"
|
|
65
|
+
DEFAULT_INSTALLATION_ID_ENV = "GITHUB_APP_INSTALLATION_ID"
|
|
66
|
+
DEFAULT_PRIVATE_KEY_ENV = "GITHUB_APP_PRIVATE_KEY"
|
|
67
|
+
|
|
68
|
+
#: Plain-token fallbacks, checked in order when the app credentials are absent.
|
|
69
|
+
FALLBACK_TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _post_json(url: str, *, bearer: str) -> dict[str, Any]:
|
|
73
|
+
"""POST to the GitHub API with a bearer credential and return the JSON."""
|
|
74
|
+
request = urllib.request.Request(
|
|
75
|
+
url,
|
|
76
|
+
method="POST",
|
|
77
|
+
headers={"Authorization": f"Bearer {bearer}", "Accept": "application/vnd.github+json"},
|
|
78
|
+
)
|
|
79
|
+
with urllib.request.urlopen(request, timeout=30) as response:
|
|
80
|
+
return json.loads(response.read().decode("utf-8"))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mint_installation_token(
|
|
84
|
+
*,
|
|
85
|
+
app_id: str | None = None,
|
|
86
|
+
installation_id: str | None = None,
|
|
87
|
+
private_key: str | None = None,
|
|
88
|
+
) -> str | None:
|
|
89
|
+
"""A fresh installation token, or None with a logged reason.
|
|
90
|
+
|
|
91
|
+
None means "proceed unauthenticated or not at all" — treat it the way a
|
|
92
|
+
missing token is treated today, so a half-configured deployment degrades
|
|
93
|
+
instead of crashing. Synchronous, one HTTPS round trip: call through
|
|
94
|
+
`asyncio.to_thread` from handlers and other async code.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
app_id: The app's numeric id; otherwise read from `GITHUB_APP_ID`.
|
|
98
|
+
installation_id: The installation's numeric id; otherwise read from
|
|
99
|
+
`GITHUB_APP_INSTALLATION_ID`.
|
|
100
|
+
private_key: The app's PEM private key; otherwise read from
|
|
101
|
+
`GITHUB_APP_PRIVATE_KEY`.
|
|
102
|
+
"""
|
|
103
|
+
app_id = app_id or os.environ.get(DEFAULT_APP_ID_ENV)
|
|
104
|
+
installation_id = installation_id or os.environ.get(DEFAULT_INSTALLATION_ID_ENV)
|
|
105
|
+
private_key = private_key or os.environ.get(DEFAULT_PRIVATE_KEY_ENV)
|
|
106
|
+
|
|
107
|
+
if not (app_id and installation_id and private_key):
|
|
108
|
+
for env in FALLBACK_TOKEN_ENVS:
|
|
109
|
+
fallback = os.environ.get(env)
|
|
110
|
+
if fallback:
|
|
111
|
+
logger.info("GitHub App credentials not set; using %s fallback", env)
|
|
112
|
+
return fallback
|
|
113
|
+
logger.warning(
|
|
114
|
+
"Neither the %s/%s/%s secrets nor a fallback token (%s) are set; "
|
|
115
|
+
"authenticated GitHub operations will be skipped",
|
|
116
|
+
DEFAULT_APP_ID_ENV,
|
|
117
|
+
DEFAULT_INSTALLATION_ID_ENV,
|
|
118
|
+
DEFAULT_PRIVATE_KEY_ENV,
|
|
119
|
+
"/".join(FALLBACK_TOKEN_ENVS),
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
import jwt # PyJWT[crypto]
|
|
125
|
+
except ModuleNotFoundError as exc: # pragma: no cover - depends on extras
|
|
126
|
+
raise ModuleNotFoundError(
|
|
127
|
+
"PyJWT is not installed. Install 'flyteplugins-github[auth]' to mint GitHub App tokens."
|
|
128
|
+
) from exc
|
|
129
|
+
|
|
130
|
+
now = int(time.time())
|
|
131
|
+
# iat is backdated 60s because GitHub rejects JWTs it considers issued in
|
|
132
|
+
# the future, and clocks drift.
|
|
133
|
+
app_jwt = jwt.encode({"iat": now - 60, "exp": now + 600, "iss": app_id}, private_key, algorithm="RS256")
|
|
134
|
+
try:
|
|
135
|
+
data = _post_json(f"{GITHUB_API}/app/installations/{installation_id}/access_tokens", bearer=app_jwt)
|
|
136
|
+
return data["token"]
|
|
137
|
+
except Exception as exc: # callers degrade, they don't crash
|
|
138
|
+
logger.warning("Could not mint a GitHub App installation token: %s", exc)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def clone_url(repo: str, token: str | None = None) -> str:
|
|
143
|
+
"""An https clone URL for `repo` ("owner/name"), authenticated when a token is given."""
|
|
144
|
+
if token:
|
|
145
|
+
return f"https://{GIT_USERNAME}:{token}@github.com/{repo}.git"
|
|
146
|
+
return f"https://github.com/{repo}.git"
|
flyteplugins/github/_provider.py
CHANGED
|
@@ -1,7 +1,20 @@
|
|
|
1
|
-
"""GitHub webhook verification and payload normalization.
|
|
1
|
+
"""GitHub webhook verification and payload normalization.
|
|
2
|
+
|
|
3
|
+
GitHub delivers the same payload in two body shapes, chosen per webhook in the
|
|
4
|
+
*Add webhook* form and signed the same way:
|
|
5
|
+
|
|
6
|
+
* content type `application/json` — the JSON is the body;
|
|
7
|
+
* content type `application/x-www-form-urlencoded` — the form's *default* —
|
|
8
|
+
the JSON arrives under a `payload=` form field.
|
|
9
|
+
|
|
10
|
+
`verify` covers both, since the HMAC signs the raw body regardless of encoding.
|
|
11
|
+
`parse` normalizes both into the same `WebhookEvent`, so a webhook left on the
|
|
12
|
+
default content type still works.
|
|
13
|
+
"""
|
|
2
14
|
|
|
3
15
|
from __future__ import annotations
|
|
4
16
|
|
|
17
|
+
import urllib.parse
|
|
5
18
|
from typing import Any, ClassVar, Mapping
|
|
6
19
|
|
|
7
20
|
from flyte.extras.webhooks import (
|
|
@@ -23,6 +36,30 @@ def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
|
|
|
23
36
|
return constant_time_equals(hex_hmac_sha256(secret, body), signature.removeprefix("sha256="))
|
|
24
37
|
|
|
25
38
|
|
|
39
|
+
def _form_payload(body: bytes) -> dict[str, Any] | None:
|
|
40
|
+
"""Decode a form-encoded delivery's `payload` field, or None when the body is JSON.
|
|
41
|
+
|
|
42
|
+
GitHub's *Add webhook* form defaults the content type to
|
|
43
|
+
`application/x-www-form-urlencoded`, which wraps the JSON in a `payload=`
|
|
44
|
+
form field. Sniffing the body rather than trusting Content-Type keeps
|
|
45
|
+
`parse` a pure function of the delivery, which is what the conformance
|
|
46
|
+
harness replays.
|
|
47
|
+
"""
|
|
48
|
+
if body[:1] in (b"{", b"["):
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
decoded = body.decode("utf-8")
|
|
52
|
+
except UnicodeDecodeError:
|
|
53
|
+
return None
|
|
54
|
+
if "=" not in decoded.split("&", 1)[0]:
|
|
55
|
+
return None
|
|
56
|
+
fields = {key: values[0] for key, values in urllib.parse.parse_qs(decoded, keep_blank_values=True).items()}
|
|
57
|
+
raw = fields.get("payload")
|
|
58
|
+
if raw is None:
|
|
59
|
+
raise SignatureError("form-encoded delivery carries no `payload` field")
|
|
60
|
+
return json_body(raw.encode("utf-8"))
|
|
61
|
+
|
|
62
|
+
|
|
26
63
|
def handshake(headers: Mapping[str, str], body: bytes) -> dict[str, Any] | None:
|
|
27
64
|
"""Answer the `ping` GitHub sends when a webhook is created."""
|
|
28
65
|
if lower_headers(headers).get("x-github-event") == "ping":
|
|
@@ -31,12 +68,13 @@ def handshake(headers: Mapping[str, str], body: bytes) -> dict[str, Any] | None:
|
|
|
31
68
|
|
|
32
69
|
|
|
33
70
|
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
|
|
34
|
-
"""Normalize a GitHub delivery into a `WebhookEvent`."""
|
|
71
|
+
"""Normalize a GitHub delivery — JSON or form-encoded — into a `WebhookEvent`."""
|
|
35
72
|
lowered = lower_headers(headers)
|
|
36
73
|
event_type = lowered.get("x-github-event")
|
|
37
74
|
if not event_type:
|
|
38
75
|
raise SignatureError("missing X-GitHub-Event header")
|
|
39
|
-
|
|
76
|
+
form = _form_payload(body)
|
|
77
|
+
payload = form if form is not None else json_body(body)
|
|
40
78
|
|
|
41
79
|
repo = payload.get("repository") or {}
|
|
42
80
|
issue_or_pr = payload.get("pull_request") or payload.get("issue") or {}
|
|
@@ -77,6 +115,9 @@ class GitHubProvider(Provider):
|
|
|
77
115
|
app_env = WebhookAppEnvironment(name="webhooks", providers=[GitHubProvider()])
|
|
78
116
|
```
|
|
79
117
|
|
|
118
|
+
Either content type in GitHub's *Add webhook* form works: `application/json`
|
|
119
|
+
and the default `application/x-www-form-urlencoded` normalize identically.
|
|
120
|
+
|
|
80
121
|
`WebhookAppEnvironment` mounts `default_secret_env` for you, so it does not
|
|
81
122
|
need naming again in `secrets=`.
|
|
82
123
|
|
flyteplugins/github/events.py
CHANGED
|
@@ -10,6 +10,8 @@ __all__ = [
|
|
|
10
10
|
"Create",
|
|
11
11
|
"Delete",
|
|
12
12
|
"Fork",
|
|
13
|
+
"Installation",
|
|
14
|
+
"InstallationRepositories",
|
|
13
15
|
"IssueComment",
|
|
14
16
|
"Issues",
|
|
15
17
|
"PullRequest",
|
|
@@ -159,6 +161,29 @@ class CheckSuite(EventType):
|
|
|
159
161
|
REREQUESTED = "check_suite.rerequested"
|
|
160
162
|
|
|
161
163
|
|
|
164
|
+
class Installation(EventType):
|
|
165
|
+
"""`installation` events — the app itself was installed, removed, or re-permissioned.
|
|
166
|
+
|
|
167
|
+
Every GitHub App webhook receives these, whatever repository events it
|
|
168
|
+
subscribes to. No repository in the payload, so they dedupe per delivery.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
ANY = "installation"
|
|
172
|
+
CREATED = "installation.created"
|
|
173
|
+
DELETED = "installation.deleted"
|
|
174
|
+
SUSPEND = "installation.suspend"
|
|
175
|
+
UNSUSPEND = "installation.unsuspend"
|
|
176
|
+
NEW_PERMISSIONS_ACCEPTED = "installation.new_permissions_accepted"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class InstallationRepositories(EventType):
|
|
180
|
+
"""`installation_repositories` events — repositories granted to or removed from the app."""
|
|
181
|
+
|
|
182
|
+
ANY = "installation_repositories"
|
|
183
|
+
ADDED = "installation_repositories.added"
|
|
184
|
+
REMOVED = "installation_repositories.removed"
|
|
185
|
+
|
|
186
|
+
|
|
162
187
|
class Star(EventType):
|
|
163
188
|
"""`star` events."""
|
|
164
189
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Typed views of GitHub payloads, for autocomplete inside handlers.
|
|
2
|
+
|
|
3
|
+
`event.payload` is `dict[str, Any]` — correct, since it carries GitHub's JSON
|
|
4
|
+
verbatim, but blind to write against. These TypedDicts spell the fields GitHub
|
|
5
|
+
actually sends, so `payloads.pull_request(event)` gives editors and agents
|
|
6
|
+
something to complete against:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from flyteplugins.github import payloads
|
|
10
|
+
|
|
11
|
+
@app_env.on_event(events.PullRequest.OPENED)
|
|
12
|
+
async def on_primary(event):
|
|
13
|
+
payload = payloads.pull_request(event)
|
|
14
|
+
branch = payload["pull_request"]["head"]["ref"]
|
|
15
|
+
repo = payload["repository"]["full_name"]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The helpers are casts, not validators: the dict is returned untouched, and a
|
|
19
|
+
field GitHub did not send is still a `KeyError` at runtime. Every class is
|
|
20
|
+
`total=False` because GitHub's payloads vary by action and App permissions —
|
|
21
|
+
treat presence the way you already would with a raw dict. Fields beyond these
|
|
22
|
+
still exist in the dict; the types name the commonly-read ones, not the whole
|
|
23
|
+
wire format.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import TYPE_CHECKING, Any, TypedDict, cast
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from flyte.extras.webhooks import WebhookEvent
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Comment",
|
|
35
|
+
"GitRef",
|
|
36
|
+
"Issue",
|
|
37
|
+
"IssueCommentEvent",
|
|
38
|
+
"Label",
|
|
39
|
+
"PullRequest",
|
|
40
|
+
"PullRequestEvent",
|
|
41
|
+
"Repository",
|
|
42
|
+
"User",
|
|
43
|
+
"issue_comment",
|
|
44
|
+
"pull_request",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class User(TypedDict, total=False):
|
|
49
|
+
"""An account — sender, author, assignee, owner."""
|
|
50
|
+
|
|
51
|
+
login: str
|
|
52
|
+
id: int
|
|
53
|
+
type: str
|
|
54
|
+
html_url: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Label(TypedDict, total=False):
|
|
58
|
+
name: str
|
|
59
|
+
color: str
|
|
60
|
+
description: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Repository(TypedDict, total=False):
|
|
64
|
+
full_name: str
|
|
65
|
+
name: str
|
|
66
|
+
html_url: str
|
|
67
|
+
clone_url: str
|
|
68
|
+
default_branch: str
|
|
69
|
+
private: bool
|
|
70
|
+
owner: User
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GitRef(TypedDict, total=False):
|
|
74
|
+
"""One side of a pull request — `head` or `base`."""
|
|
75
|
+
|
|
76
|
+
ref: str
|
|
77
|
+
sha: str
|
|
78
|
+
label: str
|
|
79
|
+
repo: Repository
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class PullRequest(TypedDict, total=False):
|
|
83
|
+
"""`payload["pull_request"]` — the PR the event is about."""
|
|
84
|
+
|
|
85
|
+
number: int
|
|
86
|
+
title: str
|
|
87
|
+
body: str
|
|
88
|
+
state: str
|
|
89
|
+
draft: bool
|
|
90
|
+
merged: bool
|
|
91
|
+
html_url: str
|
|
92
|
+
user: User
|
|
93
|
+
labels: list[Label]
|
|
94
|
+
head: GitRef
|
|
95
|
+
base: GitRef
|
|
96
|
+
additions: int
|
|
97
|
+
deletions: int
|
|
98
|
+
changed_files: int
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Issue(TypedDict, total=False):
|
|
102
|
+
"""`payload["issue"]` — present on `issues` and `issue_comment` events.
|
|
103
|
+
|
|
104
|
+
A comment on a pull request also arrives as `issue_comment`; the issue then
|
|
105
|
+
carries a `pull_request` key, which is how the two are told apart.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
number: int
|
|
109
|
+
title: str
|
|
110
|
+
body: str
|
|
111
|
+
state: str
|
|
112
|
+
html_url: str
|
|
113
|
+
user: User
|
|
114
|
+
labels: list[Label]
|
|
115
|
+
pull_request: dict[str, Any]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Comment(TypedDict, total=False):
|
|
119
|
+
id: int
|
|
120
|
+
body: str
|
|
121
|
+
html_url: str
|
|
122
|
+
user: User
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PullRequestEvent(TypedDict, total=False):
|
|
126
|
+
"""A `pull_request` delivery."""
|
|
127
|
+
|
|
128
|
+
action: str
|
|
129
|
+
number: int
|
|
130
|
+
pull_request: PullRequest
|
|
131
|
+
repository: Repository
|
|
132
|
+
sender: User
|
|
133
|
+
installation: dict[str, Any]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class IssueCommentEvent(TypedDict, total=False):
|
|
137
|
+
"""An `issue_comment` delivery — the comment-triggered-agent shape."""
|
|
138
|
+
|
|
139
|
+
action: str
|
|
140
|
+
issue: Issue
|
|
141
|
+
comment: Comment
|
|
142
|
+
repository: Repository
|
|
143
|
+
sender: User
|
|
144
|
+
installation: dict[str, Any]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def pull_request(event: WebhookEvent) -> PullRequestEvent:
|
|
148
|
+
"""Typed view of a `pull_request` delivery's payload. A cast, not validation."""
|
|
149
|
+
return cast("PullRequestEvent", event.payload)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def issue_comment(event: WebhookEvent) -> IssueCommentEvent:
|
|
153
|
+
"""Typed view of an `issue_comment` delivery's payload. A cast, not validation."""
|
|
154
|
+
return cast("IssueCommentEvent", event.payload)
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-github
|
|
3
|
+
Version: 2.8.1
|
|
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
|
+
Provides-Extra: auth
|
|
15
|
+
Requires-Dist: PyJWT[crypto]>=2; extra == "auth"
|
|
16
|
+
|
|
17
|
+
# flyteplugins-github
|
|
18
|
+
|
|
19
|
+
Receive GitHub webhooks in Flyte — JSON or form-encoded, GitHub signs both the
|
|
20
|
+
same way — plus human review gates on pull requests and GitHub App
|
|
21
|
+
installation tokens for agents that clone, push, or open PRs.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install "flyteplugins-github[app]"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Using it
|
|
28
|
+
|
|
29
|
+
Hand a `GitHubProvider()` to a `WebhookAppEnvironment` and register handlers with the
|
|
30
|
+
typed constants in `events`:
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
import flyte
|
|
34
|
+
from flyte.extras.webhooks import WebhookAppEnvironment, WebhookEvent, run_once
|
|
35
|
+
from flyteplugins.github import GitHubProvider, events
|
|
36
|
+
|
|
37
|
+
# GitHubProvider.default_secret_env is mounted for you.
|
|
38
|
+
app_env = WebhookAppEnvironment(name="github-webhooks", providers=[GitHubProvider()])
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@app_env.on_event(events.PullRequest.OPENED)
|
|
42
|
+
async def handle(event: WebhookEvent):
|
|
43
|
+
import flyte.remote as remote
|
|
44
|
+
|
|
45
|
+
task = remote.Task.get(name="my-env.my_task", auto_version="latest")
|
|
46
|
+
result = await run_once.aio(task, key=event.dedupe_key(), resource=event.resource_id)
|
|
47
|
+
if not result.created:
|
|
48
|
+
return {"skipped": result.run.name, "url": result.run.url}
|
|
49
|
+
return {"run": result.run.name}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
flyte.serve(app_env)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Handlers must `await run_once.aio(...)`. The blocking form stalls the
|
|
56
|
+
app's event loop, and GitHub times deliveries out in seconds.
|
|
57
|
+
|
|
58
|
+
One app can serve several products at once — hand it one provider per product.
|
|
59
|
+
|
|
60
|
+
## Human review gates
|
|
61
|
+
|
|
62
|
+
`review_pr` parks a run on a `flyte.new_condition` carrying the pull request's
|
|
63
|
+
metadata as JSON, waits for a human to answer in the Flyte UI, and returns a
|
|
64
|
+
typed decision the workflow branches on:
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from flyteplugins.github import review_pr
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@env.task
|
|
71
|
+
async def gated_merge(repo: str, number: int) -> str:
|
|
72
|
+
decision = await review_pr(repo, number)
|
|
73
|
+
if not decision.is_approved:
|
|
74
|
+
return f"blocked: {decision.summary}"
|
|
75
|
+
... # merge, with PyGithub
|
|
76
|
+
return "merged"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The reviewer answers in markdown; `parse_review_payload` accepts raw JSON, a
|
|
80
|
+
fenced block, or JSON buried in prose, and normalizes verdict synonyms
|
|
81
|
+
(`lgtm`, `approved`, `changes_requested`, ...) — because people paste all of
|
|
82
|
+
those.
|
|
83
|
+
|
|
84
|
+
This lives in the plugin because the condition is the part only Flyte can do.
|
|
85
|
+
Reading the pull request is `PyGithub`'s job, which the gate calls directly
|
|
86
|
+
rather than wrapping:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install "flyteplugins-github[review]"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Try it
|
|
93
|
+
|
|
94
|
+
`examples/github_webhooks.py` runs two ways. The first needs no GitHub account:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
python examples/github_webhooks.py --local # replay a real sample delivery in-process
|
|
98
|
+
python examples/github_webhooks.py # deploy the receiver to Flyte
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
|
|
102
|
+
test client, so you see a delivery verified, normalized, and dispatched — plus
|
|
103
|
+
an unsigned one refused with a 401, the same delivery replayed to show the
|
|
104
|
+
dedupe key is stable, and the same delivery form-encoded (GitHub's default
|
|
105
|
+
content type) landing on that same key.
|
|
106
|
+
|
|
107
|
+
## Setup
|
|
108
|
+
|
|
109
|
+
1. Invent a shared secret — nothing generates it for you — and store it under
|
|
110
|
+
the name the provider mounts, `GITHUB_WEBHOOK_SECRET`:
|
|
111
|
+
```bash
|
|
112
|
+
openssl rand -hex 32
|
|
113
|
+
flyte create secret GITHUB_WEBHOOK_SECRET --value <secret>
|
|
114
|
+
```
|
|
115
|
+
2. Point GitHub at `<app-url>/webhook/github`, from
|
|
116
|
+
repository Settings → Webhooks → Add webhook, pasting that same string into
|
|
117
|
+
the webhook's **Secret** field. (A GitHub App has its own *Webhook* section
|
|
118
|
+
with the same two fields; either route reaches the same endpoint.) A
|
|
119
|
+
mismatch reads as a 401 in *Recent Deliveries*. Either content type works —
|
|
120
|
+
the form's default `application/x-www-form-urlencoded` wraps the JSON in a
|
|
121
|
+
`payload=` field and is unwrapped automatically; `application/json` keeps
|
|
122
|
+
the deliveries readable in *Recent Deliveries*.
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
**Verification:** HMAC-SHA256 over the raw body (`X-Hub-Signature-256`), whichever content type the webhook uses.
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
130
|
+
## GitHub App tokens
|
|
131
|
+
|
|
132
|
+
Agents that clone, push, or open PRs authenticate best as a GitHub App: hold
|
|
133
|
+
no personal access token, mint a short-lived installation token per operation.
|
|
134
|
+
Tokens live one hour — plenty for a clone or a `gh pr create`, useless to an
|
|
135
|
+
attacker who exfiltrates one from a log:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import asyncio
|
|
139
|
+
|
|
140
|
+
from flyteplugins.github import clone_url, mint_installation_token
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@env.task
|
|
144
|
+
async def open_fix_pr(repo: str) -> str:
|
|
145
|
+
# One HTTPS round trip; keep it off the event loop.
|
|
146
|
+
token = await asyncio.to_thread(mint_installation_token)
|
|
147
|
+
url = clone_url(repo, token) # https://x-access-token:<token>@github.com/...
|
|
148
|
+
...
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
pip install "flyteplugins-github[auth]"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Configuration comes from three environment variables — `GITHUB_APP_ID`,
|
|
156
|
+
`GITHUB_APP_INSTALLATION_ID`, and `GITHUB_APP_PRIVATE_KEY`. What follows is
|
|
157
|
+
where those values come from, how to store them, how to get them onto a task,
|
|
158
|
+
and what happens when they are absent.
|
|
159
|
+
|
|
160
|
+
### Where the three values come from
|
|
161
|
+
|
|
162
|
+
Create the app first, from **Settings → Developer settings → GitHub Apps → New
|
|
163
|
+
GitHub App** ([github.com/settings/apps/new](https://github.com/settings/apps/new);
|
|
164
|
+
for an org, **Organization settings → Developer settings → GitHub Apps**). Give
|
|
165
|
+
it only the permissions the agent uses — *Contents: Read* to clone, *Read and
|
|
166
|
+
write* to push, *Pull requests: Read and write* to open PRs, *Issues: Read and
|
|
167
|
+
write* to comment. Then:
|
|
168
|
+
|
|
169
|
+
| Value | Where |
|
|
170
|
+
| --- | --- |
|
|
171
|
+
| `GITHUB_APP_ID` | The app's **General** tab, under *About* → **App ID**. A short number. |
|
|
172
|
+
| `GITHUB_APP_PRIVATE_KEY` | Same tab, **Private keys** → *Generate a private key*. Downloads a `.pem` file. |
|
|
173
|
+
| `GITHUB_APP_INSTALLATION_ID` | Install the app (**Install App** tab), then read the number off the URL you land on. |
|
|
174
|
+
|
|
175
|
+
Two things that reliably cost an hour:
|
|
176
|
+
|
|
177
|
+
- **The App ID is not the Client ID.** The General tab shows both, and the
|
|
178
|
+
Client ID (`Iv23li...`) sits right next to the App ID. Signing a JWT with the
|
|
179
|
+
Client ID as the issuer fails at GitHub, not locally, so the error surfaces as
|
|
180
|
+
a failed mint rather than a bad value.
|
|
181
|
+
- **The private key is shown once.** GitHub hands over the `.pem` at generation
|
|
182
|
+
and never again; losing it means generating a new one and deleting the old.
|
|
183
|
+
Store it in Flyte before deleting the download.
|
|
184
|
+
|
|
185
|
+
The installation id is the fiddly one — it identifies *the app installed on one
|
|
186
|
+
account*, not the app. After installing, the browser lands on
|
|
187
|
+
`https://github.com/settings/installations/<id>` (or
|
|
188
|
+
`https://github.com/organizations/<org>/settings/installations/<id>`), and the
|
|
189
|
+
trailing number is it. To read it without the browser, ask the API — the app
|
|
190
|
+
JWT is the only credential this needs, so it works before any installation
|
|
191
|
+
token exists:
|
|
192
|
+
|
|
193
|
+
```python
|
|
194
|
+
import json, time, urllib.request
|
|
195
|
+
import jwt # from flyteplugins-github[auth]
|
|
196
|
+
|
|
197
|
+
app_id, pem = "1234567", open("my-agent.private-key.pem").read()
|
|
198
|
+
now = int(time.time())
|
|
199
|
+
app_jwt = jwt.encode({"iat": now - 60, "exp": now + 600, "iss": app_id}, pem, algorithm="RS256")
|
|
200
|
+
request = urllib.request.Request(
|
|
201
|
+
"https://api.github.com/app/installations",
|
|
202
|
+
headers={"Authorization": f"Bearer {app_jwt}", "Accept": "application/vnd.github+json"},
|
|
203
|
+
)
|
|
204
|
+
for installation in json.load(urllib.request.urlopen(request)):
|
|
205
|
+
print(installation["id"], installation["account"]["login"])
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Storing them as Flyte secrets
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
flyte create secret github-app-id --value 1234567
|
|
212
|
+
flyte create secret github-app-installation-id --value 87654321
|
|
213
|
+
flyte create secret github-app-private-key --from-file ~/Downloads/my-agent.private-key.pem
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Use `--from-file` for the key, not `--value`: a PEM is multi-line, and a shell
|
|
217
|
+
that folds or strips those newlines produces a key that fails to parse at mint
|
|
218
|
+
time. `--from-file` stores the bytes verbatim, which is what the RS256 signer
|
|
219
|
+
needs. GitHub's download is a PKCS#1 PEM (`-----BEGIN RSA PRIVATE KEY-----`) and
|
|
220
|
+
is used as-is; no conversion step.
|
|
221
|
+
|
|
222
|
+
The app id and installation id are identifiers rather than credentials —
|
|
223
|
+
nothing breaks if they leak — but keeping all three in one place means one
|
|
224
|
+
mounting story instead of two.
|
|
225
|
+
|
|
226
|
+
`flyte create secret` writes to the org unless `--project`/`--domain` scope it.
|
|
227
|
+
Scope the secrets the same way as the environment that reads them, or leave all
|
|
228
|
+
of them org-wide; a secret in the wrong project is invisible at run time.
|
|
229
|
+
|
|
230
|
+
### Mounting them on the environment
|
|
231
|
+
|
|
232
|
+
Name the secrets in kebab-case and the env vars come out right on their own:
|
|
233
|
+
`flyte.Secret` upper-cases the key and turns `-` into `_`, so
|
|
234
|
+
`github-app-private-key` mounts as `GITHUB_APP_PRIVATE_KEY`, which is exactly
|
|
235
|
+
what `mint_installation_token()` reads.
|
|
236
|
+
|
|
237
|
+
```python
|
|
238
|
+
import flyte
|
|
239
|
+
|
|
240
|
+
env = flyte.TaskEnvironment(
|
|
241
|
+
name="github-agent",
|
|
242
|
+
secrets=[
|
|
243
|
+
flyte.Secret("github-app-id"),
|
|
244
|
+
flyte.Secret("github-app-installation-id"),
|
|
245
|
+
flyte.Secret("github-app-private-key"),
|
|
246
|
+
],
|
|
247
|
+
image=flyte.Image.from_debian_base().with_pip_packages("flyteplugins-github[auth]"),
|
|
248
|
+
)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
Under any other naming, spell the target out — the env var is the contract, the
|
|
252
|
+
secret name is not:
|
|
253
|
+
|
|
254
|
+
```python
|
|
255
|
+
flyte.Secret("prod-bot-key", as_env_var="GITHUB_APP_PRIVATE_KEY")
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
Pass the same `secrets=[...]` to a `WebhookAppEnvironment` when a handler mints
|
|
259
|
+
tokens directly rather than launching a task that does.
|
|
260
|
+
|
|
261
|
+
### When they are missing
|
|
262
|
+
|
|
263
|
+
`GITHUB_TOKEN`/`GH_TOKEN` are honored as fallbacks, so a deployment can migrate
|
|
264
|
+
one secret at a time; once the app secrets exist the fallback never fires. A
|
|
265
|
+
deployment with none of them gets `None` back — with a logged reason — rather
|
|
266
|
+
than a crash, so unauthenticated paths keep working. That means a missing
|
|
267
|
+
secret shows up as *unauthenticated behavior*, not an exception: if clones of
|
|
268
|
+
private repos 404, check that all three landed.
|
|
269
|
+
|
|
270
|
+
## Event constants
|
|
271
|
+
|
|
272
|
+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
|
|
273
|
+
event type, so a typo fails at import rather than by silently never matching.
|
|
274
|
+
GitHub grows actions faster than any constant list; for one the constants do
|
|
275
|
+
not cover yet, qualify the bare type with `action=`:
|
|
276
|
+
|
|
277
|
+
```python
|
|
278
|
+
@app_env.on_event(events.PullRequest.ANY, action="auto_merge_enabled")
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
(Raw qualified strings like `"pull_request.auto_merge_enabled"` work too.)
|
|
282
|
+
|
|
283
|
+
`event.payload` is GitHub's JSON verbatim, typed as `dict[str, Any]`. For
|
|
284
|
+
autocomplete, take a typed view of it — a cast, not a copy or a validation:
|
|
285
|
+
|
|
286
|
+
```python
|
|
287
|
+
payload = payloads.pull_request(event) # payload["pull_request"]["head"]["ref"] completes
|
|
288
|
+
payload = payloads.issue_comment(event) # payload["comment"]["body"], payload["issue"]["number"], ...
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
## What this plugin does not do
|
|
292
|
+
|
|
293
|
+
Wrap the GitHub API. Use `PyGithub` directly from your tasks — see
|
|
294
|
+
`examples/external_saas_integrations`. This plugin owns the parts every
|
|
295
|
+
GitHub agent otherwise duplicates: authenticating an inbound delivery and
|
|
296
|
+
turning it into a run, gating a run on a human review, and minting the App
|
|
297
|
+
token the outbound side authenticates with.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
flyteplugins/github/__init__.py,sha256=BRBj3eg-RqK6jGcuhv7VUHQFCMKkbEmXn2IMEZqnSDg,4024
|
|
2
|
+
flyteplugins/github/_app_auth.py,sha256=vleBD3urwJYxxIpQJrx6DP1U0eI5f7n8ql-jz9bNZ44,5988
|
|
3
|
+
flyteplugins/github/_provider.py,sha256=ZJFiYc0NeAiWOmVKhnNyAdv4gBDwGdlDVO0TBVJuy30,5305
|
|
4
|
+
flyteplugins/github/_review.py,sha256=6XwWI-lOZ3DIshIRU8i-FrzepJtvXmaVm_wbFAA8Iuw,11037
|
|
5
|
+
flyteplugins/github/events.py,sha256=uEb8QffVdTWdTYQnfkLMcqFw0_kWK-f0rcHzU61DYKo,5224
|
|
6
|
+
flyteplugins/github/payloads.py,sha256=bt27Vw83rWASuXElRLnMdZOhRwGzQjct_2GRqCr3NhM,3734
|
|
7
|
+
flyteplugins_github-2.8.1.dist-info/METADATA,sha256=o-G5ChVu8POhsxdqPIxJQCeUwiJyrveb7_AmRpxP0bY,11709
|
|
8
|
+
flyteplugins_github-2.8.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
flyteplugins_github-2.8.1.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
10
|
+
flyteplugins_github-2.8.1.dist-info/RECORD,,
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: flyteplugins-github
|
|
3
|
-
Version: 2.7.2
|
|
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.
|
|
@@ -1,8 +0,0 @@
|
|
|
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.2.dist-info/METADATA,sha256=SXWg8J3AYeRacctnaBEJbIbq5eO5oH8hdJdhn6TGztQ,4231
|
|
6
|
-
flyteplugins_github-2.7.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
-
flyteplugins_github-2.7.2.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
8
|
-
flyteplugins_github-2.7.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|