Skip to content
Merged
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
86 changes: 86 additions & 0 deletions internal/linear/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,92 @@ func (c *Client) CreateComment(ctx context.Context, issueID, body string) error
return nil
}

// FetchTeamID returns the team ID for the given issue.
func (c *Client) FetchTeamID(ctx context.Context, issueID string) (string, error) {
query := `query($issueId: String!) {
issue(id: $issueId) {
team { id }
}
}`

resp, err := c.doQuery(ctx, query, map[string]any{"issueId": issueID})
if err != nil {
return "", fmt.Errorf("linear_api_request: fetch team id: %w", err)
}

var result struct {
Data struct {
Issue struct {
Team struct {
ID string `json:"id"`
} `json:"team"`
} `json:"issue"`
} `json:"data"`
Errors []graphqlError `json:"errors"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return "", fmt.Errorf("linear_unknown_payload: %w", err)
}
if len(result.Errors) > 0 {
return "", fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message)
}
if result.Data.Issue.Team.ID == "" {
return "", fmt.Errorf("linear_team_not_found: no team found for issue %s", issueID)
}

return result.Data.Issue.Team.ID, nil
}

// CreateIssue creates a new Linear issue in the given team and returns its identifier (e.g. "ZYX-99").
func (c *Client) CreateIssue(ctx context.Context, teamID, title, description string) (string, error) {
mutation := `mutation($teamId: String!, $title: String!, $description: String) {
issueCreate(input: { teamId: $teamId, title: $title, description: $description }) {
success
issue {
id
identifier
}
}
}`

variables := map[string]any{
"teamId": teamID,
"title": title,
}
if description != "" {
variables["description"] = description
}

resp, err := c.doQuery(ctx, mutation, variables)
if err != nil {
return "", fmt.Errorf("linear_api_request: create issue: %w", err)
}

var result struct {
Data struct {
IssueCreate struct {
Success bool `json:"success"`
Issue struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
} `json:"issue"`
} `json:"issueCreate"`
} `json:"data"`
Errors []graphqlError `json:"errors"`
}
if err := json.Unmarshal(resp, &result); err != nil {
return "", fmt.Errorf("linear_unknown_payload: %w", err)
}
if len(result.Errors) > 0 {
return "", fmt.Errorf("linear_graphql_errors: %s", result.Errors[0].Message)
}
if !result.Data.IssueCreate.Success {
return "", fmt.Errorf("linear_create_issue_failed: issueCreate returned success=false")
}

return result.Data.IssueCreate.Issue.Identifier, nil
}

// ExecuteGraphQL runs a raw GraphQL query (for the linear_graphql tool extension).
func (c *Client) ExecuteGraphQL(ctx context.Context, query string, variables map[string]any) (json.RawMessage, error) {
return c.doQuery(ctx, query, variables)
Expand Down
156 changes: 156 additions & 0 deletions internal/linear/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,159 @@ func TestCreateComment_HTTPError(t *testing.T) {
t.Fatal("expected error, got nil")
}
}

func TestFetchTeamID_Success(t *testing.T) {
called := false
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true

var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
vars := req["variables"].(map[string]any)
if vars["issueId"] != "issue-abc" {
t.Errorf("expected issueId=issue-abc, got %v", vars["issueId"])
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"issue": map[string]any{
"team": map[string]any{"id": "team-xyz"},
},
},
})
})

teamID, err := client.FetchTeamID(context.Background(), "issue-abc")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if teamID != "team-xyz" {
t.Errorf("expected team-xyz, got %s", teamID)
}
if !called {
t.Fatal("handler was not called")
}
}

func TestFetchTeamID_GraphQLError(t *testing.T) {
client := newTestClient(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": "not found"}},
})
})

_, err := client.FetchTeamID(context.Background(), "issue-abc")
if err == nil {
t.Fatal("expected error, got nil")
}
if want := "linear_graphql_errors: not found"; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}

func TestFetchTeamID_EmptyTeam(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"issue": map[string]any{
"team": map[string]any{"id": ""},
},
},
})
})

_, err := client.FetchTeamID(context.Background(), "issue-abc")
if err == nil {
t.Fatal("expected error, got nil")
}
if want := "linear_team_not_found: no team found for issue issue-abc"; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}

func TestCreateIssue_Success(t *testing.T) {
called := false
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
called = true

var req map[string]any
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
t.Fatalf("decode request: %v", err)
}
vars := req["variables"].(map[string]any)
if vars["teamId"] != "team-xyz" {
t.Errorf("expected teamId=team-xyz, got %v", vars["teamId"])
}
if vars["title"] != "Bug: nil pointer in handler" {
t.Errorf("expected title='Bug: nil pointer in handler', got %v", vars["title"])
}
if vars["description"] != "Details here" {
t.Errorf("expected description='Details here', got %v", vars["description"])
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"issueCreate": map[string]any{
"success": true,
"issue": map[string]any{"id": "new-id-1", "identifier": "ZYX-99"},
},
},
})
})

identifier, err := client.CreateIssue(context.Background(), "team-xyz", "Bug: nil pointer in handler", "Details here")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if identifier != "ZYX-99" {
t.Errorf("expected ZYX-99, got %s", identifier)
}
if !called {
t.Fatal("handler was not called")
}
}

func TestCreateIssue_GraphQLError(t *testing.T) {
client := newTestClient(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": "unauthorized"}},
})
})

_, err := client.CreateIssue(context.Background(), "team-xyz", "title", "desc")
if err == nil {
t.Fatal("expected error, got nil")
}
if want := "linear_graphql_errors: unauthorized"; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}

func TestCreateIssue_SuccessFalse(t *testing.T) {
client := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"data": map[string]any{
"issueCreate": map[string]any{
"success": false,
"issue": nil,
},
},
})
})

_, err := client.CreateIssue(context.Background(), "team-xyz", "title", "desc")
if err == nil {
t.Fatal("expected error, got nil")
}
if want := "linear_create_issue_failed: issueCreate returned success=false"; err.Error() != want {
t.Errorf("expected %q, got %q", want, err.Error())
}
}
8 changes: 8 additions & 0 deletions internal/model/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,11 @@ type RateLimitInfo struct {
Remaining int
Reset time.Time
}

// Finding represents an issue discovered by the agent that is unrelated to the current task.
// Agents write these to .symphony/findings.json in the workspace; the orchestrator
// reads them after each turn and opens Linear issues for each one.
type Finding struct {
Title string `json:"title"`
Description string `json:"description"`
}
Loading
Loading