Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0f4ea4c
feat(slack): add Slack integration with @mention-based task submission
isadeks Apr 20, 2026
c7d1ecd
Merge remote-tracking branch 'upstream/main'
isadeks Apr 23, 2026
47a8db5
fix(docs): sync SLACK_SETUP_GUIDE into starlight site
isadeks May 5, 2026
1d8a0d3
fix(slack): address Alain's PR #42 review feedback (items 1-20)
isadeks May 5, 2026
01dbdea
test(slack): add handler-level tests for all 7 Slack Lambda handlers
isadeks May 5, 2026
aa7df09
fix(slack): soften checkChannelAccess + drop stale status claim in docs
isadeks May 5, 2026
a72b28c
chore(types): extend ChannelSource to include 'linear'
isadeks May 5, 2026
3a3c316
feat(linear): Phase 1 — CDK backend handlers + mapping tables
isadeks May 5, 2026
b81b3df
feat(linear): Phase 2 — LinearIntegration construct + stack wiring
isadeks May 5, 2026
341f2b2
feat(linear): Phase 3 — agent-side Linear MCP + channel plumbing
isadeks May 5, 2026
5a00ead
feat(linear): Phase 4 — CLI commands + setup guide + docs
May 5, 2026
ccf5c49
test(linear): Phase 5 — handler + construct + agent channel_mcp tests
May 5, 2026
7e23a41
fix(waf): scope-down CRS for Linear webhook route
May 6, 2026
3b112ef
fix(linear-webhook): dedup on issueId+action+webhookTimestamp, 8h TTL
May 6, 2026
387972e
chore(linear-processor): log label/updatedFrom details on trigger reject
May 6, 2026
63306da
feat(linear-cli): auto-link, list-projects, UUID validation
May 6, 2026
e844fc6
feat(linear-agent): 👀 → ✅/❌ issue reactions via GraphQL
May 6, 2026
0ee586b
fix(linear-agent): correct MCP tool name, make updates required
May 6, 2026
bc7dec5
fix(test): remove race in test_server thread-failure test
May 6, 2026
58b09fa
docs(linear): reflect auto-link + list-projects UX
May 6, 2026
4f51590
merge: upstream/main into feat/linear-integration
May 6, 2026
8120448
docs(linear): drop stale ROADMAP.md reference
May 6, 2026
5cdd158
docs(linear): honest v1 multi-user story
May 6, 2026
759dc96
chore(lint): apply ESLint + ruff auto-fixes
May 6, 2026
7d652a4
fix(linear): address Alain's PR #63 review blockers
May 6, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agent/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ DOCKER_ARGS=(
[[ -n "${DRY_RUN:-}" ]] && DOCKER_ARGS+=(-e "DRY_RUN=${DRY_RUN}")
[[ -n "${MAX_TURNS:-}" ]] && DOCKER_ARGS+=(-e "MAX_TURNS=${MAX_TURNS}")
[[ -n "${MAX_BUDGET_USD:-}" ]] && DOCKER_ARGS+=(-e "MAX_BUDGET_USD=${MAX_BUDGET_USD}")
[[ -n "${LINEAR_API_TOKEN:-}" ]] && DOCKER_ARGS+=(-e "LINEAR_API_TOKEN=${LINEAR_API_TOKEN}")
[[ -n "${LINEAR_API_TOKEN_SECRET_ARN:-}" ]] && DOCKER_ARGS+=(-e "LINEAR_API_TOKEN_SECRET_ARN=${LINEAR_API_TOKEN_SECRET_ARN}")

# Local events mode: connect to DynamoDB Local via the agent-local network
if [[ "$LOCAL_EVENTS" == true ]]; then
Expand Down
112 changes: 112 additions & 0 deletions agent/src/channel_mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Channel-specific MCP configuration for the agent container.

For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned
repo ``cwd`` so the Claude Agent SDK — configured with
``setting_sources=["project"]`` — picks up the Linear MCP at session start
and exposes ``mcp__linear-server__*`` tools.

For all other channel sources this is a no-op: no MCP is written, and the
SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks
from touching Linear.

See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py
(SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md.
"""

from __future__ import annotations

import json
import os
from typing import Any

from shell import log

#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport.
LINEAR_MCP_URL = "https://mcp.linear.app/mcp"

#: Key name inside ``mcpServers``. Tools surface as
#: ``mcp__linear-server__*`` in the Agent SDK (verified in findings).
LINEAR_MCP_SERVER_KEY = "linear-server"

#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}``
#: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh.
LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value


def _linear_server_entry() -> dict[str, Any]:
"""Build the `mcpServers` entry for Linear's hosted MCP."""
return {
"type": "http",
"url": LINEAR_MCP_URL,
"headers": {
"Authorization": f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}",
},
}


def _read_existing_mcp_config(path: str) -> dict[str, Any]:
"""Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid.

Malformed JSON is logged and treated as absent — we prefer to overlay a
valid Linear entry than to crash the agent because a user committed a
broken .mcp.json to their repo.
"""
if not os.path.isfile(path):
return {}
try:
with open(path, encoding="utf-8") as f:
parsed = json.load(f)
if isinstance(parsed, dict):
return parsed
log("WARN", f"Ignoring non-object .mcp.json at {path} (got {type(parsed).__name__})")
except (OSError, json.JSONDecodeError) as e:
log("WARN", f"Failed to read existing .mcp.json at {path}: {type(e).__name__}: {e}")
return {}


def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool:
"""Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``.

Gated on ``channel_source``:
* ``'linear'`` → ensure the ``linear-server`` entry is present in
``.mcp.json`` (merges into any existing config without clobbering
other servers). Returns True.
* anything else → no-op. Returns False.

Args:
repo_dir: the cloned-repo working directory the SDK will use as ``cwd``.
channel_source: inbound channel (``TaskConfig.channel_source``).

Returns:
True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``,
False otherwise (including any non-Linear channel or missing repo_dir).
"""
if channel_source != "linear":
return False

if not repo_dir or not os.path.isdir(repo_dir):
log("WARN", f"configure_channel_mcp: repo_dir missing or not a directory: {repo_dir!r}")
return False

mcp_path = os.path.join(repo_dir, ".mcp.json")
config = _read_existing_mcp_config(mcp_path)

servers = config.get("mcpServers")
if not isinstance(servers, dict):
servers = {}
servers[LINEAR_MCP_SERVER_KEY] = _linear_server_entry()
config["mcpServers"] = servers

try:
with open(mcp_path, "w", encoding="utf-8") as f:
json.dump(config, f, indent=2)
f.write("\n")
except OSError as e:
log("ERROR", f"Failed to write Linear MCP config to {mcp_path}: {e}")
return False

log(
"TASK",
f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})",
)
return True
40 changes: 40 additions & 0 deletions agent/src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,42 @@ def resolve_github_token() -> str:
return ""


def resolve_linear_api_token() -> str:
"""Resolve the Linear personal API token from Secrets Manager or env.

Mirrors ``resolve_github_token``: in deployed mode
``LINEAR_API_TOKEN_SECRET_ARN`` is set and the token is fetched once
and cached in ``LINEAR_API_TOKEN``. For local development, falls back
to ``LINEAR_API_TOKEN`` directly.

Returns an empty string if the secret is absent or empty — the agent-side
MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` env
placeholder, and the Linear MCP will reject the request (fail-closed).
This function is only called when ``channel_source == 'linear'``.
"""
cached = os.environ.get("LINEAR_API_TOKEN", "")
if cached:
return cached
secret_arn = os.environ.get("LINEAR_API_TOKEN_SECRET_ARN")
if not secret_arn:
return ""
try:
import boto3

region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
client = boto3.client("secretsmanager", region_name=region)
resp = client.get_secret_value(SecretId=secret_arn)
token = resp.get("SecretString", "") or ""
if token:
os.environ["LINEAR_API_TOKEN"] = token
return token
except Exception as e:
# Never let a Secrets Manager outage crash the agent. The Linear MCP
# will simply fail on first call with a clear auth error.
print(f"[config] resolve_linear_api_token failed: {type(e).__name__}: {e}", flush=True)
return ""


def build_config(
repo_url: str,
task_description: str = "",
Expand All @@ -52,6 +88,8 @@ def build_config(
task_type: str = "new_task",
branch_name: str = "",
pr_number: str = "",
channel_source: str = "",
channel_metadata: dict[str, str] | None = None,
trace: bool = False,
user_id: str = "",
) -> TaskConfig:
Expand Down Expand Up @@ -104,6 +142,8 @@ def build_config(
branch_name=branch_name,
pr_number=pr_number,
task_id=task_id or uuid.uuid4().hex[:12],
channel_source=channel_source,
channel_metadata=channel_metadata or {},
trace=trace,
user_id=user_id,
)
Expand Down
144 changes: 144 additions & 0 deletions agent/src/linear_reactions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Linear issue-level reaction helper for Linear-origin tasks.

Posts a 👀 reaction on the originating Linear issue at task start, then
swaps it for ✅/❌ on terminal status — mirroring the Slack integration's
terminal-emoji status signal (👀 → ✅/❌, no lingering "watching" marker).

Implementation: ``react_task_started`` captures the reaction id returned by
``reactionCreate`` and hands it back to the caller, which passes it into
``react_task_finished`` so we can ``reactionDelete`` the 👀 before posting
the terminal emoji.

Gating: every function is a no-op unless ``channel_source == 'linear'``
and the Linear issue id is present in ``channel_metadata``. All network
errors are logged and swallowed — a transient Linear API failure must
never fail the task itself (reactions are advisory UX, not load-bearing).

Why a direct GraphQL call instead of MCP: Linear's MCP v1 does not expose
a reactions tool (confirmed 2026-05-06). Once an MCP ``create_reaction``
tool ships, this module should be retired in favour of a prompt addendum
that has the agent call it directly.

See: ``agent/src/channel_mcp.py`` for the parallel MCP gate, and
``~/.claude/plans/linear-mcp-findings.md`` for the locked spec.
"""

from __future__ import annotations

import os
from typing import Any

import requests

from shell import log

#: Linear GraphQL endpoint. The same auth flow the MCP server uses.
LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql"

#: Request timeout — reactions are fire-and-forget status UX; never block
#: the task pipeline for more than a couple of seconds.
REQUEST_TIMEOUT_SECONDS = 5.0

#: Reactions in emoji short-code form (Linear accepts both emoji chars and
#: short codes; short codes are more portable in logs).
EMOJI_STARTED = "eyes"
EMOJI_SUCCESS = "white_check_mark"
EMOJI_FAILURE = "x"

_CREATE_MUTATION = """
mutation ReactIssue($issueId: String!, $emoji: String!) {
reactionCreate(input: { issueId: $issueId, emoji: $emoji }) {
success
reaction { id }
}
}
""".strip()

_DELETE_MUTATION = """
mutation UnreactIssue($id: String!) {
reactionDelete(id: $id) { success }
}
""".strip()


def _enabled(channel_source: str, channel_metadata: dict[str, str] | None) -> str | None:
"""Return the Linear issue id if reactions should fire, else None.

Gating mirrors ``channel_mcp.configure_channel_mcp`` — the same
``channel_source == 'linear'`` check, plus a metadata presence check so
we don't fire GraphQL calls we can't address.
"""
if channel_source != "linear":
return None
if not channel_metadata:
return None
return channel_metadata.get("linear_issue_id") or None


def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None:
"""POST a GraphQL query. Return parsed data on success, None on any failure.

Swallows network / auth / schema errors with a WARN log — reactions are
advisory and never gate the pipeline.
"""
token = os.environ.get("LINEAR_API_TOKEN", "")
if not token:
log("WARN", "linear_reactions: LINEAR_API_TOKEN not set; skipping reaction")
return None

try:
resp = requests.post(
LINEAR_GRAPHQL_URL,
json={"query": query, "variables": variables},
headers={
"Authorization": token,
"Content-Type": "application/json",
},
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as e:
log("WARN", f"linear_reactions: request failed ({type(e).__name__}): {e}")
return None

if resp.status_code != 200:
log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear")
return None

body = resp.json() if resp.content else {}
if body.get("errors"):
log("WARN", f"linear_reactions: GraphQL errors: {body['errors']}")
return None

return body.get("data") or {}


def react_task_started(
channel_source: str,
channel_metadata: dict[str, str] | None,
) -> str | None:
"""Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op)."""
issue_id = _enabled(channel_source, channel_metadata)
if not issue_id:
return None
data = _graphql(_CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_STARTED})
if not data:
return None
return (data.get("reactionCreate") or {}).get("reaction", {}).get("id")


def react_task_finished(
channel_source: str,
channel_metadata: dict[str, str] | None,
success: bool,
started_reaction_id: str | None = None,
) -> None:
"""Delete the 👀 (if we have its id) and post ✅/❌ as a replacement."""
issue_id = _enabled(channel_source, channel_metadata)
if not issue_id:
return
if started_reaction_id:
_graphql(_DELETE_MUTATION, {"id": started_reaction_id})
_graphql(
_CREATE_MUTATION,
{"issueId": issue_id, "emoji": EMOJI_SUCCESS if success else EMOJI_FAILURE},
)
5 changes: 5 additions & 0 deletions agent/src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ class TaskConfig(BaseModel):
branch_name: str = ""
pr_number: str = ""
task_id: str = ""
# Inbound channel the task was submitted from (mirrors ChannelSource in
# cdk/src/handlers/shared/types.ts). Gates channel-specific MCP wiring and
# prompt additions. Empty string means "no channel context" (legacy / local).
channel_source: str = ""
channel_metadata: dict[str, str] = Field(default_factory=dict)
# Platform user_id (Cognito ``sub``) threaded from the orchestrator
# payload. Required ONLY when ``trace`` is true — the agent writes
# the trajectory dump to ``traces/<user_id>/<task_id>.jsonl.gz``
Expand Down
Loading
Loading