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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flyteplugins