linear-events 0.1.0__tar.gz
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.
- linear_events-0.1.0/PKG-INFO +38 -0
- linear_events-0.1.0/README.md +27 -0
- linear_events-0.1.0/pyproject.toml +27 -0
- linear_events-0.1.0/src/linear_events/__init__.py +39 -0
- linear_events-0.1.0/src/linear_events/client.py +152 -0
- linear_events-0.1.0/src/linear_events/dispatch.py +157 -0
- linear_events-0.1.0/src/linear_events/fastapi.py +55 -0
- linear_events-0.1.0/src/linear_events/payloads.py +140 -0
- linear_events-0.1.0/src/linear_events/py.typed +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: linear-events
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Linear webhook dispatch: HMAC-SHA256 signature verification, replay protection, and .on() handler routing
|
|
5
|
+
Requires-Dist: httpx>=0.28 ; extra == 'client'
|
|
6
|
+
Requires-Dist: fastapi>=0.110 ; extra == 'fastapi'
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Provides-Extra: client
|
|
9
|
+
Provides-Extra: fastapi
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# linear-events
|
|
13
|
+
|
|
14
|
+
Linear webhook payload types, HMAC signature verification, replay protection,
|
|
15
|
+
and event dispatch.
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
pip install linear-events
|
|
19
|
+
# Optional FastAPI integration and agent activity client:
|
|
20
|
+
pip install 'linear-events[fastapi,client]'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import os
|
|
25
|
+
from linear_events import LinearDispatcher
|
|
26
|
+
|
|
27
|
+
hooks = LinearDispatcher(signing_secret=os.environ["LINEAR_WEBHOOK_SECRET"])
|
|
28
|
+
|
|
29
|
+
@hooks.on("Issue", action="create")
|
|
30
|
+
async def on_issue(payload):
|
|
31
|
+
print(payload["data"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The dispatcher verifies signatures and timestamps when a signing secret is
|
|
35
|
+
configured. Mount `as_fastapi_router()` for FastAPI handling, or call `dispatch`
|
|
36
|
+
with the raw request body, signature, and timestamp yourself. The base package
|
|
37
|
+
has no runtime dependencies. `linear_events.client.LinearAgentClient` is
|
|
38
|
+
available with the `client` extra.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# linear-events
|
|
2
|
+
|
|
3
|
+
Linear webhook payload types, HMAC signature verification, replay protection,
|
|
4
|
+
and event dispatch.
|
|
5
|
+
|
|
6
|
+
```sh
|
|
7
|
+
pip install linear-events
|
|
8
|
+
# Optional FastAPI integration and agent activity client:
|
|
9
|
+
pip install 'linear-events[fastapi,client]'
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```python
|
|
13
|
+
import os
|
|
14
|
+
from linear_events import LinearDispatcher
|
|
15
|
+
|
|
16
|
+
hooks = LinearDispatcher(signing_secret=os.environ["LINEAR_WEBHOOK_SECRET"])
|
|
17
|
+
|
|
18
|
+
@hooks.on("Issue", action="create")
|
|
19
|
+
async def on_issue(payload):
|
|
20
|
+
print(payload["data"])
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The dispatcher verifies signatures and timestamps when a signing secret is
|
|
24
|
+
configured. Mount `as_fastapi_router()` for FastAPI handling, or call `dispatch`
|
|
25
|
+
with the raw request body, signature, and timestamp yourself. The base package
|
|
26
|
+
has no runtime dependencies. `linear_events.client.LinearAgentClient` is
|
|
27
|
+
available with the `client` extra.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "linear-events"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Linear webhook dispatch: HMAC-SHA256 signature verification, replay protection, and .on() handler routing"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.optional-dependencies]
|
|
10
|
+
fastapi = ["fastapi>=0.110"]
|
|
11
|
+
client = ["httpx>=0.28"]
|
|
12
|
+
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"fastapi>=0.110",
|
|
16
|
+
"httpx>=0.28",
|
|
17
|
+
"pytest>=9.0",
|
|
18
|
+
"pytest-asyncio>=1.4",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["uv_build>=0.11,<0.12"]
|
|
23
|
+
build-backend = "uv_build"
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
asyncio_mode = "auto"
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""linear_events: Linear webhook signature verification and .on() dispatch.
|
|
2
|
+
|
|
3
|
+
Core (no dependencies): LinearDispatcher (verify + route by (type, action))
|
|
4
|
+
and the payload TypedDicts, including the AgentSessionEvent model.
|
|
5
|
+
linear-events[fastapi] adds a mountable endpoint via .as_fastapi_router();
|
|
6
|
+
linear-events[client] adds LinearAgentClient for emitting agent activities.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from linear_events.dispatch import (
|
|
10
|
+
LinearDispatcher,
|
|
11
|
+
LinearError,
|
|
12
|
+
SignatureVerificationError,
|
|
13
|
+
)
|
|
14
|
+
from linear_events.payloads import (
|
|
15
|
+
Actor,
|
|
16
|
+
AgentActivityWebhookPayload,
|
|
17
|
+
AgentSessionEventWebhookPayload,
|
|
18
|
+
AgentSessionWebhookPayload,
|
|
19
|
+
CommentChildWebhookPayload,
|
|
20
|
+
GuidanceRuleWebhookPayload,
|
|
21
|
+
IssueChildWebhookPayload,
|
|
22
|
+
UserChildWebhookPayload,
|
|
23
|
+
WebhookPayload,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Actor",
|
|
28
|
+
"AgentActivityWebhookPayload",
|
|
29
|
+
"AgentSessionEventWebhookPayload",
|
|
30
|
+
"AgentSessionWebhookPayload",
|
|
31
|
+
"CommentChildWebhookPayload",
|
|
32
|
+
"GuidanceRuleWebhookPayload",
|
|
33
|
+
"IssueChildWebhookPayload",
|
|
34
|
+
"LinearDispatcher",
|
|
35
|
+
"LinearError",
|
|
36
|
+
"SignatureVerificationError",
|
|
37
|
+
"UserChildWebhookPayload",
|
|
38
|
+
"WebhookPayload",
|
|
39
|
+
]
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""A minimal client for emitting Agent Activities (linear-events[client]).
|
|
2
|
+
|
|
3
|
+
Agents answer an AgentSession by posting typed activities — thought, action,
|
|
4
|
+
elicitation, response, error — which Linear renders in place and uses to
|
|
5
|
+
track the session's lifecycle. The content constructors below build exactly
|
|
6
|
+
the shapes Linear validates server-side.
|
|
7
|
+
|
|
8
|
+
client = LinearAgentClient(token)
|
|
9
|
+
await client.emit(session_id, thought("Looking into it…"))
|
|
10
|
+
await client.emit(session_id, action("Searching", "ENG-123"))
|
|
11
|
+
await client.emit(session_id, response("Done."))
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import httpx
|
|
20
|
+
except ImportError as e: # pragma: no cover
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"linear_events.client requires httpx — install linear-events[client]"
|
|
23
|
+
) from e
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Activity",
|
|
27
|
+
"LinearAgentClient",
|
|
28
|
+
"LinearApiError",
|
|
29
|
+
"action",
|
|
30
|
+
"elicitation",
|
|
31
|
+
"error",
|
|
32
|
+
"response",
|
|
33
|
+
"thought",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
Activity = dict[str, Any] # the `content` of an agentActivityCreate
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def thought(body: str, *, ephemeral: bool = False) -> Activity:
|
|
40
|
+
"""An internal note. Ephemeral thoughts are replaced by the next activity."""
|
|
41
|
+
return {"type": "thought", "body": body, "ephemeral": ephemeral}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def elicitation(body: str) -> Activity:
|
|
45
|
+
"""Ask the user for clarification or confirmation (session awaits input)."""
|
|
46
|
+
return {"type": "elicitation", "body": body}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def action(name: str, parameter: str, result: str | None = None, *, ephemeral: bool = False) -> Activity:
|
|
50
|
+
"""A tool invocation, without (running) or with (completed) a result."""
|
|
51
|
+
activity: Activity = {"type": "action", "action": name, "parameter": parameter, "ephemeral": ephemeral}
|
|
52
|
+
if result is not None:
|
|
53
|
+
activity["result"] = result
|
|
54
|
+
return activity
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def response(body: str) -> Activity:
|
|
58
|
+
"""The final answer; marks the session's work complete. Markdown OK."""
|
|
59
|
+
return {"type": "response", "body": body}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def error(body: str) -> Activity:
|
|
63
|
+
"""Report a failure; puts the session in its error state. Markdown OK."""
|
|
64
|
+
return {"type": "error", "body": body}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LinearApiError(Exception):
|
|
68
|
+
"""Linear answered a GraphQL call with an `errors` array."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
AGENT_ACTIVITY_CREATE = """
|
|
72
|
+
mutation AgentActivityCreate($input: AgentActivityCreateInput!) {
|
|
73
|
+
agentActivityCreate(input: $input) {
|
|
74
|
+
success
|
|
75
|
+
agentActivity { id }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LinearAgentClient:
|
|
82
|
+
"""Emits activities with an app-user OAuth token (Bearer). Pass an
|
|
83
|
+
existing httpx.AsyncClient as `http` to share one; otherwise each call
|
|
84
|
+
uses a short-lived client."""
|
|
85
|
+
|
|
86
|
+
def __init__(
|
|
87
|
+
self,
|
|
88
|
+
token: str,
|
|
89
|
+
*,
|
|
90
|
+
api_base: str = "https://api.linear.app",
|
|
91
|
+
http: httpx.AsyncClient | None = None,
|
|
92
|
+
) -> None:
|
|
93
|
+
self._token = token
|
|
94
|
+
self._api_base = api_base.rstrip("/")
|
|
95
|
+
self._http = http
|
|
96
|
+
|
|
97
|
+
async def emit(self, agent_session_id: str, content: Activity, *, activity_id: str | None = None) -> dict[str, Any]:
|
|
98
|
+
"""POST agentActivityCreate; returns the created activity's data."""
|
|
99
|
+
content = dict(content)
|
|
100
|
+
ephemeral = content.pop("ephemeral", None)
|
|
101
|
+
activity_input = {"agentSessionId": agent_session_id, "content": content}
|
|
102
|
+
if ephemeral is not None:
|
|
103
|
+
activity_input["ephemeral"] = ephemeral
|
|
104
|
+
if activity_id is not None:
|
|
105
|
+
activity_input["id"] = activity_id
|
|
106
|
+
try:
|
|
107
|
+
data = await self._graphql(AGENT_ACTIVITY_CREATE, {"input": activity_input})
|
|
108
|
+
result = data["agentActivityCreate"]
|
|
109
|
+
if not result.get("success") or not result.get("agentActivity"):
|
|
110
|
+
raise LinearApiError("Linear did not accept the activity")
|
|
111
|
+
return result["agentActivity"]
|
|
112
|
+
except (httpx.HTTPError, LinearApiError):
|
|
113
|
+
# An earlier attempt may have succeeded before its response was
|
|
114
|
+
# lost. Confirm the stable id instead of creating a second reply.
|
|
115
|
+
if activity_id is not None:
|
|
116
|
+
data = await self._graphql(
|
|
117
|
+
"query MoActivity($id: String!) { agentActivity(id: $id) { id agentSession { id } } }",
|
|
118
|
+
{"id": activity_id},
|
|
119
|
+
)
|
|
120
|
+
existing = data.get("agentActivity")
|
|
121
|
+
if existing and existing["agentSession"]["id"] == agent_session_id:
|
|
122
|
+
return {"id": existing["id"]}
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
async def link(self, agent_session_id: str, url: str, label: str) -> None:
|
|
126
|
+
"""Add a dashboard link without replacing links contributed elsewhere."""
|
|
127
|
+
data = await self._graphql(
|
|
128
|
+
"mutation MoSessionLink($id: String!, $input: AgentSessionUpdateInput!) { agentSessionUpdate(id: $id, input: $input) { success } }",
|
|
129
|
+
{"id": agent_session_id, "input": {"addedExternalUrls": [{"url": url, "label": label}]}},
|
|
130
|
+
)
|
|
131
|
+
if not data["agentSessionUpdate"]["success"]:
|
|
132
|
+
raise LinearApiError("Linear did not accept the session link")
|
|
133
|
+
|
|
134
|
+
async def _graphql(self, query: str, variables: dict[str, Any]) -> Any:
|
|
135
|
+
async def call(http: httpx.AsyncClient) -> httpx.Response:
|
|
136
|
+
return await http.post(
|
|
137
|
+
f"{self._api_base}/graphql",
|
|
138
|
+
headers={"Authorization": f"Bearer {self._token}"},
|
|
139
|
+
json={"query": query, "variables": variables},
|
|
140
|
+
timeout=15,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self._http is not None:
|
|
144
|
+
res = await call(self._http)
|
|
145
|
+
else:
|
|
146
|
+
async with httpx.AsyncClient() as http:
|
|
147
|
+
res = await call(http)
|
|
148
|
+
res.raise_for_status()
|
|
149
|
+
body = res.json()
|
|
150
|
+
if errors := body.get("errors"):
|
|
151
|
+
raise LinearApiError(errors[0].get("message", "unknown error"))
|
|
152
|
+
return body["data"]
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Framework-free Linear webhook dispatch: verify, parse, route.
|
|
2
|
+
|
|
3
|
+
Works with any HTTP framework (or none) — you bring the raw request body and
|
|
4
|
+
the signature/timestamp headers, LinearDispatcher does the rest.
|
|
5
|
+
linear_events.fastapi wraps this for FastAPI apps.
|
|
6
|
+
|
|
7
|
+
Routing keys are (type, action) pairs read from the payload body:
|
|
8
|
+
|
|
9
|
+
@hook.on("Issue") -> every Issue action
|
|
10
|
+
@hook.on("Comment", action="create") -> only new comments
|
|
11
|
+
@hook.on_any -> (type, action, payload) for all
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import inspect
|
|
19
|
+
import json
|
|
20
|
+
import time
|
|
21
|
+
from collections.abc import Callable
|
|
22
|
+
from typing import Any, TypeVar
|
|
23
|
+
|
|
24
|
+
__all__ = ["LinearDispatcher", "LinearError", "SignatureVerificationError"]
|
|
25
|
+
|
|
26
|
+
Handler = TypeVar("Handler", bound=Callable[..., Any])
|
|
27
|
+
|
|
28
|
+
# Linear recommends rejecting deliveries older than a minute (replay window).
|
|
29
|
+
REPLAY_TOLERANCE_MS = 60 * 1000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LinearError(Exception):
|
|
33
|
+
"""Base class for dispatch failures."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SignatureVerificationError(LinearError):
|
|
37
|
+
"""Linear-Signature was missing/stale/mismatched."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class LinearDispatcher:
|
|
41
|
+
"""Handler registry keyed by (type, action), with signature verification.
|
|
42
|
+
|
|
43
|
+
Handlers may be sync or async and receive the full payload; `payload["data"]`
|
|
44
|
+
holds the serialized entity. Verification is a no-op unless a signing
|
|
45
|
+
secret is configured.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, signing_secret: str | bytes | None = None) -> None:
|
|
49
|
+
if isinstance(signing_secret, str):
|
|
50
|
+
signing_secret = signing_secret.encode()
|
|
51
|
+
self._secret = signing_secret
|
|
52
|
+
self._handlers: dict[tuple[str, str | None], list[Callable[..., Any]]] = {}
|
|
53
|
+
self._any_handlers: list[Callable[..., Any]] = []
|
|
54
|
+
|
|
55
|
+
def on(
|
|
56
|
+
self, type: str, action: str | list[str] | None = None
|
|
57
|
+
) -> Callable[[Handler], Handler]:
|
|
58
|
+
"""Register a handler for one entity type, optionally narrowed to one
|
|
59
|
+
action or a list of actions (the handler fires for any of them)."""
|
|
60
|
+
actions: list[str | None]
|
|
61
|
+
if action is None:
|
|
62
|
+
actions = [None]
|
|
63
|
+
elif isinstance(action, str):
|
|
64
|
+
actions = [action]
|
|
65
|
+
else:
|
|
66
|
+
actions = list(dict.fromkeys(action)) # dedupe, keep order
|
|
67
|
+
if not actions:
|
|
68
|
+
raise ValueError("action list must not be empty")
|
|
69
|
+
|
|
70
|
+
def register(fn: Handler) -> Handler:
|
|
71
|
+
for a in actions:
|
|
72
|
+
self._handlers.setdefault((type, a), []).append(fn)
|
|
73
|
+
return fn
|
|
74
|
+
|
|
75
|
+
return register
|
|
76
|
+
|
|
77
|
+
def on_any(self, fn: Handler) -> Handler:
|
|
78
|
+
"""Register a handler called with (type, action, payload) for every delivery."""
|
|
79
|
+
self._any_handlers.append(fn)
|
|
80
|
+
return fn
|
|
81
|
+
|
|
82
|
+
def verify_signature(self, body: bytes, signature: str | None) -> None:
|
|
83
|
+
"""Linear-Signature is a plain hex HMAC-SHA256 over the raw body,
|
|
84
|
+
constant-time compared. No-op unless a signing secret is configured."""
|
|
85
|
+
if self._secret is None:
|
|
86
|
+
return
|
|
87
|
+
if not signature:
|
|
88
|
+
raise SignatureVerificationError("missing Linear-Signature header")
|
|
89
|
+
expected = hmac.new(self._secret, body, hashlib.sha256).hexdigest()
|
|
90
|
+
if not hmac.compare_digest(expected, signature):
|
|
91
|
+
raise SignatureVerificationError("Linear-Signature mismatch")
|
|
92
|
+
|
|
93
|
+
def _check_freshness(self, timestamp_ms: int | None) -> None:
|
|
94
|
+
"""The 60-second replay window Linear recommends, from the
|
|
95
|
+
Linear-Timestamp header or the body's webhookTimestamp."""
|
|
96
|
+
if self._secret is None:
|
|
97
|
+
return
|
|
98
|
+
if timestamp_ms is None:
|
|
99
|
+
raise SignatureVerificationError(
|
|
100
|
+
"missing Linear-Timestamp header / webhookTimestamp field"
|
|
101
|
+
)
|
|
102
|
+
if abs(time.time() * 1000 - timestamp_ms) > REPLAY_TOLERANCE_MS:
|
|
103
|
+
raise SignatureVerificationError(
|
|
104
|
+
"stale Linear-Timestamp (replay protection)"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
async def dispatch(
|
|
108
|
+
self,
|
|
109
|
+
body: bytes | str,
|
|
110
|
+
*,
|
|
111
|
+
timestamp: str | None = None,
|
|
112
|
+
signature: str | None = None,
|
|
113
|
+
) -> dict[str, Any]:
|
|
114
|
+
"""Verify, parse and route one delivery; returns the payload.
|
|
115
|
+
|
|
116
|
+
Raises SignatureVerificationError (a secret is configured and the
|
|
117
|
+
signature is missing/wrong or the timestamp is missing/stale),
|
|
118
|
+
json.JSONDecodeError (malformed body), LinearError (unusable payload).
|
|
119
|
+
"""
|
|
120
|
+
raw = body.encode() if isinstance(body, str) else body
|
|
121
|
+
self.verify_signature(raw, signature)
|
|
122
|
+
payload = json.loads(raw)
|
|
123
|
+
if not isinstance(payload, dict) or "type" not in payload:
|
|
124
|
+
raise LinearError("malformed payload: expected a JSON object with a `type`")
|
|
125
|
+
|
|
126
|
+
timestamp_ms: int | None
|
|
127
|
+
if timestamp is not None:
|
|
128
|
+
try:
|
|
129
|
+
timestamp_ms = int(timestamp)
|
|
130
|
+
except ValueError:
|
|
131
|
+
raise SignatureVerificationError("malformed Linear-Timestamp header")
|
|
132
|
+
else:
|
|
133
|
+
timestamp_ms = payload.get("webhookTimestamp")
|
|
134
|
+
if not isinstance(timestamp_ms, int):
|
|
135
|
+
timestamp_ms = None
|
|
136
|
+
self._check_freshness(timestamp_ms)
|
|
137
|
+
|
|
138
|
+
event_type = payload["type"]
|
|
139
|
+
action = payload.get("action")
|
|
140
|
+
# dict.fromkeys dedupes (action-less events would otherwise route twice)
|
|
141
|
+
for key in dict.fromkeys(((event_type, action), (event_type, None))):
|
|
142
|
+
for fn in self._handlers.get(key, []):
|
|
143
|
+
if inspect.isawaitable(result := fn(payload)):
|
|
144
|
+
await result
|
|
145
|
+
for fn in self._any_handlers:
|
|
146
|
+
if inspect.isawaitable(result := fn(event_type, action, payload)):
|
|
147
|
+
await result
|
|
148
|
+
return payload
|
|
149
|
+
|
|
150
|
+
def as_fastapi_router(self, *, path: str = "/") -> Any:
|
|
151
|
+
"""Build a FastAPI APIRouter with a POST endpoint wired to this dispatcher.
|
|
152
|
+
|
|
153
|
+
Requires fastapi (linear-events[fastapi]). Returns fastapi.APIRouter.
|
|
154
|
+
"""
|
|
155
|
+
from linear_events.fastapi import create_router
|
|
156
|
+
|
|
157
|
+
return create_router(self, path=path)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""FastAPI integration: mount one endpoint that verifies and dispatches deliveries.
|
|
2
|
+
|
|
3
|
+
from fastapi import FastAPI
|
|
4
|
+
from linear_events import LinearDispatcher
|
|
5
|
+
|
|
6
|
+
hook = LinearDispatcher(signing_secret="...")
|
|
7
|
+
app = FastAPI()
|
|
8
|
+
app.include_router(hook.as_fastapi_router(path="/webhooks/linear"))
|
|
9
|
+
|
|
10
|
+
Responses: 200 on success (handled or not — Linear retries anything non-200,
|
|
11
|
+
so unknown types are acknowledged too), 400 for malformed payloads, 401 for
|
|
12
|
+
bad signatures or stale timestamps.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from fastapi import APIRouter, HTTPException, Request, Response
|
|
21
|
+
except ImportError as e: # pragma: no cover
|
|
22
|
+
raise ImportError(
|
|
23
|
+
"linear_events.fastapi requires fastapi — install linear-events[fastapi]"
|
|
24
|
+
) from e
|
|
25
|
+
|
|
26
|
+
from linear_events.dispatch import (
|
|
27
|
+
LinearDispatcher,
|
|
28
|
+
LinearError,
|
|
29
|
+
SignatureVerificationError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = ["create_router"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_router(dispatcher: LinearDispatcher, *, path: str = "/") -> APIRouter:
|
|
36
|
+
"""An APIRouter whose single POST endpoint feeds deliveries to `dispatcher`."""
|
|
37
|
+
router = APIRouter()
|
|
38
|
+
|
|
39
|
+
@router.post(path)
|
|
40
|
+
async def receive_linear_event(request: Request) -> Response:
|
|
41
|
+
try:
|
|
42
|
+
await dispatcher.dispatch(
|
|
43
|
+
await request.body(),
|
|
44
|
+
timestamp=request.headers.get("linear-timestamp"),
|
|
45
|
+
signature=request.headers.get("linear-signature"),
|
|
46
|
+
)
|
|
47
|
+
except SignatureVerificationError as e:
|
|
48
|
+
raise HTTPException(401, str(e)) from e
|
|
49
|
+
except json.JSONDecodeError as e:
|
|
50
|
+
raise HTTPException(400, f"invalid JSON body: {e}") from e
|
|
51
|
+
except LinearError as e:
|
|
52
|
+
raise HTTPException(400, str(e)) from e
|
|
53
|
+
return Response(status_code=200)
|
|
54
|
+
|
|
55
|
+
return router
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Hand-written TypedDicts for the Linear webhook envelope.
|
|
2
|
+
|
|
3
|
+
The envelope's fields are stable and documented; `data` is the serialized
|
|
4
|
+
entity (Issue, Comment, ...) and moves with Linear's GraphQL schema, so it
|
|
5
|
+
stays a plain dict — handlers can rely on the envelope, not the entity.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any, TypedDict
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Actor",
|
|
12
|
+
"AgentActivityWebhookPayload",
|
|
13
|
+
"AgentSessionEventWebhookPayload",
|
|
14
|
+
"AgentSessionWebhookPayload",
|
|
15
|
+
"CommentChildWebhookPayload",
|
|
16
|
+
"GuidanceRuleWebhookPayload",
|
|
17
|
+
"IssueChildWebhookPayload",
|
|
18
|
+
"UserChildWebhookPayload",
|
|
19
|
+
"WebhookPayload",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Actor(TypedDict, total=False):
|
|
24
|
+
"""Who triggered the action. None when the triggering user or integration
|
|
25
|
+
has since been deleted."""
|
|
26
|
+
|
|
27
|
+
id: str
|
|
28
|
+
type: str # "user" | "application" | "integration"
|
|
29
|
+
name: str
|
|
30
|
+
email: str
|
|
31
|
+
url: str
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class WebhookPayload(TypedDict, total=False):
|
|
35
|
+
"""One Linear delivery. Data-change events (Issue, Comment, ...) carry
|
|
36
|
+
action/type/data; convenience streams (Issue SLA, OAuthApp revoked) add
|
|
37
|
+
their own fields on top."""
|
|
38
|
+
|
|
39
|
+
action: str # "create" | "update" | "remove" for data-change events
|
|
40
|
+
type: str # "Issue" | "Comment" | "Project" | ... (mirrors Linear-Event)
|
|
41
|
+
actor: Actor | None
|
|
42
|
+
createdAt: str # ISO-8601
|
|
43
|
+
data: dict[str, Any] # the serialized subject entity
|
|
44
|
+
url: str # deep link to the subject entity
|
|
45
|
+
updatedFrom: dict[str, Any] # update actions: previous values
|
|
46
|
+
organizationId: str
|
|
47
|
+
webhookId: str
|
|
48
|
+
webhookTimestamp: int # ms; Linear recommends a 60s freshness window
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
# Agent session events (the Agent Interaction model): type is
|
|
53
|
+
# "AgentSessionEvent", action "created" (mention/delegation — a new agent
|
|
54
|
+
# loop should start) or "prompted" (a follow-up user message, in
|
|
55
|
+
# agentActivity.content["body"]).
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UserChildWebhookPayload(TypedDict, total=False):
|
|
59
|
+
id: str
|
|
60
|
+
name: str
|
|
61
|
+
displayName: str
|
|
62
|
+
email: str
|
|
63
|
+
url: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class IssueChildWebhookPayload(TypedDict, total=False):
|
|
67
|
+
id: str
|
|
68
|
+
identifier: str # "ENG-123"
|
|
69
|
+
title: str
|
|
70
|
+
description: str
|
|
71
|
+
url: str
|
|
72
|
+
teamId: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CommentChildWebhookPayload(TypedDict, total=False):
|
|
76
|
+
id: str
|
|
77
|
+
body: str
|
|
78
|
+
userId: str
|
|
79
|
+
issueId: str
|
|
80
|
+
url: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class GuidanceRuleWebhookPayload(TypedDict, total=False):
|
|
84
|
+
"""Instructions configured at the workspace/team level (preferred repos,
|
|
85
|
+
task constraints); the nearest team-specific rule takes precedence."""
|
|
86
|
+
|
|
87
|
+
id: str
|
|
88
|
+
body: str
|
|
89
|
+
origin: str # "workspace" | "parentTeam" | "team"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AgentSessionWebhookPayload(TypedDict, total=False):
|
|
93
|
+
id: str
|
|
94
|
+
status: str # pending | active | awaitingInput | complete | error | stale
|
|
95
|
+
type: str
|
|
96
|
+
appUserId: str
|
|
97
|
+
organizationId: str
|
|
98
|
+
issueId: str | None
|
|
99
|
+
issue: IssueChildWebhookPayload | None
|
|
100
|
+
commentId: str | None
|
|
101
|
+
comment: CommentChildWebhookPayload | None
|
|
102
|
+
creatorId: str | None # the responsible human (unset for automation)
|
|
103
|
+
creator: UserChildWebhookPayload | None
|
|
104
|
+
sourceCommentId: str | None
|
|
105
|
+
url: str | None
|
|
106
|
+
summary: str | None
|
|
107
|
+
startedAt: str | None
|
|
108
|
+
endedAt: str | None
|
|
109
|
+
createdAt: str
|
|
110
|
+
updatedAt: str
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class AgentActivityWebhookPayload(TypedDict, total=False):
|
|
114
|
+
id: str
|
|
115
|
+
agentSessionId: str
|
|
116
|
+
content: dict[str, Any] # {"type": "prompt", "body": ...} for `prompted`
|
|
117
|
+
signal: str | None
|
|
118
|
+
signalMetadata: dict[str, Any] | None
|
|
119
|
+
sourceCommentId: str | None
|
|
120
|
+
userId: str
|
|
121
|
+
user: UserChildWebhookPayload
|
|
122
|
+
createdAt: str
|
|
123
|
+
updatedAt: str
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AgentSessionEventWebhookPayload(TypedDict, total=False):
|
|
127
|
+
action: str # "created" | "prompted"
|
|
128
|
+
type: str # always "AgentSessionEvent"
|
|
129
|
+
agentSession: AgentSessionWebhookPayload
|
|
130
|
+
agentActivity: AgentActivityWebhookPayload | None # set for `prompted`
|
|
131
|
+
appUserId: str
|
|
132
|
+
oauthClientId: str
|
|
133
|
+
organizationId: str
|
|
134
|
+
guidance: list[GuidanceRuleWebhookPayload] | None
|
|
135
|
+
previousComments: list[CommentChildWebhookPayload] | None
|
|
136
|
+
# Formatted context string (issue, comments, guidance); `created` only.
|
|
137
|
+
promptContext: str | None
|
|
138
|
+
webhookId: str
|
|
139
|
+
webhookTimestamp: int # ms
|
|
140
|
+
createdAt: str
|
|
File without changes
|