flyteplugins-gitlab 0.0.0a0__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/gitlab/__init__.py +71 -0
- flyteplugins/gitlab/_provider.py +188 -0
- flyteplugins/gitlab/events.py +112 -0
- flyteplugins/gitlab/payloads.py +184 -0
- flyteplugins_gitlab-0.0.0a0.dist-info/METADATA +110 -0
- flyteplugins_gitlab-0.0.0a0.dist-info/RECORD +8 -0
- flyteplugins_gitlab-0.0.0a0.dist-info/WHEEL +5 -0
- flyteplugins_gitlab-0.0.0a0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""GitLab webhooks for Flyte.
|
|
2
|
+
|
|
3
|
+
Hand a `GitLabProvider()` 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.gitlab import GitLabProvider, events
|
|
10
|
+
|
|
11
|
+
# GitLabProvider.default_secret_env (GITLAB_WEBHOOK_TOKEN) is mounted for you.
|
|
12
|
+
app_env = WebhookAppEnvironment(name="gitlab-webhooks", providers=[GitLabProvider()])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@app_env.on_event(events.MergeRequest.OPEN)
|
|
16
|
+
async def triage(event):
|
|
17
|
+
import flyte.remote as remote
|
|
18
|
+
|
|
19
|
+
task = remote.Task.get(name="gitlab-triage.triage_mr", auto_version="latest")
|
|
20
|
+
result = await run_once.aio(task, key=event.dedupe_key(), repo=event.scope)
|
|
21
|
+
if not result.created:
|
|
22
|
+
return {"skipped": result.run.name, "url": result.run.url}
|
|
23
|
+
return {"run": result.run.name}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Note GitLab does not sign its webhooks; it authenticates with a shared token
|
|
27
|
+
sent verbatim in `X-Gitlab-Token` (the legacy *Secret token* mode — its newer
|
|
28
|
+
Standard-Webhooks *Signing token* mode, with `webhook-signature` /
|
|
29
|
+
`webhook-id` / `webhook-timestamp` headers, is not covered). See `_provider`
|
|
30
|
+
for what this plugin does with that. Calling the GitLab API (opening MRs, commenting, approving) is not
|
|
31
|
+
this plugin's job — use the `python-gitlab` package from your tasks, installed
|
|
32
|
+
via `flyteplugins-gitlab[gitlab]`. See `examples/external_saas_integrations`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from . import events, payloads
|
|
36
|
+
from ._provider import GitLabProvider, parse, verify
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"SAMPLE_DELIVERY",
|
|
40
|
+
"GitLabProvider",
|
|
41
|
+
"events",
|
|
42
|
+
"parse",
|
|
43
|
+
"payloads",
|
|
44
|
+
"verify",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _sample_headers(body: bytes, secret: str) -> dict[str, str]:
|
|
49
|
+
# No signature to compute: GitLab sends a static shared token.
|
|
50
|
+
return {
|
|
51
|
+
"X-Gitlab-Event": "Merge Request Hook",
|
|
52
|
+
"X-Gitlab-Event-UUID": "00000000-0000-0000-0000-000000000000",
|
|
53
|
+
"X-Gitlab-Token": secret,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
#: A real `merge_request` "open" delivery, trimmed to the fields the parser reads.
|
|
58
|
+
#: The conformance harness replays it under the shared token, so `verify` and
|
|
59
|
+
#: `parse` are checked against an actual payload rather than against each other.
|
|
60
|
+
SAMPLE_DELIVERY = (
|
|
61
|
+
_sample_headers,
|
|
62
|
+
(
|
|
63
|
+
b'{"object_kind": "merge_request", "event_type": "merge_request",'
|
|
64
|
+
b' "user": {"name": "Octocat", "username": "octocat"},'
|
|
65
|
+
b' "project": {"id": 1, "name": "repo", "path_with_namespace": "octo/repo"},'
|
|
66
|
+
b' "object_attributes": {"id": 99, "iid": 7, "action": "open", "state": "opened",'
|
|
67
|
+
b' "title": "Add a feature", "url": "https://gitlab.com/octo/repo/-/merge_requests/7",'
|
|
68
|
+
b' "source_branch": "feature", "target_branch": "main",'
|
|
69
|
+
b' "updated_at": "2024-01-01T00:00:00Z"}}'
|
|
70
|
+
),
|
|
71
|
+
)
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""GitLab webhook verification and payload normalization.
|
|
2
|
+
|
|
3
|
+
GitLab does **not** sign its webhooks. There is no HMAC over the body; the
|
|
4
|
+
*Secret token* you set on a webhook is a static shared token that GitLab echoes
|
|
5
|
+
verbatim in the `X-Gitlab-Token` header. This plugin authenticates with that
|
|
6
|
+
token (a constant-time comparison, exactly like `JiraProvider`), and reports
|
|
7
|
+
`signed=False` so the dashboard says so rather than implying a guarantee that
|
|
8
|
+
is absent.
|
|
9
|
+
|
|
10
|
+
(GitLab's newer *Signing token* mode instead signs the body following the
|
|
11
|
+
Standard Webhooks spec: `webhook-signature` carries an HMAC-SHA256 over
|
|
12
|
+
`webhook-id`.`webhook-timestamp`.body. This plugin does not cover it — the legacy
|
|
13
|
+
shared token is what the webhook *Secret token* field sets, and it is what the
|
|
14
|
+
sample delivery exercises.)
|
|
15
|
+
|
|
16
|
+
`parse` normalizes the payload into a `WebhookEvent`: the event type comes from
|
|
17
|
+
`object_kind`, the action from `object_attributes.action` (or `status` for
|
|
18
|
+
pipelines and deployments), and the resource id follows GitLab's `!` / `#`
|
|
19
|
+
convention — merge requests use `!`, issues use `#`, on a `path_with_namespace`
|
|
20
|
+
scope. Pipelines, deployments, and releases key on their own id, tag, or
|
|
21
|
+
deployment id instead, so a redelivery dedupes even without a delivery UUID.
|
|
22
|
+
|
|
23
|
+
Deployment and release hooks are the odd ones out: GitLab sends their fields at
|
|
24
|
+
the top level of the payload, with no `object_attributes` at all.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import Any, ClassVar, Mapping
|
|
30
|
+
|
|
31
|
+
from flyte.extras.webhooks import (
|
|
32
|
+
Provider,
|
|
33
|
+
WebhookEvent,
|
|
34
|
+
constant_time_equals,
|
|
35
|
+
json_body,
|
|
36
|
+
lower_headers,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
|
|
41
|
+
"""Compare the `X-Gitlab-Token` header against the shared secret token."""
|
|
42
|
+
token = lower_headers(headers).get("x-gitlab-token")
|
|
43
|
+
if not token:
|
|
44
|
+
return False
|
|
45
|
+
return constant_time_equals(token.strip(), secret)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Hooks whose fields GitLab sends flat, at the top level of the payload, rather
|
|
49
|
+
# than nested under `object_attributes`.
|
|
50
|
+
_FLAT_KINDS = frozenset({"deployment", "release"})
|
|
51
|
+
|
|
52
|
+
# Hooks whose state is a `status` rather than an `action`.
|
|
53
|
+
_STATUS_KINDS = frozenset({"pipeline", "deployment"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _build_resource(obj_kind: str, payload: dict[str, Any], attrs: dict[str, Any], namespace: str | None) -> str | None:
|
|
57
|
+
"""A stable, human-readable id for the resource an event is about.
|
|
58
|
+
|
|
59
|
+
Push and tag-push hooks return None on purpose, matching the GitHub plugin:
|
|
60
|
+
a push is a delivery, not a resource, so the delivery id is the right key.
|
|
61
|
+
"""
|
|
62
|
+
if not namespace:
|
|
63
|
+
return None
|
|
64
|
+
if obj_kind in ("merge_request", "issue"):
|
|
65
|
+
iid = attrs.get("iid")
|
|
66
|
+
if iid is not None:
|
|
67
|
+
marker = "!" if obj_kind == "merge_request" else "#"
|
|
68
|
+
return f"{namespace}{marker}{iid}"
|
|
69
|
+
elif obj_kind == "note":
|
|
70
|
+
# A comment's parent is beside the note, not inside object_attributes.
|
|
71
|
+
parent = payload.get("merge_request") or payload.get("issue") or {}
|
|
72
|
+
parent_iid = parent.get("iid")
|
|
73
|
+
note_id = attrs.get("id")
|
|
74
|
+
if parent_iid is not None and note_id is not None:
|
|
75
|
+
marker = "!" if payload.get("merge_request") else "#"
|
|
76
|
+
return f"{namespace}{marker}{parent_iid}:{note_id}"
|
|
77
|
+
elif obj_kind == "pipeline":
|
|
78
|
+
pipeline_id = attrs.get("id")
|
|
79
|
+
if pipeline_id is not None:
|
|
80
|
+
return f"{namespace}/-/pipelines/{pipeline_id}"
|
|
81
|
+
elif obj_kind == "deployment":
|
|
82
|
+
deployment_id = attrs.get("deployment_id")
|
|
83
|
+
if deployment_id is not None:
|
|
84
|
+
return f"{namespace}/-/deployments/{deployment_id}"
|
|
85
|
+
elif obj_kind == "release":
|
|
86
|
+
tag = attrs.get("tag")
|
|
87
|
+
if tag:
|
|
88
|
+
return f"{namespace}/-/releases/{tag}"
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
|
|
93
|
+
"""Normalize a GitLab delivery into a `WebhookEvent`."""
|
|
94
|
+
payload = json_body(body)
|
|
95
|
+
lowered = lower_headers(headers)
|
|
96
|
+
|
|
97
|
+
obj_kind = payload.get("object_kind") or payload.get("event_type") or "unknown"
|
|
98
|
+
project = payload.get("project") or {}
|
|
99
|
+
namespace = project.get("path_with_namespace") or project.get("name")
|
|
100
|
+
|
|
101
|
+
# Most hooks nest the changed object under `object_attributes`. Deployment and
|
|
102
|
+
# release hooks do not: their `status` / `action`, ids, and timestamps sit at
|
|
103
|
+
# the top level, so read the payload itself as the attributes for those.
|
|
104
|
+
if obj_kind in _FLAT_KINDS:
|
|
105
|
+
attrs: dict[str, Any] = payload
|
|
106
|
+
else:
|
|
107
|
+
attrs = payload.get("object_attributes") or {}
|
|
108
|
+
|
|
109
|
+
# A note (comment) has no title of its own; the parent MR/issue does, which is
|
|
110
|
+
# what the dashboard should show for a comment the way it would for the thread.
|
|
111
|
+
# Releases call theirs `name`; a deployment's most useful label is its environment.
|
|
112
|
+
title = attrs.get("title")
|
|
113
|
+
if obj_kind == "note":
|
|
114
|
+
parent = payload.get("merge_request") or payload.get("issue") or {}
|
|
115
|
+
title = parent.get("title") or title
|
|
116
|
+
elif obj_kind == "release":
|
|
117
|
+
title = attrs.get("name") or title
|
|
118
|
+
elif obj_kind == "deployment":
|
|
119
|
+
title = attrs.get("environment") or title
|
|
120
|
+
|
|
121
|
+
user = payload.get("user")
|
|
122
|
+
if isinstance(user, dict):
|
|
123
|
+
actor = user.get("username") or user.get("name")
|
|
124
|
+
else:
|
|
125
|
+
# Push and tag-push hooks send the author as a plain string instead.
|
|
126
|
+
actor = payload.get("user_username") or payload.get("user_name")
|
|
127
|
+
|
|
128
|
+
# Pipelines and deployments report their state in `status`, not `action`.
|
|
129
|
+
if obj_kind in _STATUS_KINDS:
|
|
130
|
+
action = attrs.get("status")
|
|
131
|
+
else:
|
|
132
|
+
action = attrs.get("action")
|
|
133
|
+
|
|
134
|
+
return WebhookEvent(
|
|
135
|
+
provider="gitlab",
|
|
136
|
+
event_type=obj_kind,
|
|
137
|
+
action=action,
|
|
138
|
+
delivery_id=lowered.get("x-gitlab-event-uuid", ""),
|
|
139
|
+
resource_id=_build_resource(obj_kind, payload, attrs, namespace),
|
|
140
|
+
occurred_at=(
|
|
141
|
+
attrs.get("updated_at")
|
|
142
|
+
or attrs.get("status_changed_at") # deployment
|
|
143
|
+
or attrs.get("released_at") # release
|
|
144
|
+
or attrs.get("finished_at") # pipeline
|
|
145
|
+
or attrs.get("created_at")
|
|
146
|
+
),
|
|
147
|
+
scope=namespace,
|
|
148
|
+
title=title,
|
|
149
|
+
url=attrs.get("url") or attrs.get("deployable_url"),
|
|
150
|
+
actor=actor,
|
|
151
|
+
payload=payload,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class GitLabProvider(Provider):
|
|
156
|
+
"""GitLab's webhook provider, with its defaults pre-wired.
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
from flyte.extras.webhooks import WebhookAppEnvironment
|
|
160
|
+
from flyteplugins.gitlab import GitLabProvider
|
|
161
|
+
|
|
162
|
+
app_env = WebhookAppEnvironment(name="webhooks", providers=[GitLabProvider()])
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
GitLab does not sign its webhooks, so this provider authenticates with the
|
|
166
|
+
shared token in `X-Gitlab-Token` and reports `signed=False` — which is what
|
|
167
|
+
makes the dashboard say so rather than implying a guarantee that is absent.
|
|
168
|
+
|
|
169
|
+
`WebhookAppEnvironment` mounts `default_secret_env` for you, so it does not
|
|
170
|
+
need naming again in `secrets=`.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
secret_env: Environment variable holding the secret. Pass one only to
|
|
174
|
+
point this provider at a secret stored under a different name;
|
|
175
|
+
otherwise `default_secret_env` applies.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
default_secret_env: ClassVar[str] = "GITLAB_WEBHOOK_TOKEN"
|
|
179
|
+
|
|
180
|
+
def __init__(self, *, secret_env: str | None = None) -> None:
|
|
181
|
+
super().__init__(
|
|
182
|
+
name="gitlab",
|
|
183
|
+
secret_env=secret_env or self.default_secret_env,
|
|
184
|
+
verify=verify,
|
|
185
|
+
parse=parse,
|
|
186
|
+
signed=False,
|
|
187
|
+
setup_hint="Project Settings -> Webhooks (secret token sent verbatim in X-Gitlab-Token)",
|
|
188
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""GitLab webhook events, from the payload's `object_kind` plus `object_attributes.action`.
|
|
2
|
+
|
|
3
|
+
GitLab names each hook after its object; the header is `X-Gitlab-Event`
|
|
4
|
+
("Merge Request Hook"), and the payload's `object_kind` carries the same idea in
|
|
5
|
+
snake_case (`merge_request`). The action then lives on
|
|
6
|
+
`object_attributes.action` ("open", "close", ...). These constants spell the
|
|
7
|
+
wire values so a handler can match without assembling either part by hand.
|
|
8
|
+
|
|
9
|
+
Note hooks (comments) have no action of their own, and push / tag-push hooks
|
|
10
|
+
send none either — those classes expose only `ANY`. Pipelines and deployments
|
|
11
|
+
report a `status` instead of an action, and deployment / release hooks put it
|
|
12
|
+
at the top level of the payload rather than under `object_attributes`; the
|
|
13
|
+
parser folds all of that into the same `kind.action` shape used here.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from flyte.extras.webhooks import EventType
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Deployment",
|
|
22
|
+
"Issue",
|
|
23
|
+
"MergeRequest",
|
|
24
|
+
"Note",
|
|
25
|
+
"Pipeline",
|
|
26
|
+
"Push",
|
|
27
|
+
"Release",
|
|
28
|
+
"TagPush",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MergeRequest(EventType):
|
|
33
|
+
"""`merge_request` events. GitLab marks merge requests with `!` (`octo/repo!7`); issues use `#`."""
|
|
34
|
+
|
|
35
|
+
ANY = "merge_request"
|
|
36
|
+
OPEN = "merge_request.open"
|
|
37
|
+
CLOSE = "merge_request.close"
|
|
38
|
+
REOPEN = "merge_request.reopen"
|
|
39
|
+
UPDATE = "merge_request.update"
|
|
40
|
+
APPROVAL = "merge_request.approval"
|
|
41
|
+
APPROVED = "merge_request.approved"
|
|
42
|
+
UNAPPROVAL = "merge_request.unapproval"
|
|
43
|
+
UNAPPROVED = "merge_request.unapproved"
|
|
44
|
+
MERGE = "merge_request.merge"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Issue(EventType):
|
|
48
|
+
"""`issue` events."""
|
|
49
|
+
|
|
50
|
+
ANY = "issue"
|
|
51
|
+
OPEN = "issue.open"
|
|
52
|
+
CLOSE = "issue.close"
|
|
53
|
+
REOPEN = "issue.reopen"
|
|
54
|
+
UPDATE = "issue.update"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Note(EventType):
|
|
58
|
+
"""`note` events — comments on issues, merge requests, commits, or snippets.
|
|
59
|
+
|
|
60
|
+
GitLab sends no action for notes. `object_attributes.noteable_type` says what
|
|
61
|
+
the comment is on; the note's own `id` keeps two comments on one thread
|
|
62
|
+
from collapsing onto a single dedupe key.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
ANY = "note"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Push(EventType):
|
|
69
|
+
"""`push` events — a branch push to a project. No action."""
|
|
70
|
+
|
|
71
|
+
ANY = "push"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TagPush(EventType):
|
|
75
|
+
"""`tag_push` events — a tag created or deleted. No action."""
|
|
76
|
+
|
|
77
|
+
ANY = "tag_push"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Pipeline(EventType):
|
|
81
|
+
"""`pipeline` events. The action is the pipeline's `status`."""
|
|
82
|
+
|
|
83
|
+
ANY = "pipeline"
|
|
84
|
+
CREATED = "pipeline.created"
|
|
85
|
+
WAITING_FOR_RESOURCE = "pipeline.waiting_for_resource"
|
|
86
|
+
PREPARING = "pipeline.preparing"
|
|
87
|
+
PENDING = "pipeline.pending"
|
|
88
|
+
RUNNING = "pipeline.running"
|
|
89
|
+
SUCCESS = "pipeline.success"
|
|
90
|
+
FAILED = "pipeline.failed"
|
|
91
|
+
CANCELED = "pipeline.canceled"
|
|
92
|
+
SKIPPED = "pipeline.skipped"
|
|
93
|
+
MANUAL = "pipeline.manual"
|
|
94
|
+
SCHEDULED = "pipeline.scheduled"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Release(EventType):
|
|
98
|
+
"""`release` events. The action sits at the top level of the payload."""
|
|
99
|
+
|
|
100
|
+
ANY = "release"
|
|
101
|
+
CREATE = "release.create"
|
|
102
|
+
UPDATE = "release.update"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Deployment(EventType):
|
|
106
|
+
"""`deployment` events. The action is the top-level `status`."""
|
|
107
|
+
|
|
108
|
+
ANY = "deployment"
|
|
109
|
+
RUNNING = "deployment.running"
|
|
110
|
+
SUCCESS = "deployment.success"
|
|
111
|
+
FAILED = "deployment.failed"
|
|
112
|
+
CANCELED = "deployment.canceled"
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Typed views of GitLab payloads, for autocomplete inside handlers.
|
|
2
|
+
|
|
3
|
+
`event.payload` is `dict[str, Any]` — correct, since it carries GitLab's JSON
|
|
4
|
+
verbatim, but blind to write against. These TypedDicts spell the fields GitLab
|
|
5
|
+
actually sends, so `payloads.merge_request(event)` gives editors and agents
|
|
6
|
+
something to complete against:
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from flyteplugins.gitlab import payloads
|
|
10
|
+
|
|
11
|
+
@app_env.on_event(events.MergeRequest.OPEN)
|
|
12
|
+
async def on_mr(event):
|
|
13
|
+
payload = payloads.merge_request(event)
|
|
14
|
+
author = payload["user"]["username"]
|
|
15
|
+
branch = payload["object_attributes"]["source_branch"]
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
The helpers are casts, not validators: the dict is returned untouched, and a
|
|
19
|
+
field GitLab did not send is still a `KeyError` at runtime. Every class is
|
|
20
|
+
`total=False` because GitLab's payloads vary by hook and trigger — treat
|
|
21
|
+
presence the way you already would with a raw dict. Fields beyond these still
|
|
22
|
+
exist in the dict; the types name the commonly-read ones, not the whole wire
|
|
23
|
+
format.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from typing import TYPE_CHECKING, TypedDict, cast
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from flyte.extras.webhooks import WebhookEvent
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Author",
|
|
35
|
+
"Commit",
|
|
36
|
+
"MergeRequestEvent",
|
|
37
|
+
"NoteEvent",
|
|
38
|
+
"NoteThread",
|
|
39
|
+
"ObjectAttributes",
|
|
40
|
+
"Project",
|
|
41
|
+
"PushEvent",
|
|
42
|
+
"Repository",
|
|
43
|
+
"User",
|
|
44
|
+
"merge_request",
|
|
45
|
+
"note",
|
|
46
|
+
"push",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class User(TypedDict, total=False):
|
|
51
|
+
"""The actor on issue / merge request / note hooks."""
|
|
52
|
+
|
|
53
|
+
name: str
|
|
54
|
+
username: str
|
|
55
|
+
id: int
|
|
56
|
+
avatar_url: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Author(TypedDict, total=False):
|
|
60
|
+
"""`commit["author"]` on push hooks."""
|
|
61
|
+
|
|
62
|
+
name: str
|
|
63
|
+
email: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Commit(TypedDict, total=False):
|
|
67
|
+
"""One entry of `payload["commits"]` on push hooks."""
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
message: str
|
|
71
|
+
title: str
|
|
72
|
+
timestamp: str
|
|
73
|
+
url: str
|
|
74
|
+
author: Author
|
|
75
|
+
added: list[str]
|
|
76
|
+
modified: list[str]
|
|
77
|
+
removed: list[str]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Project(TypedDict, total=False):
|
|
81
|
+
"""`payload["project"]` — the repo the event is about."""
|
|
82
|
+
|
|
83
|
+
id: int
|
|
84
|
+
name: str
|
|
85
|
+
path_with_namespace: str
|
|
86
|
+
default_branch: str
|
|
87
|
+
web_url: str
|
|
88
|
+
visibility: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Repository(TypedDict, total=False):
|
|
92
|
+
"""`payload["repository"]` — the repo as push hooks call it."""
|
|
93
|
+
|
|
94
|
+
name: str
|
|
95
|
+
url: str
|
|
96
|
+
path: str
|
|
97
|
+
homepage: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class ObjectAttributes(TypedDict, total=False):
|
|
101
|
+
"""`payload["object_attributes"]` — the object that changed."""
|
|
102
|
+
|
|
103
|
+
id: int
|
|
104
|
+
iid: int
|
|
105
|
+
action: str
|
|
106
|
+
state: str
|
|
107
|
+
title: str
|
|
108
|
+
description: str
|
|
109
|
+
url: str
|
|
110
|
+
source_branch: str
|
|
111
|
+
target_branch: str
|
|
112
|
+
merge_status: str
|
|
113
|
+
updated_at: str
|
|
114
|
+
created_at: str
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class MergeRequestEvent(TypedDict, total=False):
|
|
118
|
+
"""A `merge_request` delivery."""
|
|
119
|
+
|
|
120
|
+
object_kind: str
|
|
121
|
+
event_type: str
|
|
122
|
+
user: User
|
|
123
|
+
project: Project
|
|
124
|
+
object_attributes: ObjectAttributes
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class NoteThread(TypedDict, total=False):
|
|
128
|
+
"""`payload["merge_request"]` or `payload["issue"]` — the thread a note is on."""
|
|
129
|
+
|
|
130
|
+
id: int
|
|
131
|
+
iid: int
|
|
132
|
+
title: str
|
|
133
|
+
url: str
|
|
134
|
+
state: str
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class NoteEvent(TypedDict, total=False):
|
|
138
|
+
"""A `note` delivery — a comment on an issue or merge request.
|
|
139
|
+
|
|
140
|
+
Exactly one of `merge_request` / `issue` is present, matching
|
|
141
|
+
`object_attributes["noteable_type"]`.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
object_kind: str
|
|
145
|
+
event_type: str
|
|
146
|
+
user: User
|
|
147
|
+
project: Project
|
|
148
|
+
object_attributes: ObjectAttributes
|
|
149
|
+
merge_request: NoteThread
|
|
150
|
+
issue: NoteThread
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class PushEvent(TypedDict, total=False):
|
|
154
|
+
"""A `push` or `tag_push` delivery. The actor arrives as plain strings, not a `User`."""
|
|
155
|
+
|
|
156
|
+
object_kind: str
|
|
157
|
+
event_name: str
|
|
158
|
+
ref: str
|
|
159
|
+
before: str
|
|
160
|
+
after: str
|
|
161
|
+
checkout_sha: str
|
|
162
|
+
user_id: int
|
|
163
|
+
user_name: str
|
|
164
|
+
user_username: str
|
|
165
|
+
user_email: str
|
|
166
|
+
project: Project
|
|
167
|
+
repository: Repository
|
|
168
|
+
commits: list[Commit]
|
|
169
|
+
total_commits_count: int
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def merge_request(event: WebhookEvent) -> MergeRequestEvent:
|
|
173
|
+
"""Typed view of a `merge_request` delivery's payload. A cast, not validation."""
|
|
174
|
+
return cast("MergeRequestEvent", event.payload)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def note(event: WebhookEvent) -> NoteEvent:
|
|
178
|
+
"""Typed view of a `note` delivery's payload. A cast, not validation."""
|
|
179
|
+
return cast("NoteEvent", event.payload)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def push(event: WebhookEvent) -> PushEvent:
|
|
183
|
+
"""Typed view of a `push` or `tag_push` delivery's payload. A cast, not validation."""
|
|
184
|
+
return cast("PushEvent", event.payload)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-gitlab
|
|
3
|
+
Version: 0.0.0a0
|
|
4
|
+
Summary: Receive GitLab 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: gitlab
|
|
13
|
+
Requires-Dist: python-gitlab>=4; extra == "gitlab"
|
|
14
|
+
|
|
15
|
+
# flyteplugins-gitlab
|
|
16
|
+
|
|
17
|
+
Receive GitLab webhooks in Flyte.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install "flyteplugins-gitlab[app]"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Using it
|
|
24
|
+
|
|
25
|
+
Hand a `GitLabProvider()` 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.gitlab import GitLabProvider, events
|
|
32
|
+
|
|
33
|
+
# GitLabProvider.default_secret_env is mounted for you.
|
|
34
|
+
app_env = WebhookAppEnvironment(name="gitlab-webhooks", providers=[GitLabProvider()])
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@app_env.on_event(events.MergeRequest.OPEN)
|
|
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(), project=event.scope)
|
|
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 GitLab times deliveries out in seconds.
|
|
53
|
+
|
|
54
|
+
One app can serve several products at once — hand it one provider per product.
|
|
55
|
+
|
|
56
|
+
## Try it
|
|
57
|
+
|
|
58
|
+
`examples/gitlab_webhooks.py` runs two ways. The first needs no GitLab account:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
python examples/gitlab_webhooks.py --local # replay a real sample delivery in-process
|
|
62
|
+
python examples/gitlab_webhooks.py # deploy the receiver to Flyte
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
|
|
66
|
+
test client, so you see a delivery verified, normalized, and dispatched — plus
|
|
67
|
+
one with the wrong token refused, and the same delivery replayed to show the
|
|
68
|
+
dedupe key is stable.
|
|
69
|
+
|
|
70
|
+
## Setup
|
|
71
|
+
|
|
72
|
+
1. Store the secret and mount it on the app:
|
|
73
|
+
```bash
|
|
74
|
+
flyte create secret GITLAB_WEBHOOK_TOKEN --value <token>
|
|
75
|
+
```
|
|
76
|
+
2. Point GitLab at `<app-url>/webhook/gitlab`, from
|
|
77
|
+
Project Settings → Webhooks → Add webhook.
|
|
78
|
+
|
|
79
|
+
**Verification:** none, in the sense of a signature. GitLab's webhook *Secret
|
|
80
|
+
token* is a static shared token, not an HMAC; GitLab sends it verbatim in
|
|
81
|
+
`X-Gitlab-Token`, which this plugin compares in constant time. The dashboard
|
|
82
|
+
reports `signed=False` so you can tell this apart from a product that actually
|
|
83
|
+
signs the body.
|
|
84
|
+
|
|
85
|
+
(GitLab's newer *Signing token* mode does sign the body, following the Standard
|
|
86
|
+
Webhooks spec: `webhook-signature` carries an HMAC-SHA256 over
|
|
87
|
+
`webhook-id`.`webhook-timestamp`.body. This plugin authenticates the classic
|
|
88
|
+
secret-token mode and does not cover that one.)
|
|
89
|
+
|
|
90
|
+
## Event constants
|
|
91
|
+
|
|
92
|
+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
|
|
93
|
+
event type, so a typo fails at import rather than by silently never matching.
|
|
94
|
+
`MergeRequest`, `Issue`, and `Release` split the action (`merge_request.open`);
|
|
95
|
+
`Pipeline` and `Deployment` split on GitLab's `status` instead
|
|
96
|
+
(`pipeline.success`, `deployment.failed`); `Push`, `TagPush`, and `Note` carry
|
|
97
|
+
only `ANY` because GitLab sends them no action. Raw strings still work, for
|
|
98
|
+
events the constants do not cover yet.
|
|
99
|
+
|
|
100
|
+
`payloads` adds typed views (`payloads.merge_request(event)`, `payloads.note(event)`,
|
|
101
|
+
`payloads.push(event)`) of the common payload fields for autocomplete inside
|
|
102
|
+
handlers.
|
|
103
|
+
|
|
104
|
+
## What this plugin does not do
|
|
105
|
+
|
|
106
|
+
Call the GitLab API. Use `python-gitlab` from your tasks — install
|
|
107
|
+
`flyteplugins-gitlab[gitlab]` for it (the plugin does not import it) — to open,
|
|
108
|
+
comment on, approve, or merge MRs once a webhook has launched a run. This plugin
|
|
109
|
+
owns only the part that is Flyte's: authenticating an inbound delivery and
|
|
110
|
+
turning it into a run.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
flyteplugins/gitlab/__init__.py,sha256=_8hqVyHE9bgHydzfBMX9UXDfSJTPJWiYm8Zz9YJC4jU,2764
|
|
2
|
+
flyteplugins/gitlab/_provider.py,sha256=BrcpNm4DXk9yx1hecRmaH5ek_psF4JVWQG8WhJL_srI,7638
|
|
3
|
+
flyteplugins/gitlab/events.py,sha256=T9QDpRIdNgOpyUMJwAsJi1kA9v0_sdC0T1N392rxfFU,3268
|
|
4
|
+
flyteplugins/gitlab/payloads.py,sha256=vd646iAg251USzFLSzg00DYwW2fuuHasC-Nay5vB6KE,4442
|
|
5
|
+
flyteplugins_gitlab-0.0.0a0.dist-info/METADATA,sha256=BVCeZQadrrAeJvHNdoAP9NSwLXHXLyjjSaqRrSiTZS8,3979
|
|
6
|
+
flyteplugins_gitlab-0.0.0a0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
7
|
+
flyteplugins_gitlab-0.0.0a0.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
8
|
+
flyteplugins_gitlab-0.0.0a0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|