From ab9484457b95af7c0c5bfe506b946c0eba88bf12 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 30 May 2026 00:21:44 -0400 Subject: [PATCH 1/2] feat: GitHub and Linear connectors (#85, #101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../engine/internal/connector/connector.go | 4 + packages/engine/internal/connector/github.go | 215 ++++++++++++ .../engine/internal/connector/github_test.go | 212 ++++++++++++ packages/engine/internal/connector/linear.go | 247 ++++++++++++++ .../engine/internal/connector/linear_test.go | 321 ++++++++++++++++++ 5 files changed, 999 insertions(+) create mode 100644 packages/engine/internal/connector/github.go create mode 100644 packages/engine/internal/connector/github_test.go create mode 100644 packages/engine/internal/connector/linear.go create mode 100644 packages/engine/internal/connector/linear_test.go diff --git a/packages/engine/internal/connector/connector.go b/packages/engine/internal/connector/connector.go index 52a8d839..3f59e863 100644 --- a/packages/engine/internal/connector/connector.go +++ b/packages/engine/internal/connector/connector.go @@ -40,6 +40,10 @@ func NewRegistry() *Registry { r.Register("s3/list", &S3ListConnector{}) r.Register("docker/run", &DockerRunConnector{}) r.Register("browser/run", &BrowserRunConnector{}) + r.Register("github/create_issue", &GitHubCreateIssueConnector{}) + r.Register("github/dispatch", &GitHubDispatchConnector{}) + r.Register("linear/create_issue", &LinearCreateIssueConnector{}) + r.Register("linear/search", &LinearSearchConnector{}) return r } diff --git a/packages/engine/internal/connector/github.go b/packages/engine/internal/connector/github.go new file mode 100644 index 00000000..446afc26 --- /dev/null +++ b/packages/engine/internal/connector/github.go @@ -0,0 +1,215 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const githubBaseURL = "https://api.github.com" + +// GitHubCreateIssueConnector creates an issue in a GitHub repository. +type GitHubCreateIssueConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *GitHubCreateIssueConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = githubBaseURL + } + return base + path +} + +func (c *GitHubCreateIssueConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractGitHubToken(params) + if err != nil { + return nil, fmt.Errorf("github/create_issue: %w", err) + } + + owner, _ := params["owner"].(string) + if owner == "" { + return nil, fmt.Errorf("github/create_issue: owner is required") + } + repo, _ := params["repo"].(string) + if repo == "" { + return nil, fmt.Errorf("github/create_issue: repo is required") + } + title, _ := params["title"].(string) + if title == "" { + return nil, fmt.Errorf("github/create_issue: title is required") + } + + body := map[string]any{"title": title} + if b, ok := params["body"].(string); ok && b != "" { + body["body"] = b + } + if labels := toStringSlice(params["labels"]); len(labels) > 0 { + body["labels"] = labels + } + if assignees := toStringSlice(params["assignees"]); len(assignees) > 0 { + body["assignees"] = assignees + } + + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("github/create_issue: marshaling request: %w", err) + } + + path := fmt.Sprintf("/repos/%s/%s/issues", owner, repo) + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("github/create_issue: creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("github/create_issue: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("github/create_issue: reading response: %w", err) + } + + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("github/create_issue: GitHub API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var issue struct { + Number int `json:"number"` + HTMLURL string `json:"html_url"` + NodeID string `json:"node_id"` + State string `json:"state"` + Title string `json:"title"` + } + if err := json.Unmarshal(respBody, &issue); err != nil { + return nil, fmt.Errorf("github/create_issue: parsing response: %w", err) + } + + return map[string]any{ + "number": issue.Number, + "url": issue.HTMLURL, + "node_id": issue.NodeID, + "state": issue.State, + "title": issue.Title, + }, nil +} + +// GitHubDispatchConnector triggers a repository_dispatch event on a GitHub repository. +type GitHubDispatchConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *GitHubDispatchConnector) apiURL(path string) string { + base := c.baseURL + if base == "" { + base = githubBaseURL + } + return base + path +} + +func (c *GitHubDispatchConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractGitHubToken(params) + if err != nil { + return nil, fmt.Errorf("github/dispatch: %w", err) + } + + owner, _ := params["owner"].(string) + if owner == "" { + return nil, fmt.Errorf("github/dispatch: owner is required") + } + repo, _ := params["repo"].(string) + if repo == "" { + return nil, fmt.Errorf("github/dispatch: repo is required") + } + eventType, _ := params["event_type"].(string) + if eventType == "" { + return nil, fmt.Errorf("github/dispatch: event_type is required") + } + + body := map[string]any{"event_type": eventType} + if payload, ok := params["client_payload"].(map[string]any); ok { + body["client_payload"] = payload + } + + reqJSON, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("github/dispatch: marshaling request: %w", err) + } + + path := fmt.Sprintf("/repos/%s/%s/dispatches", owner, repo) + req, err := http.NewRequestWithContext(ctx, "POST", c.apiURL(path), bytes.NewReader(reqJSON)) + if err != nil { + return nil, fmt.Errorf("github/dispatch: creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := httpClient(c.Client).Do(req) + if err != nil { + return nil, fmt.Errorf("github/dispatch: %w", err) + } + defer resp.Body.Close() + + // GitHub returns 204 No Content on success. + if resp.StatusCode != http.StatusNoContent { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) + return nil, fmt.Errorf("github/dispatch: GitHub API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + } + + return map[string]any{"ok": true}, nil +} + +// extractGitHubToken pulls the GitHub token from _credential. +func extractGitHubToken(params map[string]any) (string, error) { + cred, ok := params["_credential"].(map[string]string) + if !ok { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + token := cred["token"] + if token == "" { + return "", fmt.Errorf("credential must contain a 'token' field") + } + return token, nil +} + +// httpClient returns the provided client or a default with a 30s timeout. +func httpClient(c *http.Client) *http.Client { + if c != nil { + return c + } + return &http.Client{Timeout: 30 * time.Second} +} + +// toStringSlice converts a []any or []string param value to []string. +func toStringSlice(v any) []string { + switch val := v.(type) { + case []string: + return val + case []any: + out := make([]string, 0, len(val)) + for _, item := range val { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} diff --git a/packages/engine/internal/connector/github_test.go b/packages/engine/internal/connector/github_test.go new file mode 100644 index 00000000..03c1516e --- /dev/null +++ b/packages/engine/internal/connector/github_test.go @@ -0,0 +1,212 @@ +package connector + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestGitHubCreateIssueConnector_CreatesIssue(t *testing.T) { + var gotAuth, gotVersion string + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotVersion = r.Header.Get("X-GitHub-Api-Version") + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "number": 42, + "html_url": "https://github.com/acme/repo/issues/42", + "node_id": "I_abc123", + "state": "open", + "title": "Bug: something is broken", + }) + })) + defer server.Close() + + c := &GitHubCreateIssueConnector{baseURL: server.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "title": "Bug: something is broken", + "body": "Details here.", + "labels": []any{"bug", "urgent"}, + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if gotAuth != "Bearer ghp_test" { + t.Errorf("Authorization = %q, want %q", gotAuth, "Bearer ghp_test") + } + if gotVersion != "2022-11-28" { + t.Errorf("X-GitHub-Api-Version = %q, want %q", gotVersion, "2022-11-28") + } + if gotBody["title"] != "Bug: something is broken" { + t.Errorf("body title = %q", gotBody["title"]) + } + if out["number"] != 42 { + t.Errorf("number = %v, want 42", out["number"]) + } + if out["url"] != "https://github.com/acme/repo/issues/42" { + t.Errorf("url = %q", out["url"]) + } + if out["state"] != "open" { + t.Errorf("state = %q, want open", out["state"]) + } +} + +func TestGitHubCreateIssueConnector_MissingTitle(t *testing.T) { + c := &GitHubCreateIssueConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err == nil { + t.Fatal("expected error for missing title") + } +} + +func TestGitHubCreateIssueConnector_MissingCredential(t *testing.T) { + c := &GitHubCreateIssueConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "title": "Test", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestGitHubCreateIssueConnector_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + w.Write([]byte(`{"message":"Validation Failed"}`)) + })) + defer server.Close() + + c := &GitHubCreateIssueConnector{baseURL: server.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "title": "Test", + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err == nil { + t.Fatal("expected error for API error response") + } +} + +func TestGitHubCreateIssueConnector_CredentialDeleted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]any{ + "number": 1, "html_url": "https://github.com/a/b/issues/1", + "node_id": "I_1", "state": "open", "title": "T", + }) + })) + defer server.Close() + + params := map[string]any{ + "owner": "acme", + "repo": "repo", + "title": "T", + "_credential": map[string]string{"token": "ghp_test"}, + } + c := &GitHubCreateIssueConnector{baseURL: server.URL} + if _, err := c.Execute(context.Background(), params); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if _, exists := params["_credential"]; exists { + t.Error("_credential should be deleted from params after execution") + } +} + +func TestGitHubDispatchConnector_DispatchesEvent(t *testing.T) { + var gotBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &gotBody) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := &GitHubDispatchConnector{baseURL: server.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "event_type": "deploy", + "client_payload": map[string]any{"env": "production"}, + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if gotBody["event_type"] != "deploy" { + t.Errorf("event_type = %q, want deploy", gotBody["event_type"]) + } + if payload, ok := gotBody["client_payload"].(map[string]any); !ok || payload["env"] != "production" { + t.Errorf("client_payload = %v", gotBody["client_payload"]) + } + if out["ok"] != true { + t.Errorf("ok = %v, want true", out["ok"]) + } +} + +func TestGitHubDispatchConnector_MissingEventType(t *testing.T) { + c := &GitHubDispatchConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "repo", + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err == nil { + t.Fatal("expected error for missing event_type") + } +} + +func TestGitHubDispatchConnector_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Not Found"}`)) + })) + defer server.Close() + + c := &GitHubDispatchConnector{baseURL: server.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "owner": "acme", + "repo": "missing-repo", + "event_type": "deploy", + "_credential": map[string]string{"token": "ghp_test"}, + }) + if err == nil { + t.Fatal("expected error for 404 response") + } +} + +func TestRegistry_GitHubConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"github/create_issue", "github/dispatch"} { + if _, err := r.Get(action); err != nil { + t.Errorf("Get(%q) error: %v", action, err) + } + } +} diff --git a/packages/engine/internal/connector/linear.go b/packages/engine/internal/connector/linear.go new file mode 100644 index 00000000..3075d85f --- /dev/null +++ b/packages/engine/internal/connector/linear.go @@ -0,0 +1,247 @@ +package connector + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +const linearGraphQLURL = "https://api.linear.app/graphql" + +// LinearCreateIssueConnector creates an issue in a Linear team via the GraphQL API. +type LinearCreateIssueConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *LinearCreateIssueConnector) gqlURL() string { + if c.baseURL != "" { + return c.baseURL + } + return linearGraphQLURL +} + +func (c *LinearCreateIssueConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractLinearToken(params) + if err != nil { + return nil, fmt.Errorf("linear/create_issue: %w", err) + } + + teamID, _ := params["team_id"].(string) + if teamID == "" { + return nil, fmt.Errorf("linear/create_issue: team_id is required") + } + title, _ := params["title"].(string) + if title == "" { + return nil, fmt.Errorf("linear/create_issue: title is required") + } + + input := map[string]any{ + "teamId": teamID, + "title": title, + } + if desc, ok := params["description"].(string); ok && desc != "" { + input["description"] = desc + } + if assigneeID, ok := params["assignee_id"].(string); ok && assigneeID != "" { + input["assigneeId"] = assigneeID + } + if projectID, ok := params["project_id"].(string); ok && projectID != "" { + input["projectId"] = projectID + } + if priority, ok := extractIntParam(params["priority"]); ok { + input["priority"] = priority + } + if labelIDs := toStringSlice(params["label_ids"]); len(labelIDs) > 0 { + input["labelIds"] = labelIDs + } + + const mutation = ` +mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { + id + identifier + title + url + } + } +}` + + result, err := linearGQL(ctx, httpClient(c.Client), c.gqlURL(), token, mutation, map[string]any{"input": input}) + if err != nil { + return nil, fmt.Errorf("linear/create_issue: %w", err) + } + + created, ok := result["issueCreate"].(map[string]any) + if !ok { + return nil, fmt.Errorf("linear/create_issue: unexpected response shape") + } + if success, _ := created["success"].(bool); !success { + return nil, fmt.Errorf("linear/create_issue: API returned success=false") + } + issue, ok := created["issue"].(map[string]any) + if !ok { + return nil, fmt.Errorf("linear/create_issue: missing issue in response") + } + + return map[string]any{ + "id": issue["id"], + "identifier": issue["identifier"], + "title": issue["title"], + "url": issue["url"], + }, nil +} + +// LinearSearchConnector searches issues in Linear via the GraphQL API. +type LinearSearchConnector struct { + Client *http.Client + baseURL string // override for testing +} + +func (c *LinearSearchConnector) gqlURL() string { + if c.baseURL != "" { + return c.baseURL + } + return linearGraphQLURL +} + +func (c *LinearSearchConnector) Execute(ctx context.Context, params map[string]any) (map[string]any, error) { + token, err := extractLinearToken(params) + if err != nil { + return nil, fmt.Errorf("linear/search: %w", err) + } + + limit := 25 + if l, ok := extractIntParam(params["limit"]); ok && l > 0 { + limit = l + } + + filter := map[string]any{} + if query, ok := params["query"].(string); ok && query != "" { + filter["title"] = map[string]any{"containsIgnoreCase": query} + } + if teamID, ok := params["team_id"].(string); ok && teamID != "" { + filter["team"] = map[string]any{"id": map[string]any{"eq": teamID}} + } + if assigneeID, ok := params["assignee_id"].(string); ok && assigneeID != "" { + filter["assignee"] = map[string]any{"id": map[string]any{"eq": assigneeID}} + } + if state, ok := params["state"].(string); ok && state != "" { + filter["state"] = map[string]any{"name": map[string]any{"eq": state}} + } + + const query = ` +query Issues($filter: IssueFilter, $first: Int) { + issues(filter: $filter, first: $first) { + nodes { + id + identifier + title + url + priority + state { name } + assignee { name email } + } + } +}` + + vars := map[string]any{"first": limit} + if len(filter) > 0 { + vars["filter"] = filter + } + + result, err := linearGQL(ctx, httpClient(c.Client), c.gqlURL(), token, query, vars) + if err != nil { + return nil, fmt.Errorf("linear/search: %w", err) + } + + issuesData, ok := result["issues"].(map[string]any) + if !ok { + return nil, fmt.Errorf("linear/search: unexpected response shape") + } + nodes, _ := issuesData["nodes"].([]any) + + return map[string]any{ + "issues": nodes, + "count": len(nodes), + }, nil +} + +// linearGQL executes a GraphQL request against the Linear API. +func linearGQL(ctx context.Context, client *http.Client, url, token, query string, variables map[string]any) (map[string]any, error) { + payload := map[string]any{ + "query": query, + "variables": variables, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshaling request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", token) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, DefaultMaxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("Linear API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) + } + + var gqlResp struct { + Data map[string]any `json:"data"` + Errors []map[string]any `json:"errors"` + } + if err := json.Unmarshal(respBody, &gqlResp); err != nil { + return nil, fmt.Errorf("parsing response: %w", err) + } + if len(gqlResp.Errors) > 0 { + msg, _ := gqlResp.Errors[0]["message"].(string) + return nil, fmt.Errorf("Linear GraphQL error: %s", msg) + } + + return gqlResp.Data, nil +} + +// extractLinearToken pulls the Linear API key from _credential. +func extractLinearToken(params map[string]any) (string, error) { + cred, ok := params["_credential"].(map[string]string) + if !ok { + return "", fmt.Errorf("credential is required") + } + delete(params, "_credential") + + token := cred["token"] + if token == "" { + return "", fmt.Errorf("credential must contain a 'token' field") + } + return token, nil +} + +// extractIntParam extracts an integer from float64 (JSON default) or int. +func extractIntParam(v any) (int, bool) { + switch n := v.(type) { + case int: + return n, true + case float64: + return int(n), true + } + return 0, false +} diff --git a/packages/engine/internal/connector/linear_test.go b/packages/engine/internal/connector/linear_test.go new file mode 100644 index 00000000..bd43c2bb --- /dev/null +++ b/packages/engine/internal/connector/linear_test.go @@ -0,0 +1,321 @@ +package connector + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func linearTestServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + s := httptest.NewServer(handler) + t.Cleanup(s.Close) + return s +} + +func gqlResponse(data map[string]any) map[string]any { + return map[string]any{"data": data} +} + +func TestLinearCreateIssueConnector_CreatesIssue(t *testing.T) { + var gotAuth string + var gotVars map[string]any + + server := linearTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + var req struct { + Variables map[string]any `json:"variables"` + } + json.NewDecoder(r.Body).Decode(&req) + gotVars = req.Variables + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gqlResponse(map[string]any{ + "issueCreate": map[string]any{ + "success": true, + "issue": map[string]any{ + "id": "issue-uuid-1", + "identifier": "ENG-42", + "title": "Fix login bug", + "url": "https://linear.app/team/issue/ENG-42", + }, + }, + })) + }) + + c := &LinearCreateIssueConnector{baseURL: server.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "team_id": "team-uuid", + "title": "Fix login bug", + "description": "Users cannot log in on Safari.", + "priority": float64(2), + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if gotAuth != "lin_api_test" { + t.Errorf("Authorization = %q, want %q", gotAuth, "lin_api_test") + } + input, ok := gotVars["input"].(map[string]any) + if !ok { + t.Fatalf("variables.input missing or wrong type: %T", gotVars["input"]) + } + if input["teamId"] != "team-uuid" { + t.Errorf("input.teamId = %q, want team-uuid", input["teamId"]) + } + if input["title"] != "Fix login bug" { + t.Errorf("input.title = %q", input["title"]) + } + if input["priority"] != float64(2) { + t.Errorf("input.priority = %v, want 2", input["priority"]) + } + if out["identifier"] != "ENG-42" { + t.Errorf("identifier = %q, want ENG-42", out["identifier"]) + } + if out["url"] != "https://linear.app/team/issue/ENG-42" { + t.Errorf("url = %q", out["url"]) + } +} + +func TestLinearCreateIssueConnector_MissingTeamID(t *testing.T) { + c := &LinearCreateIssueConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "title": "Test", + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err == nil { + t.Fatal("expected error for missing team_id") + } +} + +func TestLinearCreateIssueConnector_MissingTitle(t *testing.T) { + c := &LinearCreateIssueConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "team_id": "team-uuid", + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err == nil { + t.Fatal("expected error for missing title") + } +} + +func TestLinearCreateIssueConnector_MissingCredential(t *testing.T) { + c := &LinearCreateIssueConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "team_id": "team-uuid", + "title": "Test", + }) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestLinearCreateIssueConnector_GraphQLError(t *testing.T) { + server := linearTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "errors": []map[string]any{{"message": "Entity not found"}}, + }) + }) + + c := &LinearCreateIssueConnector{baseURL: server.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "team_id": "bad-team", + "title": "Test", + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err == nil { + t.Fatal("expected error for GraphQL error response") + } + if got := err.Error(); got == "" { + t.Errorf("error message is empty") + } +} + +func TestLinearCreateIssueConnector_SuccessFalse(t *testing.T) { + server := linearTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gqlResponse(map[string]any{ + "issueCreate": map[string]any{"success": false}, + })) + }) + + c := &LinearCreateIssueConnector{baseURL: server.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "team_id": "team-uuid", + "title": "Test", + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err == nil { + t.Fatal("expected error when success=false") + } +} + +func TestLinearCreateIssueConnector_CredentialDeleted(t *testing.T) { + server := linearTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gqlResponse(map[string]any{ + "issueCreate": map[string]any{ + "success": true, + "issue": map[string]any{ + "id": "i1", "identifier": "ENG-1", "title": "T", + "url": "https://linear.app/t/issue/ENG-1", + }, + }, + })) + }) + + params := map[string]any{ + "team_id": "team-uuid", + "title": "T", + "_credential": map[string]string{"token": "lin_api_test"}, + } + c := &LinearCreateIssueConnector{baseURL: server.URL} + if _, err := c.Execute(context.Background(), params); err != nil { + t.Fatalf("Execute() error: %v", err) + } + if _, exists := params["_credential"]; exists { + t.Error("_credential should be deleted from params after execution") + } +} + +func TestLinearSearchConnector_ReturnsIssues(t *testing.T) { + var gotVars map[string]any + + server := linearTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Variables map[string]any `json:"variables"` + } + json.NewDecoder(r.Body).Decode(&req) + gotVars = req.Variables + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gqlResponse(map[string]any{ + "issues": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": "i1", "identifier": "ENG-1", "title": "Alpha", + "url": "https://linear.app/t/issue/ENG-1", "priority": float64(1), + "state": map[string]any{"name": "In Progress"}, + "assignee": map[string]any{"name": "Alice", "email": "alice@example.com"}, + }, + map[string]any{ + "id": "i2", "identifier": "ENG-2", "title": "Beta", + "url": "https://linear.app/t/issue/ENG-2", "priority": float64(3), + "state": map[string]any{"name": "Todo"}, + "assignee": nil, + }, + }, + }, + })) + }) + + c := &LinearSearchConnector{baseURL: server.URL} + out, err := c.Execute(context.Background(), map[string]any{ + "query": "login", + "team_id": "team-uuid", + "limit": float64(10), + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + + if gotVars["first"] != float64(10) { + t.Errorf("first = %v, want 10", gotVars["first"]) + } + filter, ok := gotVars["filter"].(map[string]any) + if !ok { + t.Fatalf("filter missing or wrong type: %T", gotVars["filter"]) + } + if _, hasTitle := filter["title"]; !hasTitle { + t.Error("filter should contain title for query param") + } + if _, hasTeam := filter["team"]; !hasTeam { + t.Error("filter should contain team for team_id param") + } + + count, _ := out["count"].(int) + if count != 2 { + t.Errorf("count = %d, want 2", count) + } + issues, _ := out["issues"].([]any) + if len(issues) != 2 { + t.Errorf("len(issues) = %d, want 2", len(issues)) + } +} + +func TestLinearSearchConnector_DefaultLimit(t *testing.T) { + var gotFirst any + + server := linearTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Variables map[string]any `json:"variables"` + } + json.NewDecoder(r.Body).Decode(&req) + gotFirst = req.Variables["first"] + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gqlResponse(map[string]any{ + "issues": map[string]any{"nodes": []any{}}, + })) + }) + + c := &LinearSearchConnector{baseURL: server.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if gotFirst != float64(25) { + t.Errorf("default limit = %v, want 25", gotFirst) + } +} + +func TestLinearSearchConnector_NoFilter(t *testing.T) { + var gotVars map[string]any + + server := linearTestServer(t, func(w http.ResponseWriter, r *http.Request) { + var req struct { + Variables map[string]any `json:"variables"` + } + json.NewDecoder(r.Body).Decode(&req) + gotVars = req.Variables + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(gqlResponse(map[string]any{ + "issues": map[string]any{"nodes": []any{}}, + })) + }) + + c := &LinearSearchConnector{baseURL: server.URL} + _, err := c.Execute(context.Background(), map[string]any{ + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err != nil { + t.Fatalf("Execute() error: %v", err) + } + if _, hasFilter := gotVars["filter"]; hasFilter { + t.Error("filter should be absent when no filter params provided") + } +} + +func TestLinearSearchConnector_MissingCredential(t *testing.T) { + c := &LinearSearchConnector{} + _, err := c.Execute(context.Background(), map[string]any{}) + if err == nil { + t.Fatal("expected error for missing credential") + } +} + +func TestRegistry_LinearConnectors(t *testing.T) { + r := NewRegistry() + for _, action := range []string{"linear/create_issue", "linear/search"} { + if _, err := r.Get(action); err != nil { + t.Errorf("Get(%q) error: %v", action, err) + } + } +} From fe1d3b6dbfce2e864a8e5b6807903b035f6a9177 Mon Sep 17 00:00:00 2001 From: Michael McNees Date: Sat, 30 May 2026 11:22:39 -0400 Subject: [PATCH 2/2] fix: address Copilot review feedback on PR #142 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate priority range (0–4) in linear/create_issue; return error early - Replace duplicate extractIntParam with shared extractInt (handles int64) - Rename shadowed body var to respBody in github/dispatch error path - Add TestLinearCreateIssueConnector_InvalidPriority test Co-Authored-By: Claude Sonnet 4.6 --- packages/engine/internal/connector/github.go | 4 ++-- packages/engine/internal/connector/linear.go | 17 +++++------------ .../engine/internal/connector/linear_test.go | 13 +++++++++++++ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/packages/engine/internal/connector/github.go b/packages/engine/internal/connector/github.go index 446afc26..3ab5a20b 100644 --- a/packages/engine/internal/connector/github.go +++ b/packages/engine/internal/connector/github.go @@ -167,8 +167,8 @@ func (c *GitHubDispatchConnector) Execute(ctx context.Context, params map[string // GitHub returns 204 No Content on success. if resp.StatusCode != http.StatusNoContent { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) - return nil, fmt.Errorf("github/dispatch: GitHub API returned %d: %s", resp.StatusCode, truncate(string(body), 500)) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 500)) + return nil, fmt.Errorf("github/dispatch: GitHub API returned %d: %s", resp.StatusCode, truncate(string(respBody), 500)) } return map[string]any{"ok": true}, nil diff --git a/packages/engine/internal/connector/linear.go b/packages/engine/internal/connector/linear.go index 3075d85f..ce90dae6 100644 --- a/packages/engine/internal/connector/linear.go +++ b/packages/engine/internal/connector/linear.go @@ -52,7 +52,10 @@ func (c *LinearCreateIssueConnector) Execute(ctx context.Context, params map[str if projectID, ok := params["project_id"].(string); ok && projectID != "" { input["projectId"] = projectID } - if priority, ok := extractIntParam(params["priority"]); ok { + if priority, ok := extractInt(params["priority"]); ok { + if priority < 0 || priority > 4 { + return nil, fmt.Errorf("linear/create_issue: priority must be between 0 and 4, got %d", priority) + } input["priority"] = priority } if labelIDs := toStringSlice(params["label_ids"]); len(labelIDs) > 0 { @@ -117,7 +120,7 @@ func (c *LinearSearchConnector) Execute(ctx context.Context, params map[string]a } limit := 25 - if l, ok := extractIntParam(params["limit"]); ok && l > 0 { + if l, ok := extractInt(params["limit"]); ok && l > 0 { limit = l } @@ -235,13 +238,3 @@ func extractLinearToken(params map[string]any) (string, error) { return token, nil } -// extractIntParam extracts an integer from float64 (JSON default) or int. -func extractIntParam(v any) (int, bool) { - switch n := v.(type) { - case int: - return n, true - case float64: - return int(n), true - } - return 0, false -} diff --git a/packages/engine/internal/connector/linear_test.go b/packages/engine/internal/connector/linear_test.go index bd43c2bb..c0ecfc3c 100644 --- a/packages/engine/internal/connector/linear_test.go +++ b/packages/engine/internal/connector/linear_test.go @@ -103,6 +103,19 @@ func TestLinearCreateIssueConnector_MissingTitle(t *testing.T) { } } +func TestLinearCreateIssueConnector_InvalidPriority(t *testing.T) { + c := &LinearCreateIssueConnector{} + _, err := c.Execute(context.Background(), map[string]any{ + "team_id": "team-uuid", + "title": "Test", + "priority": float64(5), + "_credential": map[string]string{"token": "lin_api_test"}, + }) + if err == nil { + t.Fatal("expected error for priority out of range") + } +} + func TestLinearCreateIssueConnector_MissingCredential(t *testing.T) { c := &LinearCreateIssueConnector{} _, err := c.Execute(context.Background(), map[string]any{