Skip to content

Commit b640226

Browse files
authored
feat: adds linear tools (#499)
1 parent a819642 commit b640226

File tree

10 files changed

+387
-5
lines changed

10 files changed

+387
-5
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ dependencies = [
7070
"numpy>=2.2.2",
7171
"mcp[cli]",
7272
"neo4j",
73+
"modal>=0.73.45",
7374
]
7475

7576
license = { text = "Apache-2.0" }

src/codegen/extensions/events/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import logging
22

3-
import modal
3+
import modal # deptry: ignore
44

55
from codegen.extensions.events.linear import Linear
66

src/codegen/extensions/events/interface.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Protocol
22

3-
import modal
3+
import modal # deptry: ignore
44

55

66
class EventHandlerManagerProtocol(Protocol):

src/codegen/extensions/events/linear.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import os
44
from typing import Callable
55

6-
import modal
6+
import modal # deptry: ignore
77
from anthropic import BaseModel
88

99
from codegen.extensions.clients.linear import LinearClient

src/codegen/extensions/langchain/tools.py

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
from pydantic import BaseModel, Field
88

99
from codegen import Codebase
10+
from codegen.extensions.linear.linear_client import LinearClient
11+
from codegen.extensions.tools.linear_tools import (
12+
linear_comment_on_issue_tool,
13+
linear_get_issue_comments_tool,
14+
linear_get_issue_tool,
15+
)
1016

1117
from ..tools import (
1218
commit,
@@ -184,7 +190,10 @@ class RevealSymbolInput(BaseModel):
184190

185191
symbol_name: str = Field(..., description="Name of the symbol to analyze")
186192
degree: int = Field(default=1, description="How many degrees of separation to traverse")
187-
max_tokens: Optional[int] = Field(default=None, description="Optional maximum number of tokens for all source code combined")
193+
max_tokens: Optional[int] = Field(
194+
default=None,
195+
description="Optional maximum number of tokens for all source code combined",
196+
)
188197
collect_dependencies: bool = Field(default=True, description="Whether to collect dependencies")
189198
collect_usages: bool = Field(default=True, description="Whether to collect usages")
190199

@@ -281,7 +290,10 @@ class MoveSymbolInput(BaseModel):
281290
source_file: str = Field(..., description="Path to the file containing the symbol")
282291
symbol_name: str = Field(..., description="Name of the symbol to move")
283292
target_file: str = Field(..., description="Path to the destination file")
284-
strategy: Literal["update_all_imports", "add_back_edge"] = Field(default="update_all_imports", description="Strategy for handling imports: 'update_all_imports' (default) or 'add_back_edge'")
293+
strategy: Literal["update_all_imports", "add_back_edge"] = Field(
294+
default="update_all_imports",
295+
description="Strategy for handling imports: 'update_all_imports' (default) or 'add_back_edge'",
296+
)
285297
include_dependencies: bool = Field(default=True, description="Whether to move dependencies along with the symbol")
286298

287299

@@ -453,6 +465,73 @@ def _run(
453465
return json.dumps(result, indent=2)
454466

455467

468+
class LinearGetIssueInput(BaseModel):
469+
"""Input for getting a Linear issue."""
470+
471+
issue_id: str = Field(..., description="ID of the Linear issue to retrieve")
472+
473+
474+
class LinearGetIssueTool(BaseTool):
475+
"""Tool for getting Linear issue details."""
476+
477+
name: ClassVar[str] = "linear_get_issue"
478+
description: ClassVar[str] = "Get details of a Linear issue by its ID"
479+
args_schema: ClassVar[type[BaseModel]] = LinearGetIssueInput
480+
client: LinearClient = Field(exclude=True)
481+
482+
def __init__(self, client: LinearClient) -> None:
483+
super().__init__(client=client)
484+
485+
def _run(self, issue_id: str) -> str:
486+
result = linear_get_issue_tool(self.client, issue_id)
487+
return json.dumps(result, indent=2)
488+
489+
490+
class LinearGetIssueCommentsInput(BaseModel):
491+
"""Input for getting Linear issue comments."""
492+
493+
issue_id: str = Field(..., description="ID of the Linear issue to get comments for")
494+
495+
496+
class LinearGetIssueCommentsTool(BaseTool):
497+
"""Tool for getting Linear issue comments."""
498+
499+
name: ClassVar[str] = "linear_get_issue_comments"
500+
description: ClassVar[str] = "Get all comments on a Linear issue"
501+
args_schema: ClassVar[type[BaseModel]] = LinearGetIssueCommentsInput
502+
client: LinearClient = Field(exclude=True)
503+
504+
def __init__(self, client: LinearClient) -> None:
505+
super().__init__(client=client)
506+
507+
def _run(self, issue_id: str) -> str:
508+
result = linear_get_issue_comments_tool(self.client, issue_id)
509+
return json.dumps(result, indent=2)
510+
511+
512+
class LinearCommentOnIssueInput(BaseModel):
513+
"""Input for commenting on a Linear issue."""
514+
515+
issue_id: str = Field(..., description="ID of the Linear issue to comment on")
516+
body: str = Field(..., description="The comment text")
517+
518+
519+
class LinearCommentOnIssueTool(BaseTool):
520+
"""Tool for commenting on Linear issues."""
521+
522+
name: ClassVar[str] = "linear_comment_on_issue"
523+
description: ClassVar[str] = "Add a comment to a Linear issue"
524+
args_schema: ClassVar[type[BaseModel]] = LinearCommentOnIssueInput
525+
client: LinearClient = Field(exclude=True)
526+
527+
def __init__(self, client: LinearClient) -> None:
528+
super().__init__(client=client)
529+
530+
def _run(self, issue_id: str, body: str) -> str:
531+
result = linear_comment_on_issue_tool(self.client, issue_id, body)
532+
return json.dumps(result, indent=2)
533+
534+
456535
def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]:
457536
"""Get all workspace tools initialized with a codebase.
458537
@@ -479,4 +558,7 @@ def get_workspace_tools(codebase: Codebase) -> list["BaseTool"]:
479558
SemanticEditTool(codebase),
480559
SemanticSearchTool(codebase),
481560
ViewFileTool(codebase),
561+
LinearGetIssueTool(codebase),
562+
LinearGetIssueCommentsTool(codebase),
563+
LinearCommentOnIssueTool(codebase),
482564
]
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .linear_client import LinearClient
2+
3+
__all__ = ["LinearClient"]
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import json
2+
import logging
3+
import os
4+
from typing import Optional
5+
6+
import requests
7+
from pydantic import BaseModel
8+
9+
logger = logging.getLogger(__name__)
10+
11+
12+
# --- TYPES
13+
14+
15+
class LinearUser(BaseModel):
16+
id: str
17+
name: str
18+
19+
20+
class LinearComment(BaseModel):
21+
id: str
22+
body: str
23+
user: LinearUser | None = None
24+
25+
26+
class LinearIssue(BaseModel):
27+
id: str
28+
title: str
29+
description: str | None = None
30+
31+
32+
class LinearClient:
33+
api_headers: dict
34+
api_endpoint = "https://api.linear.app/graphql"
35+
36+
def __init__(self, access_token: Optional[str] = None):
37+
if not access_token:
38+
access_token = os.getenv("LINEAR_ACCESS_TOKEN")
39+
if not access_token:
40+
msg = "access_token is required"
41+
raise ValueError(msg)
42+
self.access_token = access_token
43+
self.api_headers = {
44+
"Content-Type": "application/json",
45+
"Authorization": self.access_token,
46+
}
47+
48+
def get_issue(self, issue_id: str) -> LinearIssue:
49+
query = """
50+
query getIssue($issueId: String!) {
51+
issue(id: $issueId) {
52+
id
53+
title
54+
description
55+
}
56+
}
57+
"""
58+
variables = {"issueId": issue_id}
59+
response = requests.post(self.api_endpoint, headers=self.api_headers, json={"query": query, "variables": variables})
60+
data = response.json()
61+
issue_data = data["data"]["issue"]
62+
return LinearIssue(id=issue_data["id"], title=issue_data["title"], description=issue_data["description"])
63+
64+
def get_issue_comments(self, issue_id: str) -> list[LinearComment]:
65+
query = """
66+
query getIssueComments($issueId: String!) {
67+
issue(id: $issueId) {
68+
comments {
69+
nodes {
70+
id
71+
body
72+
user {
73+
id
74+
name
75+
}
76+
}
77+
78+
}
79+
}
80+
}
81+
"""
82+
variables = {"issueId": issue_id}
83+
response = requests.post(self.api_endpoint, headers=self.api_headers, json={"query": query, "variables": variables})
84+
data = response.json()
85+
comments = data["data"]["issue"]["comments"]["nodes"]
86+
87+
# Parse comments into list of LinearComment objects
88+
parsed_comments = []
89+
for comment in comments:
90+
user = comment.get("user", None)
91+
parsed_comment = LinearComment(id=comment["id"], body=comment["body"], user=LinearUser(id=user.get("id"), name=user.get("name")) if user else None)
92+
parsed_comments.append(parsed_comment)
93+
94+
# Convert raw comments to LinearComment objects
95+
return parsed_comments
96+
97+
def comment_on_issue(self, issue_id: str, body: str) -> dict:
98+
"""issue_id is our internal issue ID"""
99+
query = """mutation makeComment($issueId: String!, $body: String!) {
100+
commentCreate(input: {issueId: $issueId, body: $body}) {
101+
comment {
102+
id
103+
body
104+
url
105+
user {
106+
id
107+
name
108+
}
109+
}
110+
}
111+
}
112+
"""
113+
variables = {"issueId": issue_id, "body": body}
114+
response = requests.post(
115+
self.api_endpoint,
116+
headers=self.api_headers,
117+
data=json.dumps({"query": query, "variables": variables}),
118+
)
119+
data = response.json()
120+
try:
121+
comment_data = data["data"]["commentCreate"]["comment"]
122+
123+
return comment_data
124+
except Exception as e:
125+
msg = f"Error creating comment\n{data}\n{e}"
126+
raise Exception(msg)
127+
128+
def register_webhook(self, webhook_url: str, team_id: str, secret: str, enabled: bool, resource_types: list[str]):
129+
mutation = """
130+
mutation createWebhook($input: WebhookCreateInput!) {
131+
webhookCreate(input: $input) {
132+
success
133+
webhook {
134+
id
135+
enabled
136+
}
137+
}
138+
}
139+
"""
140+
141+
variables = {
142+
"input": {
143+
"url": webhook_url,
144+
"teamId": team_id,
145+
"resourceTypes": resource_types,
146+
"enabled": enabled,
147+
"secret": secret,
148+
}
149+
}
150+
151+
response = requests.post(self.api_endpoint, headers=self.api_headers, json={"query": mutation, "variables": variables})
152+
body = response.json()
153+
return body
154+
155+
def search_issues(self, query: str, limit: int = 10) -> list[LinearIssue]:
156+
"""Search for issues using a query string.
157+
158+
Args:
159+
query: Search query string
160+
limit: Maximum number of issues to return (default: 10)
161+
162+
Returns:
163+
List of LinearIssue objects matching the search query
164+
"""
165+
graphql_query = """
166+
query searchIssues($query: String!, $limit: Int!) {
167+
issueSearch(query: $query, first: $limit) {
168+
nodes {
169+
id
170+
title
171+
description
172+
}
173+
}
174+
}
175+
"""
176+
variables = {"query": query, "limit": limit}
177+
response = requests.post(
178+
self.api_endpoint,
179+
headers=self.api_headers,
180+
json={"query": graphql_query, "variables": variables},
181+
)
182+
data = response.json()
183+
184+
try:
185+
issues_data = data["data"]["issueSearch"]["nodes"]
186+
return [
187+
LinearIssue(
188+
id=issue["id"],
189+
title=issue["title"],
190+
description=issue["description"],
191+
)
192+
for issue in issues_data
193+
]
194+
except Exception as e:
195+
msg = f"Error searching issues\n{data}\n{e}"
196+
raise Exception(msg)

src/codegen/extensions/tools/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
from .github.create_pr_comment import create_pr_comment
99
from .github.create_pr_review_comment import create_pr_review_comment
1010
from .github.view_pr import view_pr
11+
from .linear_tools import (
12+
linear_comment_on_issue_tool,
13+
linear_get_issue_comments_tool,
14+
linear_get_issue_tool,
15+
linear_register_webhook_tool,
16+
)
1117
from .list_directory import list_directory
1218
from .move_symbol import move_symbol
1319
from .rename_file import rename_file
@@ -27,6 +33,11 @@
2733
"create_pr_review_comment",
2834
"delete_file",
2935
"edit_file",
36+
# Linear operations
37+
"linear_comment_on_issue_tool",
38+
"linear_get_issue_comments_tool",
39+
"linear_get_issue_tool",
40+
"linear_register_webhook_tool",
3041
"list_directory",
3142
# Symbol operations
3243
"move_symbol",

0 commit comments

Comments
 (0)