Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 8 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
OPENAI_API_KEY=sk-your-key
GITHUB_TOKEN=ghp-your-token
GITHUB_REPOSITORIES=evalops/platform,evalops/deploy,evalops/maestro-internal,haasonsaas/homelab
LINEAR_API_KEY=lin_api_your-key
LINEAR_TEAM_IDS=TEAM_ID_1
SENTRY_AUTH_TOKEN=sntrys_your-token
SENTRY_ORG_SLUG=evalops-inc
SENTRY_BASE_URL=https://sentry.io/api/0
JIRA_BASE_URL=https://your-domain.atlassian.net
JIRA_API_TOKEN=your-jira-api-token
JIRA_EMAIL=you@your-domain.com
DRY_RUN=true
APPROVAL_REQUIRED=true
VECTOR_STORE_PATH=./data/vector_store.json
TRACE_DIR=./data/traces
SLACK_BOT_TOKEN=xoxb-your-token
SLACK_BOT_TOKEN=***
SLACK_STATUS_CHANNEL=#status-updates
CALENDAR_ID=primary
GOOGLE_SERVICE_ACCOUNT_FILE=./config/google-service-account.json
Expand All @@ -30,4 +36,4 @@ DATABASE_ECHO=false
REDIS_URL=redis://localhost:6379
ENABLE_OPENTELEMETRY=false
OTEL_SERVICE_NAME=agent-pm
OTEL_EXPORTER_ENDPOINT=http://localhost:4317
OTEL_EXPORTER_ENDPOINT=http://localhost:4317
6 changes: 6 additions & 0 deletions agent_pm/connectors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
from .email import EmailConnector
from .github import GitHubConnector
from .google_drive import GoogleDriveConnector
from .linear import LinearConnector, linear_connector
from .notion import NotionConnector
from .sentry import SentryConnector, sentry_connector
from .slack import SlackConnector

__all__ = [
Expand All @@ -14,6 +16,10 @@
"EmailConnector",
"GitHubConnector",
"GoogleDriveConnector",
"LinearConnector",
"linear_connector",
"NotionConnector",
"SentryConnector",
"sentry_connector",
"SlackConnector",
]
214 changes: 214 additions & 0 deletions agent_pm/connectors/linear.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""Linear connector — GraphQL API for issue tracking and project management."""

from __future__ import annotations

from datetime import datetime
from typing import Any

import httpx

from agent_pm.connectors.base import Connector
from agent_pm.settings import settings

LINEAR_API_URL = "https://api.linear.app/graphql"


class LinearConnector(Connector):
def __init__(self) -> None:
super().__init__(name="linear")
self._api_key = settings.linear_api_key
self._team_ids = settings.linear_team_ids

@property
def enabled(self) -> bool:
return bool(self._api_key)

def _headers(self) -> dict[str, str]:
return {
"Authorization": self._api_key or "",
"Content-Type": "application/json",
}

async def _graphql(self, query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
if settings.dry_run or not self.enabled:
return {"dry_run": True, "query": query[:200], "variables": variables}

async with httpx.AsyncClient() as client:
resp = await client.post(
LINEAR_API_URL,
headers=self._headers(),
json={"query": query, "variables": variables or {}},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
if "errors" in data:
raise RuntimeError(f"Linear GraphQL errors: {data['errors']}")
return data.get("data", data)

# ── read operations ──────────────────────────────────────────

async def list_issues(
self,
*,
assignee_id: str | None = None,
assignee_email: str | None = None,
team_id: str | None = None,
state: str | None = None,
order_by: str = "updatedAt",
limit: int | None = 50,
) -> list[dict[str, Any]]:
filters: dict[str, Any] = {}
if assignee_email:
filters["assignee"] = {"email": {"eq": assignee_email}}
elif assignee_id:
filters["assignee"] = {"id": {"eq": assignee_id}}
if team_id:
filters["team"] = {"id": {"eq": team_id}}
if state:
filters["state"] = {"name": {"eq": state}}

query = """
query($filter: IssueFilter, $orderBy: PaginationOrderBy, $first: Int!, $after: String) {
issues(filter: $filter, orderBy: $orderBy, first: $first, after: $after) {
nodes {
id identifier title description state { name } priority
assignee { id name email } team { id name key }
dueDate createdAt updatedAt
labels { nodes { name } }
parent { id identifier }
}
pageInfo { hasNextPage endCursor }
}
}
"""
issues: list[dict[str, Any]] = []
after: str | None = None
remaining = limit
page_size = 50 if limit is None else limit

while page_size > 0:
result = await self._graphql(
query,
{
"filter": filters if filters else None,
"orderBy": order_by,
"first": page_size,
"after": after,
},
)
connection = result.get("issues", {})
nodes = connection.get("nodes", [])
issues.extend(nodes)

if remaining is not None:
remaining -= len(nodes)
if remaining <= 0:
break
page_size = remaining

page_info = connection.get("pageInfo", {})
if not page_info.get("hasNextPage"):
break
after = page_info.get("endCursor")
if not after:
break

return issues

async def list_teams(self) -> list[dict[str, Any]]:
query = """
query {
teams { nodes { id name key } }
}
"""
result = await self._graphql(query)
return result.get("teams", {}).get("nodes", [])

async def get_issue_comments(self, issue_id: str, limit: int = 20) -> list[dict[str, Any]]:
query = """
query($issueId: String!, $first: Int!) {
issue(id: $issueId) {
comments(first: $first) { nodes { id body createdAt user { name } } }
}
}
"""
result = await self._graphql(query, {"issueId": issue_id, "first": limit})
return result.get("issue", {}).get("comments", {}).get("nodes", [])

# ── write operations ─────────────────────────────────────────

async def create_issue(
self,
*,
team_id: str,
title: str,
description: str = "",
assignee_id: str | None = None,
priority: int | None = None,
due_date: str | None = None,
) -> dict[str, Any]:
create_input: dict[str, Any] = {
"teamId": team_id,
"title": title,
"description": description,
}
if assignee_id:
create_input["assigneeId"] = assignee_id
if priority is not None:
create_input["priority"] = priority
if due_date:
create_input["dueDate"] = due_date

query = """
mutation($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue { id identifier title url }
}
}
"""
result = await self._graphql(query, {"input": create_input})
return result.get("issueCreate", {})

async def update_issue(self, issue_id: str, **fields: Any) -> dict[str, Any]:
query = """
mutation($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue { id identifier title }
}
}
"""
result = await self._graphql(query, {"id": issue_id, "input": fields})
return result.get("issueUpdate", {})

async def add_comment(self, issue_id: str, body: str) -> dict[str, Any]:
query = """
mutation($issueId: String!, $body: String!) {
commentCreate(input: {issueId: $issueId, body: $body}) {
success
comment { id body }
}
}
"""
result = await self._graphql(query, {"issueId": issue_id, "body": body})
return result.get("commentCreate", {})

# ── connector protocol ───────────────────────────────────────

async def sync(self, *, since: datetime | None = None) -> list[dict[str, Any]]:
payloads: list[dict[str, Any]] = []
team_ids = self._team_ids
if not team_ids:
teams = await self.list_teams()
team_ids = [t["id"] for t in teams]
for tid in team_ids:
issues = await self.list_issues(team_id=tid, limit=50)
payloads.append({"team_id": tid, "issues": issues})
return payloads


linear_connector = LinearConnector()

__all__ = ["LinearConnector", "linear_connector"]
Loading
Loading