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.
@@ -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,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