Skip to content

Commit ab94844

Browse files
Michael McNeesclaude
authored andcommitted
feat: GitHub and Linear connectors (#85, #101)
github/create_issue — POST /repos/{owner}/{repo}/issues with title, body, labels, and assignees. Returns number, url, node_id, state. github/dispatch — POST /repos/{owner}/{repo}/dispatches for repository_dispatch events with optional client_payload. linear/create_issue — GraphQL IssueCreate mutation with team_id, title, description, assignee_id, project_id, priority, and label_ids. Returns id, identifier, url, title. linear/search — GraphQL Issues query with optional title/team/assignee/state filters and configurable limit (default 25). Returns issues array and count. All four connectors follow the _credential/token pattern, delete the credential key from params after extraction, use httptest servers in tests, and are registered in NewRegistry. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 1bd8cbf commit ab94844

5 files changed

Lines changed: 999 additions & 0 deletions

File tree

packages/engine/internal/connector/connector.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ func NewRegistry() *Registry {
4040
r.Register("s3/list", &S3ListConnector{})
4141
r.Register("docker/run", &DockerRunConnector{})
4242
r.Register("browser/run", &BrowserRunConnector{})
43+
r.Register("github/create_issue", &GitHubCreateIssueConnector{})
44+
r.Register("github/dispatch", &GitHubDispatchConnector{})
45+
r.Register("linear/create_issue", &LinearCreateIssueConnector{})
46+
r.Register("linear/search", &LinearSearchConnector{})
4347
return r
4448
}
4549

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
package connector
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"time"
11+
)
12+
13+
const githubBaseURL = "https://api.github.com"
14+
15+
// GitHubCreateIssueConnector creates an issue in a GitHub repository.
16+
type GitHubCreateIssueConnector struct {
17+
Client *http.Client
18+
baseURL string // override for testing
19+
}
20+
21+
func (c *GitHubCreateIssueConnector) apiURL(path string) string {
22+
base := c.baseURL
23+
if base == "" {
24+
base = githubBaseURL
25+
}
26+
return base + path
27+
}
28+
29+
func (c *GitHubCreateIssueConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) {
30+
token, err := extractGitHubToken(params)
31+
if err != nil {
32+
return nil, fmt.Errorf("github/create_issue: %w", err)
33+
}
34+
35+
owner, _ := params["owner"].(string)
36+
if owner == "" {
37+
return nil, fmt.Errorf("github/create_issue: owner is required")
38+
}
39+
repo, _ := params["repo"].(string)
40+
if repo == "" {
41+
return nil, fmt.Errorf("github/create_issue: repo is required")
42+
}
43+
title, _ := params["title"].(string)
44+
if title == "" {
45+
return nil, fmt.Errorf("github/create_issue: title is required")
46+
}
47+
48+
body := map[string]any{"title": title}
49+
if b, ok := params["body"].(string); ok && b != "" {
50+
body["body"] = b
51+
}
52+
if labels := toStringSlice(params["labels"]); len(labels) > 0 {
53+
body["labels"] = labels
54+
}
55+
if assignees := toStringSlice(params["assignees"]); len(assignees) > 0 {
56+
body["assignees"] = assignees
57+
}
58+
59+
reqJSON, err := json.Marshal(body)
60+
if err != nil {
61+
return nil, fmt.Errorf("github/create_issue: marshaling request: %w", err)
62+
}
63+
64+
path := fmt.Sprintf("/repos/%s/%s/issues", owner, repo)
65+
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON))
66+
if err != nil {
67+
return nil, fmt.Errorf("github/create_issue: creating request: %w", err)
68+
}
69+
req.Header.Set("Accept", "application/vnd.github+json")
70+
req.Header.Set("Authorization", "Bearer "+token)
71+
req.Header.Set("Content-Type", "application/json")
72+
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
73+
74+
resp, err := httpClient(c.Client).Do(req)
75+
if err != nil {
76+
return nil, fmt.Errorf("github/create_issue: %w", err)
77+
}
78+
defer resp.Body.Close()
79+
80+
respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes))
81+
if err != nil {
82+
return nil, fmt.Errorf("github/create_issue: reading response: %w", err)
83+
}
84+
85+
if resp.StatusCode != http.StatusCreated {
86+
return nil, fmt.Errorf("github/create_issue: GitHub API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500))
87+
}
88+
89+
var issue struct {
90+
Number int `json:"number"`
91+
HTMLURL string `json:"html_url"`
92+
NodeID string `json:"node_id"`
93+
State string `json:"state"`
94+
Title string `json:"title"`
95+
}
96+
if err := json.Unmarshal(respBody, &issue); err != nil {
97+
return nil, fmt.Errorf("github/create_issue: parsing response: %w", err)
98+
}
99+
100+
return map[string]any{
101+
"number": issue.Number,
102+
"url": issue.HTMLURL,
103+
"node_id": issue.NodeID,
104+
"state": issue.State,
105+
"title": issue.Title,
106+
}, nil
107+
}
108+
109+
// GitHubDispatchConnector triggers a repository_dispatch event on a GitHub repository.
110+
type GitHubDispatchConnector struct {
111+
Client *http.Client
112+
baseURL string // override for testing
113+
}
114+
115+
func (c *GitHubDispatchConnector) apiURL(path string) string {
116+
base := c.baseURL
117+
if base == "" {
118+
base = githubBaseURL
119+
}
120+
return base + path
121+
}
122+
123+
func (c *GitHubDispatchConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) {
124+
token, err := extractGitHubToken(params)
125+
if err != nil {
126+
return nil, fmt.Errorf("github/dispatch: %w", err)
127+
}
128+
129+
owner, _ := params["owner"].(string)
130+
if owner == "" {
131+
return nil, fmt.Errorf("github/dispatch: owner is required")
132+
}
133+
repo, _ := params["repo"].(string)
134+
if repo == "" {
135+
return nil, fmt.Errorf("github/dispatch: repo is required")
136+
}
137+
eventType, _ := params["event_type"].(string)
138+
if eventType == "" {
139+
return nil, fmt.Errorf("github/dispatch: event_type is required")
140+
}
141+
142+
body := map[string]any{"event_type": eventType}
143+
if payload, ok := params["client_payload"].(map[string]any); ok {
144+
body["client_payload"] = payload
145+
}
146+
147+
reqJSON, err := json.Marshal(body)
148+
if err != nil {
149+
return nil, fmt.Errorf("github/dispatch: marshaling request: %w", err)
150+
}
151+
152+
path := fmt.Sprintf("/repos/%s/%s/dispatches", owner, repo)
153+
req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON))
154+
if err != nil {
155+
return nil, fmt.Errorf("github/dispatch: creating request: %w", err)
156+
}
157+
req.Header.Set("Accept", "application/vnd.github+json")
158+
req.Header.Set("Authorization", "Bearer "+token)
159+
req.Header.Set("Content-Type", "application/json")
160+
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
161+
162+
resp, err := httpClient(c.Client).Do(req)
163+
if err != nil {
164+
return nil, fmt.Errorf("github/dispatch: %w", err)
165+
}
166+
defer resp.Body.Close()
167+
168+
// GitHub returns 204 No Content on success.
169+
if resp.StatusCode != http.StatusNoContent {
170+
body, _ := io.ReadAll(io.LimitReader(resp.Body, 500))
171+
return nil, fmt.Errorf("github/dispatch: GitHub API returned %d: %s", resp.StatusCode, truncate(string(body), 500))
172+
}
173+
174+
return map[string]any{"ok": true}, nil
175+
}
176+
177+
// extractGitHubToken pulls the GitHub token from _credential.
178+
func extractGitHubToken(params map[string]any) (string, error) {
179+
cred, ok := params["_credential"].(map[string]string)
180+
if !ok {
181+
return "", fmt.Errorf("credential is required")
182+
}
183+
delete(params, "_credential")
184+
185+
token := cred["token"]
186+
if token == "" {
187+
return "", fmt.Errorf("credential must contain a 'token' field")
188+
}
189+
return token, nil
190+
}
191+
192+
// httpClient returns the provided client or a default with a 30s timeout.
193+
func httpClient(c *http.Client) *http.Client {
194+
if c != nil {
195+
return c
196+
}
197+
return &http.Client{Timeout: 30 * time.Second}
198+
}
199+
200+
// toStringSlice converts a []any or []string param value to []string.
201+
func toStringSlice(v any) []string {
202+
switch val := v.(type) {
203+
case []string:
204+
return val
205+
case []any:
206+
out := make([]string, 0, len(val))
207+
for _, item := range val {
208+
if s, ok := item.(string); ok {
209+
out = append(out, s)
210+
}
211+
}
212+
return out
213+
}
214+
return nil
215+
}

0 commit comments

Comments
 (0)