flyteplugins-linear 2.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- flyteplugins/linear/__init__.py +32 -0
- flyteplugins/linear/_provider.py +92 -0
- flyteplugins/linear/events.py +79 -0
- flyteplugins_linear-2.7.0.dist-info/METADATA +91 -0
- flyteplugins_linear-2.7.0.dist-info/RECORD +7 -0
- flyteplugins_linear-2.7.0.dist-info/WHEEL +5 -0
- flyteplugins_linear-2.7.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Linear webhooks for Flyte.
|
|
2
|
+
|
|
3
|
+
Hand a `LinearProvider()` to a `WebhookAppEnvironment` and register handlers with the
|
|
4
|
+
typed constants in `events`. Calling the Linear API is not this plugin's job —
|
|
5
|
+
Linear's API is a single GraphQL endpoint, so use `gql` from your tasks. See
|
|
6
|
+
`examples/external_saas_integrations`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
|
|
12
|
+
from . import events
|
|
13
|
+
from ._provider import LinearProvider, parse, verify
|
|
14
|
+
|
|
15
|
+
__all__ = ["SAMPLE_DELIVERY", "LinearProvider", "events", "parse", "verify"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _sample_headers(body: bytes, secret: str) -> dict[str, str]:
|
|
19
|
+
return {"X-Linear-Signature": hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: A real `Issue.create` delivery, trimmed to the fields the parser reads.
|
|
23
|
+
SAMPLE_DELIVERY = (
|
|
24
|
+
_sample_headers,
|
|
25
|
+
(
|
|
26
|
+
b'{"action": "create", "type": "Issue", "webhookId": "wh-000",'
|
|
27
|
+
b' "createdAt": "2024-01-01T00:00:00.000Z",'
|
|
28
|
+
b' "data": {"id": "00000000-0000-0000-0000-000000000000", "title": "A bug",'
|
|
29
|
+
b' "teamId": "team-000", "updatedAt": "2024-01-01T00:00:00.000Z",'
|
|
30
|
+
b' "url": "https://linear.app/acme/issue/ENG-1"}}'
|
|
31
|
+
),
|
|
32
|
+
)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Linear 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
|
+
WebhookEvent,
|
|
10
|
+
constant_time_equals,
|
|
11
|
+
hex_hmac_sha256,
|
|
12
|
+
json_body,
|
|
13
|
+
lower_headers,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def verify(body: bytes, headers: Mapping[str, str], secret: str) -> bool:
|
|
18
|
+
"""Verify the `X-Linear-Signature` HMAC over the raw body."""
|
|
19
|
+
signature = lower_headers(headers).get("x-linear-signature")
|
|
20
|
+
if not signature:
|
|
21
|
+
return False
|
|
22
|
+
return constant_time_equals(hex_hmac_sha256(secret, body), signature.strip())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _team_id(data: dict[str, Any]) -> str | None:
|
|
26
|
+
"""Find the team id, which Comment and Reaction payloads nest on the issue.
|
|
27
|
+
|
|
28
|
+
Without these fallbacks a `scopes` allowlist drops every non-Issue event as
|
|
29
|
+
unattributable.
|
|
30
|
+
"""
|
|
31
|
+
issue = data.get("issue") or {}
|
|
32
|
+
for candidate in (
|
|
33
|
+
data.get("teamId"),
|
|
34
|
+
(data.get("team") or {}).get("id"),
|
|
35
|
+
issue.get("teamId"),
|
|
36
|
+
(issue.get("team") or {}).get("id"),
|
|
37
|
+
):
|
|
38
|
+
if candidate:
|
|
39
|
+
return str(candidate)
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse(headers: Mapping[str, str], body: bytes) -> WebhookEvent:
|
|
44
|
+
"""Normalize a Linear delivery into a `WebhookEvent`."""
|
|
45
|
+
payload = json_body(body)
|
|
46
|
+
data = payload.get("data") or {}
|
|
47
|
+
return WebhookEvent(
|
|
48
|
+
provider="linear",
|
|
49
|
+
event_type=payload.get("type", "Unknown"),
|
|
50
|
+
action=payload.get("action", "unknown"),
|
|
51
|
+
delivery_id=str(payload.get("webhookId") or ""),
|
|
52
|
+
resource_id=data.get("id"),
|
|
53
|
+
# `updatedAt` is on the entity; `createdAt` is the delivery time and the
|
|
54
|
+
# only timestamp on payloads whose entity carries none.
|
|
55
|
+
occurred_at=data.get("updatedAt") or payload.get("createdAt"),
|
|
56
|
+
scope=_team_id(data),
|
|
57
|
+
title=data.get("title"),
|
|
58
|
+
url=data.get("url") or payload.get("url"),
|
|
59
|
+
actor=(data.get("creator") or {}).get("name"),
|
|
60
|
+
payload=payload,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class LinearProvider(Provider):
|
|
65
|
+
"""Linear's webhook provider, with its defaults pre-wired.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from flyte.extras.webhooks import WebhookAppEnvironment
|
|
69
|
+
from flyteplugins.linear import LinearProvider
|
|
70
|
+
|
|
71
|
+
app_env = WebhookAppEnvironment(name="webhooks", providers=[LinearProvider()])
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`WebhookAppEnvironment` mounts `default_secret_env` for you, so it does not
|
|
75
|
+
need naming again in `secrets=`.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
secret_env: Environment variable holding the secret. Pass one only to
|
|
79
|
+
point this provider at a secret stored under a different name;
|
|
80
|
+
otherwise `default_secret_env` applies.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
default_secret_env: ClassVar[str] = "LINEAR_WEBHOOK_SECRET"
|
|
84
|
+
|
|
85
|
+
def __init__(self, *, secret_env: str | None = None) -> None:
|
|
86
|
+
super().__init__(
|
|
87
|
+
name="linear",
|
|
88
|
+
secret_env=secret_env or self.default_secret_env,
|
|
89
|
+
verify=verify,
|
|
90
|
+
parse=parse,
|
|
91
|
+
setup_hint="Linear Settings -> API -> Webhooks",
|
|
92
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Linear webhook events, spelled as the `Type.action` pattern Linear sends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from flyte.extras.webhooks import EventType
|
|
6
|
+
|
|
7
|
+
__all__ = ["Attachment", "Comment", "Cycle", "Issue", "IssueLabel", "Project", "ProjectUpdate", "Reaction"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Issue(EventType):
|
|
11
|
+
"""`Issue` entity events."""
|
|
12
|
+
|
|
13
|
+
ANY = "Issue"
|
|
14
|
+
CREATE = "Issue.create"
|
|
15
|
+
UPDATE = "Issue.update"
|
|
16
|
+
REMOVE = "Issue.remove"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Comment(EventType):
|
|
20
|
+
"""`Comment` entity events."""
|
|
21
|
+
|
|
22
|
+
ANY = "Comment"
|
|
23
|
+
CREATE = "Comment.create"
|
|
24
|
+
UPDATE = "Comment.update"
|
|
25
|
+
REMOVE = "Comment.remove"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class IssueLabel(EventType):
|
|
29
|
+
"""`IssueLabel` entity events."""
|
|
30
|
+
|
|
31
|
+
ANY = "IssueLabel"
|
|
32
|
+
CREATE = "IssueLabel.create"
|
|
33
|
+
UPDATE = "IssueLabel.update"
|
|
34
|
+
REMOVE = "IssueLabel.remove"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Project(EventType):
|
|
38
|
+
"""`Project` entity events."""
|
|
39
|
+
|
|
40
|
+
ANY = "Project"
|
|
41
|
+
CREATE = "Project.create"
|
|
42
|
+
UPDATE = "Project.update"
|
|
43
|
+
REMOVE = "Project.remove"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ProjectUpdate(EventType):
|
|
47
|
+
"""`ProjectUpdate` entity events — project status posts."""
|
|
48
|
+
|
|
49
|
+
ANY = "ProjectUpdate"
|
|
50
|
+
CREATE = "ProjectUpdate.create"
|
|
51
|
+
UPDATE = "ProjectUpdate.update"
|
|
52
|
+
REMOVE = "ProjectUpdate.remove"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Cycle(EventType):
|
|
56
|
+
"""`Cycle` entity events."""
|
|
57
|
+
|
|
58
|
+
ANY = "Cycle"
|
|
59
|
+
CREATE = "Cycle.create"
|
|
60
|
+
UPDATE = "Cycle.update"
|
|
61
|
+
REMOVE = "Cycle.remove"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class Reaction(EventType):
|
|
65
|
+
"""`Reaction` entity events."""
|
|
66
|
+
|
|
67
|
+
ANY = "Reaction"
|
|
68
|
+
CREATE = "Reaction.create"
|
|
69
|
+
UPDATE = "Reaction.update"
|
|
70
|
+
REMOVE = "Reaction.remove"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Attachment(EventType):
|
|
74
|
+
"""`Attachment` entity events."""
|
|
75
|
+
|
|
76
|
+
ANY = "Attachment"
|
|
77
|
+
CREATE = "Attachment.create"
|
|
78
|
+
UPDATE = "Attachment.update"
|
|
79
|
+
REMOVE = "Attachment.remove"
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: flyteplugins-linear
|
|
3
|
+
Version: 2.7.0
|
|
4
|
+
Summary: Receive Linear 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
|
+
|
|
13
|
+
# flyteplugins-linear
|
|
14
|
+
|
|
15
|
+
Receive Linear webhooks in Flyte.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install "flyteplugins-linear[app]"
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Using it
|
|
22
|
+
|
|
23
|
+
Hand a `LinearProvider()` to a `WebhookAppEnvironment` and register handlers with the
|
|
24
|
+
typed constants in `events`:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import flyte
|
|
28
|
+
from flyte.extras.webhooks import WebhookAppEnvironment, run_once
|
|
29
|
+
from flyteplugins.linear import LinearProvider, events
|
|
30
|
+
|
|
31
|
+
# LinearProvider.default_secret_env is mounted for you.
|
|
32
|
+
app_env = WebhookAppEnvironment(name="linear-webhooks", providers=[LinearProvider()])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@app_env.on_event(events.Issue.CREATE)
|
|
36
|
+
async def handle(event):
|
|
37
|
+
import flyte.remote as remote
|
|
38
|
+
|
|
39
|
+
task = remote.Task.get(name="my-env.my_task", auto_version="latest")
|
|
40
|
+
result = await run_once.aio(task, key=event.dedupe_key(), resource=event.resource_id)
|
|
41
|
+
if not result.created:
|
|
42
|
+
return {"skipped": result.run.name, "url": result.run.url}
|
|
43
|
+
return {"run": result.run.name}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
flyte.serve(app_env)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Handlers must `await run_once.aio(...)`. The blocking form stalls the
|
|
50
|
+
app's event loop, and Linear times deliveries out in seconds.
|
|
51
|
+
|
|
52
|
+
One app can serve several products at once — hand it one provider per product.
|
|
53
|
+
|
|
54
|
+
## Try it
|
|
55
|
+
|
|
56
|
+
`examples/linear_webhooks.py` runs two ways. The first needs no Linear account:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python examples/linear_webhooks.py --local # replay a real sample delivery in-process
|
|
60
|
+
python examples/linear_webhooks.py # deploy the receiver to Flyte
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`--local` posts this plugin's `SAMPLE_DELIVERY` through the app with FastAPI's
|
|
64
|
+
test client, so you see a delivery verified, normalized, and dispatched — plus
|
|
65
|
+
an unsigned one refused with a 401, and the same delivery replayed to show the
|
|
66
|
+
dedupe key is stable.
|
|
67
|
+
|
|
68
|
+
## Setup
|
|
69
|
+
|
|
70
|
+
1. Store the secret and mount it on the app:
|
|
71
|
+
```bash
|
|
72
|
+
flyte create secret LINEAR_WEBHOOK_SECRET --value <secret>
|
|
73
|
+
```
|
|
74
|
+
2. Point Linear at `<app-url>/webhook/linear`, from
|
|
75
|
+
Linear Settings → API → Webhooks (it shows the signing secret on creation).
|
|
76
|
+
|
|
77
|
+
**Verification:** HMAC-SHA256 over the raw body (`X-Linear-Signature`).
|
|
78
|
+
|
|
79
|
+
Comment and reaction payloads carry the team id only on the nested issue; the parser follows it, so a `scopes` allowlist can still attribute them.
|
|
80
|
+
|
|
81
|
+
## Event constants
|
|
82
|
+
|
|
83
|
+
`events` spells every event this plugin can dispatch, as `str` enums grouped by
|
|
84
|
+
event type, so a typo fails at import rather than by silently never matching.
|
|
85
|
+
Raw strings still work, for events the constants do not cover yet.
|
|
86
|
+
|
|
87
|
+
## What this plugin does not do
|
|
88
|
+
|
|
89
|
+
Call the Linear API. Use `gql` — Linear ships no Python SDK, and its API is a single GraphQL endpoint directly from your tasks — see
|
|
90
|
+
`examples/external_saas_integrations`. This plugin owns only the part that is
|
|
91
|
+
Flyte's: authenticating an inbound delivery and turning it into a run.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
flyteplugins/linear/__init__.py,sha256=LMVeCvdZ6fOVr0280-oTk7lUFVI--j0UZqxh3el4tKM,1138
|
|
2
|
+
flyteplugins/linear/_provider.py,sha256=1aTTdtYIEh4568QZ0pSwAEvhaUd5mCS-S2-1ak8Wm68,3035
|
|
3
|
+
flyteplugins/linear/events.py,sha256=qAFB8qlkA7BOgDbAX9Y_GwA-8OoiOEK6vCAymemb98k,1741
|
|
4
|
+
flyteplugins_linear-2.7.0.dist-info/METADATA,sha256=FQd7eDb9bYcv9uVDEZGItEwdoPpFr_FFjQcofFVLu6M,3081
|
|
5
|
+
flyteplugins_linear-2.7.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
6
|
+
flyteplugins_linear-2.7.0.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
|
|
7
|
+
flyteplugins_linear-2.7.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
flyteplugins
|