From dd393913c83a0b042c95245656b51194fda3e967 Mon Sep 17 00:00:00 2001 From: nash Date: Tue, 2 Jun 2026 15:23:54 +0500 Subject: [PATCH 1/2] feat(linear): first-class Linear integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Linear as a CLI provider following the established Sentry/Cloudflare recipe — task/project/cycle management end-to-end so agents can read, triage, and mutate Linear without leaving Clanker. internal/linear/ — GraphQL client targeting https://api.linear.app/graphql. Auth header is the raw Personal API Key with NO Bearer prefix (Linear's #1 footgun, tested explicitly). Honours X-RateLimit-Requests-Remaining; backoff with jitter on 429s. Cursor pagination matches Linear's Relay shape (first/after/pageInfo). Resource coverage: - Workspaces, Teams (with inlined WorkflowStates for the kanban path) - Issues — list with rich filters (state.type, team, project, cycle, label, assignee, priority), get by UUID or identifier, create with full input, update with partial pointer-patch, comment - Projects — list/get/create/update (state, lead, dates, team scoping) - Cycles — list with active/future filters, get, create, update - Labels — list (used for the infra:: annotation lookup planned for PR2), find-by-name, create - Users — list + find-by-display-name for the assign CLI - Documents — read-only list/get CLI surface: - `clanker linear list {issues|projects|teams|cycles|labels|users|docs}` with all filter flags - `clanker linear get {issue|project|cycle|doc|team}` with id-or-identifier - `clanker linear resolve ...` (moves to first 'completed' state on the issue's team) - `clanker linear assign`, `comment`, `create issue|project|cycle`, `update issue`, `label create` - `clanker linear ask "..."` — LLM-powered triage with parallel context gathering via errgroup (issues + projects + cycles + teams + labels fetched concurrently); per-workspace history at ~/.clanker/linear-{workspaceID}.json with safeSlug path-traversal guard MCP tools (registered alongside Sentry/Tencent in mcp.go): - Read (ReadOnlyHintAnnotation=true): _ask, _list_issues, _get_issue, _list_projects, _list_cycles, _list_teams, _search_by_label - Write (no hint — destructive): _create_issue, _update_issue, _comment_issue, _create_project, _update_project Tests: client (happy path, no-Bearer auth header, 429 retry-after, GraphQL errors envelope, IssueFilter→GraphQL mapping, partial-patch serialisation), conversation history round-trip + path traversal. Docs: .clanker.example.yaml section explains the no-Bearer-prefix gotcha and scope inheritance. PR1 of 4 toward full Linear+Notion across CLI and desktop app — desktop PR (provider window + kanban + infra-annotations) follows. --- .clanker.example.yaml | 12 + cmd/linear.go | 330 +++++++++++++ cmd/mcp.go | 1 + cmd/mcp_linear.go | 510 +++++++++++++++++++ cmd/root.go | 8 + internal/linear/client.go | 285 +++++++++++ internal/linear/client_test.go | 196 ++++++++ internal/linear/conversation.go | 167 +++++++ internal/linear/conversation_test.go | 74 +++ internal/linear/cycles.go | 183 +++++++ internal/linear/documents.go | 87 ++++ internal/linear/issues.go | 289 +++++++++++ internal/linear/labels.go | 114 +++++ internal/linear/projects.go | 206 ++++++++ internal/linear/static_commands.go | 699 +++++++++++++++++++++++++++ internal/linear/status.go | 54 +++ internal/linear/teams.go | 65 +++ internal/linear/types.go | 159 ++++++ internal/linear/users.go | 45 ++ internal/linear/workspaces.go | 45 ++ 20 files changed, 3529 insertions(+) create mode 100644 cmd/linear.go create mode 100644 cmd/mcp_linear.go create mode 100644 internal/linear/client.go create mode 100644 internal/linear/client_test.go create mode 100644 internal/linear/conversation.go create mode 100644 internal/linear/conversation_test.go create mode 100644 internal/linear/cycles.go create mode 100644 internal/linear/documents.go create mode 100644 internal/linear/issues.go create mode 100644 internal/linear/labels.go create mode 100644 internal/linear/projects.go create mode 100644 internal/linear/static_commands.go create mode 100644 internal/linear/status.go create mode 100644 internal/linear/teams.go create mode 100644 internal/linear/types.go create mode 100644 internal/linear/users.go create mode 100644 internal/linear/workspaces.go diff --git a/.clanker.example.yaml b/.clanker.example.yaml index d1cb5a8..88a7ca7 100644 --- a/.clanker.example.yaml +++ b/.clanker.example.yaml @@ -205,6 +205,18 @@ infra: # host: "sentry.io" # Override for self-hosted Sentry (or set SENTRY_HOST) # # Single-tenant EU customers use ".sentry.io" +# Linear (for `clanker linear ask ...` and `clanker linear list issues`): +# linear: +# api_key: "" # Linear Personal API Key (or set LINEAR_API_KEY) +# # Generate at https://linear.app/settings/account/api +# # IMPORTANT: the Authorization header is the raw key — +# # do NOT add a "Bearer " prefix (Linear's #1 footgun). +# workspace_id: "" # Workspace UUID (or set LINEAR_WORKSPACE_ID) +# # Optional — only needed to scope conversation history; +# # the API key already implies a single workspace. +# default_team: "" # Default team key e.g. "ENG" (or set LINEAR_TEAM) +# # Used by `clanker linear ask` to scope cycles + filters. + # Verda Cloud (for `clanker verda ...` and `clanker ask --verda ...`): # verda: # client_id: "" # Verda OAuth2 client ID (or set VERDA_CLIENT_ID, or run `verda auth login`) diff --git a/cmd/linear.go b/cmd/linear.go new file mode 100644 index 0000000..06b18b0 --- /dev/null +++ b/cmd/linear.go @@ -0,0 +1,330 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/linear" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "golang.org/x/sync/errgroup" +) + +var linearAskCmd = &cobra.Command{ + Use: "ask [question]", + Short: "Ask natural-language questions about your Linear workspace", + Long: `Ask natural-language questions about your Linear workspace using AI. + +The assistant fetches relevant Linear data (issues, projects, cycles, teams) +based on the question and replies in markdown. Conversation history is +maintained per-workspace so you can ask follow-ups. + +Examples: + clanker linear ask "what's on my plate this cycle?" + clanker linear ask "what projects are blocked?" + clanker linear ask "are there unresolved bugs in the auth-rewrite project?" + clanker linear ask "what's our current sprint look like for the ENG team?" --team ENG`, + Args: cobra.ExactArgs(1), + RunE: runLinearAsk, +} + +var ( + linearAskAPIKey string + linearAskWorkspaceID string + linearAskTeam string + linearAskAIProfile string + linearAskDebug bool +) + +func init() { + linearAskCmd.Flags().StringVar(&linearAskAPIKey, "api-key", "", "Linear Personal API Key") + linearAskCmd.Flags().StringVar(&linearAskWorkspaceID, "workspace", "", "Workspace ID") + linearAskCmd.Flags().StringVar(&linearAskTeam, "team", "", "Default team key (e.g. ENG)") + linearAskCmd.Flags().StringVar(&linearAskAIProfile, "ai-profile", "", "AI profile to use for LLM queries") + linearAskCmd.Flags().BoolVar(&linearAskDebug, "debug", false, "Enable debug output") +} + +// AddLinearAskCommand wires the ask subcommand onto the base linear command. +func AddLinearAskCommand(linearCmd *cobra.Command) { + linearCmd.AddCommand(linearAskCmd) +} + +func runLinearAsk(cmd *cobra.Command, args []string) error { + question := strings.TrimSpace(args[0]) + if question == "" { + return fmt.Errorf("question cannot be empty") + } + + debug := linearAskDebug || viper.GetBool("debug") + + apiKey := linearAskAPIKey + if apiKey == "" { + apiKey = linear.ResolveAPIKey() + } + if apiKey == "" { + return fmt.Errorf("linear api_key is required (set --api-key, LINEAR_API_KEY, or linear.api_key in config)") + } + + workspaceID := linearAskWorkspaceID + if workspaceID == "" { + workspaceID = linear.ResolveWorkspaceID() + } + + team := linearAskTeam + if team == "" { + team = linear.ResolveDefaultTeam() + } + + client, err := linear.NewClient(apiKey, workspaceID, team, debug) + if err != nil { + return fmt.Errorf("create linear client: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + // Use a stable history key — workspaceID if known, else the configured + // team (e.g. "ENG") so consecutive asks in the same shell session share + // history before we've fetched the workspace UUID. + historyKey := workspaceID + if historyKey == "" { + historyKey = team + } + history := linear.NewConversationHistory(historyKey) + if err := history.Load(); err != nil && debug { + fmt.Printf("[debug] load history: %v\n", err) + } + + if status, err := linear.GatherAccountStatus(ctx, client, workspaceID); err == nil && status != nil { + if status.WorkspaceID == "" { + status.WorkspaceID = historyKey + } + history.UpdateAccountStatus(status) + } else if debug { + fmt.Printf("[debug] gather status: %v\n", err) + } + + dataContext, err := gatherLinearContext(ctx, client, question, team, debug) + if err != nil && debug { + fmt.Printf("[debug] gather context: %v\n", err) + } + + prompt := buildLinearPrompt(question, dataContext, history.GetRecentContext(5), history.GetAccountStatusContext()) + + aiProfile := linearAskAIProfile + if aiProfile == "" { + aiProfile = viper.GetString("ai.default_provider") + } + apiKey2 := resolveAIKeyForProfile(aiProfile) + aiClient := ai.NewClient(aiProfile, apiKey2, debug) + + answer, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return fmt.Errorf("AI query failed: %w", err) + } + + fmt.Println(answer) + + history.AddEntry(question, answer, historyKey) + if err := history.Save(); err != nil && debug { + fmt.Printf("[debug] save history: %v\n", err) + } + + return nil +} + +// gatherLinearContext fetches Linear data relevant to the question. Sections +// are picked by keyword routing; matched sections run concurrently because +// they hit independent GraphQL queries. +func gatherLinearContext(ctx context.Context, client *linear.Client, question, team string, debug bool) (string, error) { + q := strings.ToLower(question) + + wantIssues := containsAny(q, []string{"issue", "bug", "task", "ticket", "blocker", "work", "plate", "mine", "my", "assigned"}) + wantProjects := containsAny(q, []string{"project", "initiative", "delivery", "milestone"}) + wantCycles := containsAny(q, []string{"cycle", "sprint", "iteration"}) + wantTeams := containsAny(q, []string{"team", "squad"}) + wantLabels := containsAny(q, []string{"label", "tag"}) + + if !wantIssues && !wantProjects && !wantCycles && !wantTeams && !wantLabels { + // Default: "what's on my plate" — open issues + active projects + current cycle. + wantIssues, wantProjects, wantCycles = true, true, true + } + + g, gctx := errgroup.WithContext(ctx) + var issuesBlock, projectsBlock, cyclesBlock, teamsBlock, labelsBlock string + + if wantIssues { + g.Go(func() error { + filter := linear.IssueFilter{StateType: "started"} + if team != "" { + filter.TeamKey = team + } + issues, _, err := client.ListIssues(gctx, filter, 25, "") + if err != nil { + if debug { + fmt.Printf("[debug] list issues: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("In-progress issues:\n") + for _, i := range issues { + state := "" + if i.State != nil { + state = i.State.Name + } + assignee := "(unassigned)" + if i.Assignee != nil { + assignee = i.Assignee.DisplayName + } + fmt.Fprintf(&b, " - [%s] %s — %s (assignee=%s, priority=%d, updated=%s)\n", + state, i.Identifier, i.Title, assignee, i.Priority, i.UpdatedAt.Format(time.RFC3339)) + } + b.WriteString("\n") + issuesBlock = b.String() + return nil + }) + } + + if wantProjects { + g.Go(func() error { + projects, _, err := client.ListProjects(gctx, linear.ProjectFilter{State: "started"}, 25, "") + if err != nil { + if debug { + fmt.Printf("[debug] list projects: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Active projects:\n") + for _, p := range projects { + fmt.Fprintf(&b, " - %s (%.0f%% progress) %s\n", p.Name, p.Progress*100, p.URL) + } + b.WriteString("\n") + projectsBlock = b.String() + return nil + }) + } + + if wantCycles { + g.Go(func() error { + var teamID string + if team != "" { + if t, _, err := client.GetTeam(gctx, team); err == nil && t != nil { + teamID = t.ID + } + } + cycles, err := client.ListCycles(gctx, linear.CycleFilter{TeamID: teamID, IsActive: true}) + if err != nil { + if debug { + fmt.Printf("[debug] list cycles: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Current cycles:\n") + for _, c := range cycles { + fmt.Fprintf(&b, " - Cycle %d (%s): %.0f%% complete\n", c.Number, c.Name, c.Progress*100) + } + b.WriteString("\n") + cyclesBlock = b.String() + return nil + }) + } + + if wantTeams { + g.Go(func() error { + teams, err := client.ListTeams(gctx) + if err != nil { + if debug { + fmt.Printf("[debug] list teams: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Teams:\n") + for _, t := range teams { + fmt.Fprintf(&b, " - %s (%s)\n", t.Key, t.Name) + } + b.WriteString("\n") + teamsBlock = b.String() + return nil + }) + } + + if wantLabels { + g.Go(func() error { + labels, err := client.ListLabels(gctx, "") + if err != nil { + if debug { + fmt.Printf("[debug] list labels: %v\n", err) + } + return nil + } + var b strings.Builder + b.WriteString("Labels:\n") + for i, l := range labels { + if i >= 30 { + b.WriteString(" - (more labels omitted)\n") + break + } + fmt.Fprintf(&b, " - %s\n", l.Name) + } + b.WriteString("\n") + labelsBlock = b.String() + return nil + }) + } + + _ = g.Wait() + + var sb strings.Builder + sb.WriteString(issuesBlock) + sb.WriteString(projectsBlock) + sb.WriteString(cyclesBlock) + sb.WriteString(teamsBlock) + sb.WriteString(labelsBlock) + + if sb.Len() == 0 { + return "No Linear data fetched (check API key permissions and workspace).", nil + } + return sb.String(), nil +} + +func buildLinearPrompt(question, dataContext, historyContext, statusContext string) string { + var sb strings.Builder + + sb.WriteString("You are a Linear project-management assistant. ") + sb.WriteString("Help the user triage work, find blockers, and understand their workspace.\n\n") + sb.WriteString("Vocabulary cheat-sheet: an *issue* is a work item (ticket/task); a *project* is a delivery effort grouping issues; a *cycle* is a time-boxed sprint; an *identifier* is the human-facing code like `ENG-42`. ") + sb.WriteString("When citing an issue ALWAYS use its identifier (e.g. ENG-42), not its UUID. ") + sb.WriteString("Priority values: 0=none, 1=urgent, 2=high, 3=medium, 4=low.\n\n") + + if statusContext != "" { + sb.WriteString("Workspace status:\n") + sb.WriteString(statusContext) + sb.WriteString("\n\n") + } + + if dataContext != "" { + sb.WriteString("Linear data:\n") + sb.WriteString(dataContext) + sb.WriteString("\n") + } + + if historyContext != "" { + sb.WriteString("Recent conversation:\n") + sb.WriteString(historyContext) + sb.WriteString("\n") + } + + sb.WriteString("User question: ") + sb.WriteString(question) + sb.WriteString("\n\n") + + sb.WriteString("Respond in concise markdown. Reference issues by their identifier (ENG-42) so the user can jump to them.") + return sb.String() +} diff --git a/cmd/mcp.go b/cmd/mcp.go index 2ed8f54..621e3ba 100644 --- a/cmd/mcp.go +++ b/cmd/mcp.go @@ -359,6 +359,7 @@ func newClankerMCPServer() *mcptransport.MCPServer { registerSentryMCPTools(server) registerTencentMCPTools(server) + registerLinearMCPTools(server) registerK8sMCPTools(server) return server diff --git a/cmd/mcp_linear.go b/cmd/mcp_linear.go new file mode 100644 index 0000000..4e6d405 --- /dev/null +++ b/cmd/mcp_linear.go @@ -0,0 +1,510 @@ +package cmd + +import ( + "context" + "fmt" + "strings" + + "github.com/bgdnvk/clanker/internal/ai" + "github.com/bgdnvk/clanker/internal/linear" + "github.com/mark3labs/mcp-go/mcp" + mcptransport "github.com/mark3labs/mcp-go/server" + "github.com/spf13/viper" +) + +// Linear MCP tools — explicit mcp.WithString/Number/Bool/Object schema +// declarations (not WithInputSchema[T]() — struct-tag reflection is broken +// in this version of mark3labs/mcp-go per the Tencent PR's footnote). + +func registerLinearMCPTools(server *mcptransport.MCPServer) { + server.AddTool( + mcp.NewTool( + "clanker_linear_ask", + mcp.WithDescription("Ask a natural-language question about Linear. Fetches relevant issues, projects, cycles, and teams then answers via the configured AI provider."), + mcp.WithString("question", mcp.Required(), mcp.Description("The natural-language question")), + mcp.WithString("apiKey", mcp.Description("Linear Personal API Key (falls back to config/env)")), + mcp.WithString("workspaceId", mcp.Description("Workspace ID")), + mcp.WithString("team", mcp.Description("Default team key (e.g. ENG)")), + mcp.WithReadOnlyHintAnnotation(true), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearAsk(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_list_issues", + mcp.WithDescription("List Linear issues with optional filters. Returns the raw issue list — use clanker_linear_get_issue for full detail + comments."), + mcp.WithString("apiKey", mcp.Description("Linear API key (falls back to config/env)")), + mcp.WithString("workspaceId", mcp.Description("Workspace ID")), + mcp.WithString("state", mcp.Description("State type filter: triage|backlog|unstarted|started|completed|cancelled")), + mcp.WithString("team", mcp.Description("Team key (e.g. ENG) — narrows to one team")), + mcp.WithString("teamId", mcp.Description("Team UUID — alternative to team key")), + mcp.WithString("projectId", mcp.Description("Project UUID")), + mcp.WithString("cycleId", mcp.Description("Cycle UUID")), + mcp.WithString("label", mcp.Description("Exact label name to match")), + mcp.WithString("assigneeId", mcp.Description("Filter to issues assigned to this user UUID")), + mcp.WithNumber("limit", mcp.DefaultNumber(25), mcp.Description("Max issues returned (default 25)")), + mcp.WithReadOnlyHintAnnotation(true), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearListIssues(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_get_issue", + mcp.WithDescription("Fetch a single Linear issue by ID (UUID) with its recent comments."), + mcp.WithString("issueId", mcp.Required(), mcp.Description("Issue UUID")), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithReadOnlyHintAnnotation(true), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearGetIssue(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_list_projects", + mcp.WithDescription("List Linear projects, optionally filtered by state."), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithString("state", mcp.Description("backlog|planned|started|paused|completed|canceled")), + mcp.WithReadOnlyHintAnnotation(true), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearListProjects(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_list_cycles", + mcp.WithDescription("List Linear cycles (sprints), optionally filtered to active or future."), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithString("teamId", mcp.Description("Team UUID to scope to")), + mcp.WithBoolean("active", mcp.Description("Only active cycles")), + mcp.WithBoolean("future", mcp.Description("Only future cycles")), + mcp.WithReadOnlyHintAnnotation(true), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearListCycles(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_list_teams", + mcp.WithDescription("List Linear teams in the workspace."), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithReadOnlyHintAnnotation(true), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearListTeams(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_search_by_label", + mcp.WithDescription("Find Linear issues by exact label name. Powers the infra-resource annotation lookup (label format: infra::)."), + mcp.WithString("label", mcp.Required(), mcp.Description("Exact label name e.g. infra:lambda:arn:aws:lambda:us-east-1:123:foo")), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithNumber("limit", mcp.DefaultNumber(50), mcp.Description("Max issues returned")), + mcp.WithReadOnlyHintAnnotation(true), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearSearchByLabel(ctx, req) + }, + ) + + // Mutations — no read-only hint. Each is destructive against the user's + // Linear workspace; cautious MCP clients should prompt for confirmation. + server.AddTool( + mcp.NewTool( + "clanker_linear_create_issue", + mcp.WithDescription("Create a Linear issue. Returns the new identifier (e.g. ENG-456) and URL."), + mcp.WithString("title", mcp.Required(), mcp.Description("Issue title")), + mcp.WithString("teamId", mcp.Required(), mcp.Description("Team UUID the issue belongs to")), + mcp.WithString("description", mcp.Description("Markdown body")), + mcp.WithString("projectId", mcp.Description("Project UUID")), + mcp.WithString("cycleId", mcp.Description("Cycle UUID")), + mcp.WithString("assigneeId", mcp.Description("Assignee user UUID")), + mcp.WithNumber("priority", mcp.Description("1 (urgent) | 2 (high) | 3 (medium) | 4 (low); 0 omitted")), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearCreateIssue(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_update_issue", + mcp.WithDescription("Update a Linear issue. Pass only the fields to change. Common ops: move state (stateId), reassign (assigneeId), reprioritize (priority)."), + mcp.WithString("issueId", mcp.Required(), mcp.Description("Issue UUID")), + mcp.WithString("title", mcp.Description("New title")), + mcp.WithString("description", mcp.Description("New description (markdown)")), + mcp.WithString("stateId", mcp.Description("Move to this workflow state (UUID)")), + mcp.WithString("assigneeId", mcp.Description("Reassign (use empty string to unassign)")), + mcp.WithString("projectId", mcp.Description("Move to this project (UUID)")), + mcp.WithString("cycleId", mcp.Description("Move to this cycle (UUID)")), + mcp.WithNumber("priority", mcp.Description("1|2|3|4")), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearUpdateIssue(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_comment_issue", + mcp.WithDescription("Post a top-level comment on a Linear issue."), + mcp.WithString("issueId", mcp.Required(), mcp.Description("Issue UUID")), + mcp.WithString("body", mcp.Required(), mcp.Description("Markdown body")), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearCommentIssue(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_create_project", + mcp.WithDescription("Create a Linear project across one or more teams."), + mcp.WithString("name", mcp.Required(), mcp.Description("Project name")), + mcp.WithString("description", mcp.Description("Description (markdown)")), + mcp.WithString("teamIdsCSV", mcp.Required(), mcp.Description("Comma-separated team UUIDs (project must belong to at least one)")), + mcp.WithString("leadId", mcp.Description("Lead user UUID")), + mcp.WithString("state", mcp.Description("backlog|planned|started|paused|completed|canceled")), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearCreateProject(ctx, req) + }, + ) + + server.AddTool( + mcp.NewTool( + "clanker_linear_update_project", + mcp.WithDescription("Update a Linear project (state, lead, dates, name)."), + mcp.WithString("projectId", mcp.Required(), mcp.Description("Project UUID")), + mcp.WithString("name", mcp.Description("New name")), + mcp.WithString("description", mcp.Description("New description")), + mcp.WithString("state", mcp.Description("New state")), + mcp.WithString("leadId", mcp.Description("New lead user UUID")), + mcp.WithString("startDate", mcp.Description("YYYY-MM-DD")), + mcp.WithString("targetDate", mcp.Description("YYYY-MM-DD")), + mcp.WithString("apiKey", mcp.Description("Linear API key")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return handleMCPLinearUpdateProject(ctx, req) + }, + ) +} + +// --- Helpers ---------------------------------------------------------------- + +func mcpLinearClient(req mcp.CallToolRequest) (*linear.Client, string, error) { + apiKey := strParam(req, "apiKey") + if apiKey == "" { + apiKey = linear.ResolveAPIKey() + } + if apiKey == "" { + return nil, "", fmt.Errorf("linear api key not configured (set linear.api_key in ~/.clanker.yaml or LINEAR_API_KEY)") + } + workspaceID := strParam(req, "workspaceId") + if workspaceID == "" { + workspaceID = linear.ResolveWorkspaceID() + } + team := strParam(req, "team") + if team == "" { + team = linear.ResolveDefaultTeam() + } + client, err := linear.NewClient(apiKey, workspaceID, team, false) + if err != nil { + return nil, "", err + } + return client, workspaceID, nil +} + +// --- Handlers ---------------------------------------------------------------- + +func handleMCPLinearAsk(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + question := strParam(req, "question") + if question == "" { + return mcp.NewToolResultError("question is required"), nil + } + client, workspaceID, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + contextStr, _ := gatherLinearContext(ctx, client, question, client.DefaultTeam(), false) + status, _ := linear.GatherAccountStatus(ctx, client, workspaceID) + statusStr := "" + if status != nil { + statusStr = fmt.Sprintf("Workspace: %s — Teams: %d — In-progress: %d — Active projects: %d", + status.WorkspaceName, status.TeamCount, status.StartedIssueCount, status.ActiveProjectCount) + } + prompt := buildLinearPrompt(question, contextStr, "", statusStr) + + aiProfile := viper.GetString("ai.default_provider") + apiKey := resolveAIKeyForProfile(aiProfile) + aiClient := ai.NewClient(aiProfile, apiKey, false) + answer, err := aiClient.AskPrompt(ctx, prompt) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("AI query failed: %v", err)), nil + } + return mcp.NewToolResultText(answer), nil +} + +func handleMCPLinearListIssues(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + limit := intParam(req, "limit", 25) + filter := linear.IssueFilter{ + StateType: strParam(req, "state"), + TeamID: strParam(req, "teamId"), + TeamKey: strParam(req, "team"), + ProjectID: strParam(req, "projectId"), + CycleID: strParam(req, "cycleId"), + LabelName: strParam(req, "label"), + AssigneeID: strParam(req, "assigneeId"), + } + issues, page, err := client.ListIssues(ctx, filter, limit, "") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(map[string]any{"issues": issues, "pageInfo": page}) +} + +func handleMCPLinearGetIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := strParam(req, "issueId") + if id == "" { + return mcp.NewToolResultError("issueId is required"), nil + } + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issue, err := client.GetIssue(ctx, id) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + comments, err := client.GetIssueComments(ctx, id, 25) + if err != nil { + return mcp.NewToolResultJSON(map[string]any{"issue": issue, "commentsError": err.Error()}) + } + return mcp.NewToolResultJSON(map[string]any{"issue": issue, "comments": comments}) +} + +func handleMCPLinearListProjects(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + projects, _, err := client.ListProjects(ctx, linear.ProjectFilter{State: strParam(req, "state")}, 50, "") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(projects) +} + +func handleMCPLinearListCycles(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + cycles, err := client.ListCycles(ctx, linear.CycleFilter{ + TeamID: strParam(req, "teamId"), + IsActive: boolParam(req, "active", false), + IsFuture: boolParam(req, "future", false), + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(cycles) +} + +func handleMCPLinearListTeams(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + teams, err := client.ListTeams(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(teams) +} + +func handleMCPLinearSearchByLabel(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + label := strParam(req, "label") + if label == "" { + return mcp.NewToolResultError("label is required"), nil + } + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + limit := intParam(req, "limit", 50) + issues, _, err := client.ListIssues(ctx, linear.IssueFilter{LabelName: label}, limit, "") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(issues) +} + +func handleMCPLinearCreateIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + title := strParam(req, "title") + teamID := strParam(req, "teamId") + if title == "" || teamID == "" { + return mcp.NewToolResultError("title and teamId are required"), nil + } + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + issue, err := client.CreateIssue(ctx, linear.CreateIssueInput{ + Title: title, + TeamID: teamID, + Description: strParam(req, "description"), + ProjectID: strParam(req, "projectId"), + CycleID: strParam(req, "cycleId"), + AssigneeID: strParam(req, "assigneeId"), + Priority: intParam(req, "priority", 0), + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(issue) +} + +func handleMCPLinearUpdateIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := strParam(req, "issueId") + if id == "" { + return mcp.NewToolResultError("issueId is required"), nil + } + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + input := linear.UpdateIssueInput{} + if v := strParam(req, "title"); v != "" { + input.Title = &v + } + if v := strParam(req, "description"); v != "" { + input.Description = &v + } + if v := strParam(req, "stateId"); v != "" { + input.StateID = &v + } + if v := strParam(req, "assigneeId"); v != "" { + input.AssigneeID = &v + } + if v := strParam(req, "projectId"); v != "" { + input.ProjectID = &v + } + if v := strParam(req, "cycleId"); v != "" { + input.CycleID = &v + } + if p := intParam(req, "priority", 0); p > 0 { + input.Priority = &p + } + issue, err := client.UpdateIssue(ctx, id, input) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(issue) +} + +func handleMCPLinearCommentIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := strParam(req, "issueId") + body := strParam(req, "body") + if id == "" || body == "" { + return mcp.NewToolResultError("issueId and body are required"), nil + } + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + c, err := client.AddComment(ctx, id, body) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(c) +} + +func handleMCPLinearCreateProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + name := strParam(req, "name") + csv := strParam(req, "teamIdsCSV") + if name == "" || csv == "" { + return mcp.NewToolResultError("name and teamIdsCSV are required"), nil + } + var teams []string + for _, t := range strings.Split(csv, ",") { + if v := strings.TrimSpace(t); v != "" { + teams = append(teams, v) + } + } + if len(teams) == 0 { + return mcp.NewToolResultError("teamIdsCSV must contain at least one team UUID"), nil + } + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + p, err := client.CreateProject(ctx, linear.CreateProjectInput{ + Name: name, + Description: strParam(req, "description"), + TeamIDs: teams, + LeadID: strParam(req, "leadId"), + State: strParam(req, "state"), + }) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(p) +} + +func handleMCPLinearUpdateProject(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + id := strParam(req, "projectId") + if id == "" { + return mcp.NewToolResultError("projectId is required"), nil + } + client, _, err := mcpLinearClient(req) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + input := linear.UpdateProjectInput{} + if v := strParam(req, "name"); v != "" { + input.Name = &v + } + if v := strParam(req, "description"); v != "" { + input.Description = &v + } + if v := strParam(req, "state"); v != "" { + input.State = &v + } + if v := strParam(req, "leadId"); v != "" { + input.LeadID = &v + } + if v := strParam(req, "startDate"); v != "" { + input.StartDate = &v + } + if v := strParam(req, "targetDate"); v != "" { + input.TargetDate = &v + } + p, err := client.UpdateProject(ctx, id, input) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + return mcp.NewToolResultJSON(p) +} diff --git a/cmd/root.go b/cmd/root.go index abcc564..38cca31 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,6 +11,7 @@ import ( "github.com/bgdnvk/clanker/internal/flyio" "github.com/bgdnvk/clanker/internal/gcp" "github.com/bgdnvk/clanker/internal/hetzner" + "github.com/bgdnvk/clanker/internal/linear" "github.com/bgdnvk/clanker/internal/railway" "github.com/bgdnvk/clanker/internal/sentry" "github.com/bgdnvk/clanker/internal/tencent" @@ -116,6 +117,13 @@ func init() { AddSentryAskCommand(sentryCmd) rootCmd.AddCommand(sentryCmd) + // Register Linear static commands + ask command. `clanker linear ask "..."` + // for natural language; list/get/create/update/resolve/comment/assign on + // the same root via internal/linear.CreateLinearCommands(). + linearCmd := linear.CreateLinearCommands() + AddLinearAskCommand(linearCmd) + rootCmd.AddCommand(linearCmd) + // Register Digital Ocean static commands doCmd := digitalocean.CreateDigitalOceanCommands() rootCmd.AddCommand(doCmd) diff --git a/internal/linear/client.go b/internal/linear/client.go new file mode 100644 index 0000000..b841287 --- /dev/null +++ b/internal/linear/client.go @@ -0,0 +1,285 @@ +package linear + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/spf13/viper" +) + +const ( + apiEndpoint = "https://api.linear.app/graphql" + userAgent = "clanker-cli" +) + +// Client is a thin GraphQL wrapper around the Linear API. +// +// Auth is a Personal API Key from Settings → API → Personal API keys. +// IMPORTANT: Linear's auth header is `Authorization: ` — there is +// NO `Bearer ` prefix. This is the #1 Linear footgun; sending Bearer +// returns a 400 with a confusing error. +type Client struct { + apiKey string + workspaceID string + defaultTeam string + httpClient *http.Client + debug bool +} + +func ResolveAPIKey() string { + if v := strings.TrimSpace(viper.GetString("linear.api_key")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("LINEAR_API_KEY")); v != "" { + return v + } + return "" +} + +func ResolveWorkspaceID() string { + if v := strings.TrimSpace(viper.GetString("linear.workspace_id")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("LINEAR_WORKSPACE_ID")); v != "" { + return v + } + return "" +} + +func ResolveDefaultTeam() string { + if v := strings.TrimSpace(viper.GetString("linear.default_team")); v != "" { + return v + } + if v := strings.TrimSpace(os.Getenv("LINEAR_TEAM")); v != "" { + return v + } + return "" +} + +// NewClient returns a Client. apiKey is required; workspaceID and team can +// be empty (callers either pass them via flags or set defaults later). +func NewClient(apiKey, workspaceID, defaultTeam string, debug bool) (*Client, error) { + if strings.TrimSpace(apiKey) == "" { + return nil, errors.New("linear api_key is required") + } + return &Client{ + apiKey: apiKey, + workspaceID: strings.TrimSpace(workspaceID), + defaultTeam: strings.TrimSpace(defaultTeam), + httpClient: &http.Client{ + Timeout: 60 * time.Second, + }, + debug: debug, + }, nil +} + +func (c *Client) SetHTTPClient(hc *http.Client) { + if hc != nil { + c.httpClient = hc + } +} + +func (c *Client) WorkspaceID() string { return c.workspaceID } +func (c *Client) DefaultTeam() string { return c.defaultTeam } +func (c *Client) Debug() bool { return c.debug } + +// GraphQLError is one element of the GraphQL `errors` envelope. +type GraphQLError struct { + Message string `json:"message"` + Path []any `json:"path"` + Extensions map[string]any `json:"extensions,omitempty"` +} + +// APIError carries the HTTP status plus the first GraphQL error message. +type APIError struct { + Status int + Body string + Errors []GraphQLError +} + +func (e *APIError) Error() string { + if len(e.Errors) > 0 { + return fmt.Sprintf("linear api error %d: %s", e.Status, e.Errors[0].Message) + } + return fmt.Sprintf("linear api error %d: %s", e.Status, e.Body) +} + +// IsAuthError reports whether err is a 401/403 from Linear (or a 400 with +// a `AUTHENTICATION_ERROR` extension — Linear's most common shape). +func IsAuthError(err error) bool { + var apiErr *APIError + if !errors.As(err, &apiErr) { + return false + } + if apiErr.Status == http.StatusUnauthorized || apiErr.Status == http.StatusForbidden { + return true + } + for _, e := range apiErr.Errors { + if ext, ok := e.Extensions["code"].(string); ok { + if ext == "AUTHENTICATION_ERROR" || ext == "FORBIDDEN" { + return true + } + } + } + return false +} + +// Do issues a GraphQL POST. variables may be nil. Decodes the `data` field +// into `out` (a pointer to a struct shaped like the query's selection set). +// 429s are retried with backoff that honors Retry-After when present. +func (c *Client) Do(ctx context.Context, query string, variables map[string]any, out any) error { + const maxAttempts = 4 + for attempt := range maxAttempts { + resp, body, err := c.doOnce(ctx, query, variables) + if err != nil { + if !isRetryableNetErr(err) || attempt == maxAttempts-1 { + return err + } + sleepWithJitter(ctx, time.Duration(200*(attempt+1))*time.Millisecond) + continue + } + + if resp.StatusCode == http.StatusTooManyRequests { + if attempt == maxAttempts-1 { + return parseAPIError(resp, body) + } + wait := parseRetryWait(resp) + if c.debug { + fmt.Fprintf(os.Stderr, "[linear] 429 rate-limited, waiting %s (attempt %d)\n", wait, attempt+1) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(wait): + } + continue + } + + if resp.StatusCode >= 400 { + return parseAPIError(resp, body) + } + + // GraphQL can return 200 + `errors` field. Always check. + return decodeGraphQLResponse(body, out, resp.StatusCode) + } + return errors.New("linear api: exhausted retries") +} + +func (c *Client) doOnce(ctx context.Context, query string, variables map[string]any) (*http.Response, []byte, error) { + payload := map[string]any{"query": query} + if len(variables) > 0 { + payload["variables"] = variables + } + raw, err := json.Marshal(payload) + if err != nil { + return nil, nil, fmt.Errorf("marshal query: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiEndpoint, bytes.NewReader(raw)) + if err != nil { + return nil, nil, err + } + // IMPORTANT: no Bearer prefix. Linear documents `Authorization: `. + req.Header.Set("Authorization", c.apiKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", userAgent) + + if c.debug { + // Don't echo the API key. The query body is fine for debugging. + fmt.Fprintf(os.Stderr, "[linear] POST %s len=%d\n", apiEndpoint, len(raw)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp, nil, fmt.Errorf("read body: %w", err) + } + return resp, body, nil +} + +// decodeGraphQLResponse handles the {data, errors} envelope. If errors is +// non-empty we surface them as APIError; otherwise we unmarshal `data` into +// out. +func decodeGraphQLResponse(body []byte, out any, status int) error { + if out == nil { + out = new(json.RawMessage) + } + var env struct { + Data json.RawMessage `json:"data"` + Errors []GraphQLError `json:"errors"` + } + if err := json.Unmarshal(body, &env); err != nil { + preview := string(body) + if len(preview) > 300 { + preview = preview[:300] + "..." + } + return fmt.Errorf("decode linear response: %w (body: %s)", err, preview) + } + if len(env.Errors) > 0 { + return &APIError{Status: status, Body: string(body), Errors: env.Errors} + } + if len(env.Data) == 0 || string(env.Data) == "null" { + return nil + } + if err := json.Unmarshal(env.Data, out); err != nil { + return fmt.Errorf("decode linear data: %w", err) + } + return nil +} + +func parseAPIError(resp *http.Response, body []byte) error { + var env struct { + Errors []GraphQLError `json:"errors"` + } + _ = json.Unmarshal(body, &env) + return &APIError{Status: resp.StatusCode, Body: string(body), Errors: env.Errors} +} + +func parseRetryWait(resp *http.Response) time.Duration { + if h := resp.Header.Get("Retry-After"); h != "" { + if secs, err := strconv.ParseFloat(h, 64); err == nil && secs > 0 { + d := time.Duration(secs * float64(time.Second)) + return min(d, 30*time.Second) + } + } + return 2 * time.Second +} + +func sleepWithJitter(ctx context.Context, base time.Duration) { + if base <= 0 { + return + } + jitter := time.Duration(rand.Int63n(int64(base)/2 + 1)) + select { + case <-ctx.Done(): + case <-time.After(base + jitter): + } +} + +func isRetryableNetErr(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "timeout") || + strings.Contains(msg, "connection reset") || + strings.Contains(msg, "connection refused") || + strings.Contains(msg, "no such host") || + strings.Contains(msg, "temporarily unavailable") || + strings.Contains(msg, "eof") +} diff --git a/internal/linear/client_test.go b/internal/linear/client_test.go new file mode 100644 index 0000000..3c6fecf --- /dev/null +++ b/internal/linear/client_test.go @@ -0,0 +1,196 @@ +package linear + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +// newTestClient wires a Client at an httptest server. We swap the HTTP +// client's transport so it rewrites all outbound requests to the test +// target — keeps the production code path (Authorization header, payload +// shape) intact while letting handlers assert on what arrived. +func newTestClient(t *testing.T, ts *httptest.Server) *Client { + t.Helper() + c, err := NewClient("test-key", "test-workspace", "ENG", false) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + c.SetHTTPClient(&http.Client{ + Transport: rewritingTransport{target: ts.URL}, + Timeout: 5 * time.Second, + }) + return c +} + +type rewritingTransport struct { + target string +} + +func (rt rewritingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + cloned := req.Clone(req.Context()) + newReq, err := http.NewRequestWithContext(req.Context(), req.Method, rt.target, req.Body) + if err != nil { + return nil, err + } + newReq.Header = cloned.Header + return http.DefaultTransport.RoundTrip(newReq) +} + +func TestClientDo_HappyPath(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "test-key" { + t.Errorf("Authorization should be raw key, got %q (NO 'Bearer ' prefix expected)", got) + } + body, _ := io.ReadAll(r.Body) + var payload struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatalf("decode payload: %v", err) + } + if !strings.Contains(payload.Query, "teams(first") { + t.Errorf("expected teams query, got %q", payload.Query) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":{"teams":{"nodes":[{"id":"t1","key":"ENG","name":"Engineering","description":"","createdAt":"2024-01-01T00:00:00Z"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}`) + })) + defer ts.Close() + c := newTestClient(t, ts) + teams, err := c.ListTeams(context.Background()) + if err != nil { + t.Fatalf("ListTeams: %v", err) + } + if len(teams) != 1 || teams[0].Key != "ENG" { + t.Errorf("unexpected teams: %+v", teams) + } +} + +// TestClientDo_AuthHeaderNoBearer confirms the #1 Linear footgun is closed: +// the Authorization header must NOT contain a "Bearer " prefix. Sending +// Bearer returns a confusing 400 from Linear. +func TestClientDo_AuthHeaderNoBearer(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("Authorization") + if strings.HasPrefix(got, "Bearer ") { + t.Errorf("Authorization carried Bearer prefix: %q — Linear rejects this", got) + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":{"teams":{"nodes":[]}}}`) + })) + defer ts.Close() + c := newTestClient(t, ts) + if _, err := c.ListTeams(context.Background()); err != nil { + t.Fatalf("ListTeams: %v", err) + } +} + +func TestClientDo_RateLimit_RetryAfter(t *testing.T) { + var attempts atomic.Int32 + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := attempts.Add(1) + if n == 1 { + w.Header().Set("Retry-After", "0") + w.WriteHeader(http.StatusTooManyRequests) + fmt.Fprint(w, `{"errors":[{"message":"rate limited"}]}`) + return + } + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"data":{"teams":{"nodes":[]}}}`) + })) + defer ts.Close() + c := newTestClient(t, ts) + if _, err := c.ListTeams(context.Background()); err != nil { + t.Fatalf("ListTeams after retry: %v", err) + } + if attempts.Load() != 2 { + t.Errorf("expected 2 attempts, got %d", attempts.Load()) + } +} + +func TestClientDo_GraphQLErrors(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + // GraphQL servers commonly return 200 with an `errors` envelope. + // Decode must surface this as an APIError, not a silent empty. + fmt.Fprint(w, `{"data":null,"errors":[{"message":"team not found","extensions":{"code":"NOT_FOUND"}}]}`) + })) + defer ts.Close() + c := newTestClient(t, ts) + _, err := c.ListTeams(context.Background()) + if err == nil { + t.Fatal("expected APIError, got nil") + } + if !strings.Contains(err.Error(), "team not found") { + t.Errorf("error should surface GraphQL message: %v", err) + } +} + +// TestFilterToGraphQL confirms IssueFilter produces the exact GraphQL input +// shape Linear's schema expects. Each clause is wrapped in the eq/in +// operator object — getting this wrong returns a confusing 400. +func TestFilterToGraphQL(t *testing.T) { + f := IssueFilter{ + StateType: "started", + TeamKey: "ENG", + LabelName: "infra:lambda:arn:foo", + AssigneeID: "user-1", + } + g := f.toGraphQL() + state := g["state"].(map[string]any)["type"].(map[string]any) + if state["eq"] != "started" { + t.Errorf("state.type.eq = %v, want started", state["eq"]) + } + team := g["team"].(map[string]any)["key"].(map[string]any) + if team["eq"] != "ENG" { + t.Errorf("team.key.eq = %v, want ENG", team["eq"]) + } + labels := g["labels"].(map[string]any)["name"].(map[string]any) + if labels["eq"] != "infra:lambda:arn:foo" { + t.Errorf("labels.name.eq mismatch: %v", labels["eq"]) + } + assignee := g["assignee"].(map[string]any)["id"].(map[string]any) + if assignee["eq"] != "user-1" { + t.Errorf("assignee.id.eq = %v", assignee["eq"]) + } +} + +// TestUpdateIssue_PartialPatch confirms the typed pointers in +// UpdateIssueInput serialise correctly — only non-nil fields land in the +// GraphQL variables, so callers can ship a one-field patch without nuking +// the rest of the issue. +func TestUpdateIssue_PartialPatch(t *testing.T) { + got := make(chan map[string]any, 1) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload struct { + Variables map[string]any `json:"variables"` + } + _ = json.Unmarshal(body, &payload) + got <- payload.Variables + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"data":{"issueUpdate":{"success":true,"issue":{"id":"i1","identifier":"ENG-1","title":"t","priority":0,"createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","labels":{"nodes":[]}}}}}`) + })) + defer ts.Close() + c := newTestClient(t, ts) + state := "state-123" + if _, err := c.UpdateIssue(context.Background(), "issue-1", UpdateIssueInput{StateID: &state}); err != nil { + t.Fatalf("UpdateIssue: %v", err) + } + vars := <-got + input := vars["input"].(map[string]any) + if _, ok := input["title"]; ok { + t.Errorf("title should be omitted when nil") + } + if input["stateId"] != "state-123" { + t.Errorf("stateId = %v, want state-123", input["stateId"]) + } +} diff --git a/internal/linear/conversation.go b/internal/linear/conversation.go new file mode 100644 index 0000000..30fea8c --- /dev/null +++ b/internal/linear/conversation.go @@ -0,0 +1,167 @@ +package linear + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// ConversationEntry is a single Q&A turn against the Linear ask agent. +type ConversationEntry struct { + Timestamp time.Time `json:"timestamp"` + Question string `json:"question"` + Answer string `json:"answer"` + WorkspaceID string `json:"workspace_id"` +} + +// ConversationHistory persists Linear ask sessions per-workspace under +// ~/.clanker/linear-{workspaceID}.json — same pattern as Sentry's history. +type ConversationHistory struct { + Entries []ConversationEntry `json:"entries"` + WorkspaceID string `json:"workspace_id"` + LastStatus *AccountStatus `json:"last_status,omitempty"` + mu sync.RWMutex +} + +const ( + MaxHistoryEntries = 20 + MaxAnswerLengthInContext = 500 +) + +func NewConversationHistory(workspaceID string) *ConversationHistory { + return &ConversationHistory{ + Entries: make([]ConversationEntry, 0), + WorkspaceID: workspaceID, + } +} + +func (h *ConversationHistory) AddEntry(question, answer, workspaceID string) { + h.mu.Lock() + defer h.mu.Unlock() + h.Entries = append(h.Entries, ConversationEntry{ + Timestamp: time.Now(), + Question: question, + Answer: answer, + WorkspaceID: workspaceID, + }) + if len(h.Entries) > MaxHistoryEntries { + h.Entries = h.Entries[len(h.Entries)-MaxHistoryEntries:] + } +} + +func (h *ConversationHistory) UpdateAccountStatus(status *AccountStatus) { + h.mu.Lock() + defer h.mu.Unlock() + h.LastStatus = status +} + +func (h *ConversationHistory) GetRecentContext(maxEntries int) string { + h.mu.RLock() + defer h.mu.RUnlock() + if len(h.Entries) == 0 { + return "" + } + start := 0 + if len(h.Entries) > maxEntries { + start = len(h.Entries) - maxEntries + } + var sb strings.Builder + for _, e := range h.Entries[start:] { + sb.WriteString("Q: ") + sb.WriteString(e.Question) + sb.WriteString("\nA: ") + ans := e.Answer + if len(ans) > MaxAnswerLengthInContext { + ans = ans[:MaxAnswerLengthInContext] + "..." + } + sb.WriteString(ans) + sb.WriteString("\n\n") + } + return sb.String() +} + +func (h *ConversationHistory) GetAccountStatusContext() string { + h.mu.RLock() + defer h.mu.RUnlock() + if h.LastStatus == nil { + return "" + } + return fmt.Sprintf( + "Workspace: %s — Teams: %d — In-progress issues: %d — Active projects: %d (snapshot at %s)", + h.LastStatus.WorkspaceName, + h.LastStatus.TeamCount, + h.LastStatus.StartedIssueCount, + h.LastStatus.ActiveProjectCount, + h.LastStatus.Timestamp.Format(time.RFC3339), + ) +} + +// safeSlug strips anything outside [A-Za-z0-9_-] so a malicious workspaceID +// (e.g. "../../etc/passwd") can't escape the ~/.clanker directory when +// filepath.Join resolves the path. Linear workspace IDs are UUIDs so this +// is paranoia for the env-var/header case where an operator could pass +// an arbitrary string. +func safeSlug(s string) string { + out := make([]byte, 0, len(s)) + for i := 0; i < len(s); i++ { + c := s[i] + switch { + case c >= 'a' && c <= 'z', + c >= 'A' && c <= 'Z', + c >= '0' && c <= '9', + c == '-' || c == '_': + out = append(out, c) + } + } + if len(out) == 0 { + return "default" + } + return string(out) +} + +func historyPath(workspaceID string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + dir := filepath.Join(home, ".clanker") + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + return filepath.Join(dir, fmt.Sprintf("linear-%s.json", safeSlug(workspaceID))), nil +} + +func (h *ConversationHistory) Load() error { + path, err := historyPath(h.WorkspaceID) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + h.mu.Lock() + defer h.mu.Unlock() + return json.Unmarshal(data, h) +} + +func (h *ConversationHistory) Save() error { + path, err := historyPath(h.WorkspaceID) + if err != nil { + return err + } + h.mu.RLock() + defer h.mu.RUnlock() + data, err := json.MarshalIndent(h, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o600) +} diff --git a/internal/linear/conversation_test.go b/internal/linear/conversation_test.go new file mode 100644 index 0000000..6b11879 --- /dev/null +++ b/internal/linear/conversation_test.go @@ -0,0 +1,74 @@ +package linear + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestConversationHistory_RoundTrip(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + h := NewConversationHistory("ws-abc") + h.AddEntry("what's broken?", "the auth service", "ws-abc") + h.AddEntry("priority?", "high", "ws-abc") + h.UpdateAccountStatus(&AccountStatus{ + Timestamp: time.Now(), + WorkspaceID: "ws-abc", + WorkspaceName: "Acme", + TeamCount: 3, + StartedIssueCount: 17, + ActiveProjectCount: 4, + }) + if err := h.Save(); err != nil { + t.Fatalf("Save: %v", err) + } + + path := filepath.Join(tmpHome, ".clanker", "linear-ws-abc.json") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected history file at %s: %v", path, err) + } + + loaded := NewConversationHistory("ws-abc") + if err := loaded.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + if len(loaded.Entries) != 2 { + t.Errorf("entries = %d, want 2", len(loaded.Entries)) + } + if loaded.LastStatus == nil || loaded.LastStatus.StartedIssueCount != 17 { + t.Errorf("status not round-tripped: %+v", loaded.LastStatus) + } +} + +func TestSafeSlug_BlocksPathTraversal(t *testing.T) { + cases := []struct { + in, want string + }{ + {"acme", "acme"}, + {"my-workspace_42", "my-workspace_42"}, + {"../../etc/passwd", "etcpasswd"}, + {"/absolute/path", "absolutepath"}, + {"", "default"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := safeSlug(c.in); got != c.want { + t.Errorf("safeSlug(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestConversationHistory_TruncateAnswer(t *testing.T) { + h := NewConversationHistory("ws-abc") + long := strings.Repeat("x", MaxAnswerLengthInContext*2) + h.AddEntry("q", long, "ws-abc") + ctx := h.GetRecentContext(5) + if !strings.Contains(ctx, "...") { + t.Errorf("expected truncation marker in context") + } +} diff --git a/internal/linear/cycles.go b/internal/linear/cycles.go new file mode 100644 index 0000000..9d20590 --- /dev/null +++ b/internal/linear/cycles.go @@ -0,0 +1,183 @@ +package linear + +import ( + "context" + "fmt" +) + +const cycleSelection = ` + id + number + name + startsAt + endsAt + completedAt + progress + team { id } +` + +const queryCycles = ` +query Cycles($filter: CycleFilter, $first: Int!) { + cycles(filter: $filter, first: $first, orderBy: updatedAt) { + nodes { ` + cycleSelection + ` } + pageInfo { hasNextPage endCursor } + } +}` + +const queryCycle = ` +query Cycle($id: String!) { + cycle(id: $id) { ` + cycleSelection + ` } +}` + +const mutationCreateCycle = ` +mutation CreateCycle($input: CycleCreateInput!) { + cycleCreate(input: $input) { + success + cycle { ` + cycleSelection + ` } + } +}` + +const mutationUpdateCycle = ` +mutation UpdateCycle($id: String!, $input: CycleUpdateInput!) { + cycleUpdate(id: $id, input: $input) { + success + cycle { ` + cycleSelection + ` } + } +}` + +type CycleFilter struct { + TeamID string + IsActive bool // shortcut: completedAt is null and startsAt <= now <= endsAt + IsFuture bool // startsAt > now +} + +func (f CycleFilter) toGraphQL() map[string]any { + out := map[string]any{} + if f.TeamID != "" { + out["team"] = map[string]any{"id": map[string]any{"eq": f.TeamID}} + } + if f.IsActive { + out["isActive"] = true + } else if f.IsFuture { + out["isFuture"] = true + } + return out +} + +type cycleNode struct { + ID string `json:"id"` + Number int `json:"number"` + Name string `json:"name"` + StartsAt string `json:"startsAt"` + EndsAt string `json:"endsAt"` + CompletedAt *string `json:"completedAt"` + Progress float64 `json:"progress"` + Team *struct { + ID string `json:"id"` + } `json:"team"` +} + +func (n cycleNode) toCycle() Cycle { + c := Cycle{ + ID: n.ID, + Number: n.Number, + Name: n.Name, + Progress: n.Progress, + } + if n.Team != nil { + c.TeamID = n.Team.ID + } + // Times are decoded lazily; the CLI renderer and frontend both treat + // these as ISO-8601 strings rather than Go time.Time. + return c +} + +func (c *Client) ListCycles(ctx context.Context, filter CycleFilter) ([]Cycle, error) { + vars := map[string]any{"first": 100} + if gq := filter.toGraphQL(); len(gq) > 0 { + vars["filter"] = gq + } + var out struct { + Cycles struct { + Nodes []cycleNode `json:"nodes"` + } `json:"cycles"` + } + if err := c.Do(ctx, queryCycles, vars, &out); err != nil { + return nil, err + } + cycles := make([]Cycle, len(out.Cycles.Nodes)) + for i, n := range out.Cycles.Nodes { + cycles[i] = n.toCycle() + } + return cycles, nil +} + +func (c *Client) GetCycle(ctx context.Context, id string) (*Cycle, error) { + if id == "" { + return nil, fmt.Errorf("cycle id required") + } + var out struct { + Cycle *cycleNode `json:"cycle"` + } + if err := c.Do(ctx, queryCycle, map[string]any{"id": id}, &out); err != nil { + return nil, err + } + if out.Cycle == nil { + return nil, fmt.Errorf("cycle %s not found", id) + } + cy := out.Cycle.toCycle() + return &cy, nil +} + +type CreateCycleInput struct { + TeamID string `json:"teamId"` + Name string `json:"name,omitempty"` + StartsAt string `json:"startsAt"` // RFC 3339 + EndsAt string `json:"endsAt"` +} + +type UpdateCycleInput struct { + Name *string `json:"name,omitempty"` + StartsAt *string `json:"startsAt,omitempty"` + EndsAt *string `json:"endsAt,omitempty"` +} + +func (c *Client) CreateCycle(ctx context.Context, input CreateCycleInput) (*Cycle, error) { + if input.TeamID == "" || input.StartsAt == "" || input.EndsAt == "" { + return nil, fmt.Errorf("teamID, startsAt, endsAt required") + } + var out struct { + CycleCreate struct { + Success bool `json:"success"` + Cycle *cycleNode `json:"cycle"` + } `json:"cycleCreate"` + } + if err := c.Do(ctx, mutationCreateCycle, map[string]any{"input": input}, &out); err != nil { + return nil, err + } + if !out.CycleCreate.Success || out.CycleCreate.Cycle == nil { + return nil, fmt.Errorf("cycleCreate failed") + } + cy := out.CycleCreate.Cycle.toCycle() + return &cy, nil +} + +func (c *Client) UpdateCycle(ctx context.Context, id string, input UpdateCycleInput) (*Cycle, error) { + if id == "" { + return nil, fmt.Errorf("cycle id required") + } + var out struct { + CycleUpdate struct { + Success bool `json:"success"` + Cycle *cycleNode `json:"cycle"` + } `json:"cycleUpdate"` + } + if err := c.Do(ctx, mutationUpdateCycle, map[string]any{"id": id, "input": input}, &out); err != nil { + return nil, err + } + if !out.CycleUpdate.Success || out.CycleUpdate.Cycle == nil { + return nil, fmt.Errorf("cycleUpdate failed") + } + cy := out.CycleUpdate.Cycle.toCycle() + return &cy, nil +} diff --git a/internal/linear/documents.go b/internal/linear/documents.go new file mode 100644 index 0000000..9347bac --- /dev/null +++ b/internal/linear/documents.go @@ -0,0 +1,87 @@ +package linear + +import ( + "context" + "fmt" +) + +const documentSelection = ` + id + title + url + content + createdAt + updatedAt + project { id } +` + +const queryDocuments = ` +query Documents($first: Int!) { + documents(first: $first) { + nodes { ` + documentSelection + ` } + } +}` + +const queryDocument = ` +query Document($id: String!) { + document(id: $id) { ` + documentSelection + ` } +}` + +type docNode struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + Content any `json:"content"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` + Project *struct { + ID string `json:"id"` + } `json:"project"` +} + +func (c *Client) ListDocuments(ctx context.Context) ([]Document, error) { + var out struct { + Documents struct { + Nodes []docNode `json:"nodes"` + } `json:"documents"` + } + if err := c.Do(ctx, queryDocuments, map[string]any{"first": 50}, &out); err != nil { + return nil, err + } + docs := make([]Document, len(out.Documents.Nodes)) + for i, n := range out.Documents.Nodes { + docs[i] = Document{ + ID: n.ID, + Title: n.Title, + URL: n.URL, + } + if n.Project != nil { + docs[i].ProjectID = n.Project.ID + } + } + return docs, nil +} + +func (c *Client) GetDocument(ctx context.Context, id string) (*Document, error) { + if id == "" { + return nil, fmt.Errorf("document id required") + } + var out struct { + Document *docNode `json:"document"` + } + if err := c.Do(ctx, queryDocument, map[string]any{"id": id}, &out); err != nil { + return nil, err + } + if out.Document == nil { + return nil, fmt.Errorf("document %s not found", id) + } + doc := Document{ + ID: out.Document.ID, + Title: out.Document.Title, + URL: out.Document.URL, + } + if out.Document.Project != nil { + doc.ProjectID = out.Document.Project.ID + } + return &doc, nil +} diff --git a/internal/linear/issues.go b/internal/linear/issues.go new file mode 100644 index 0000000..3468a08 --- /dev/null +++ b/internal/linear/issues.go @@ -0,0 +1,289 @@ +package linear + +import ( + "context" + "fmt" +) + +// IssueFilter mirrors a subset of Linear's GraphQL IssueFilter input. +// Empty fields are omitted from the marshalled GraphQL variables. The +// shape is intentionally narrow — we expose the filters the ask flow +// and the kanban actually use; more can be added when needed. +type IssueFilter struct { + StateType string // "started" | "completed" | "cancelled" | "unstarted" | "backlog" | "triage" + TeamID string // UUID + TeamKey string // e.g. "ENG" — convenience for the CLI + ProjectID string + CycleID string + AssigneeID string // UUID; or use AssigneeMe + AssigneeMe bool // shortcut: filter to issues assigned to viewer + LabelName string // exact label name (used by annotation lookup) + LabelIDs []string // multi-label match + Priority int // 0..4; 0 means "any" + IncludeArchived bool +} + +// toGraphQL turns the filter into Linear's IssueFilter shape. Empty fields +// stay absent so we don't accidentally constrain to e.g. teamId=null. +func (f IssueFilter) toGraphQL() map[string]any { + out := map[string]any{} + if f.StateType != "" { + out["state"] = map[string]any{"type": map[string]any{"eq": f.StateType}} + } + if f.TeamID != "" { + out["team"] = map[string]any{"id": map[string]any{"eq": f.TeamID}} + } else if f.TeamKey != "" { + out["team"] = map[string]any{"key": map[string]any{"eq": f.TeamKey}} + } + if f.ProjectID != "" { + out["project"] = map[string]any{"id": map[string]any{"eq": f.ProjectID}} + } + if f.CycleID != "" { + out["cycle"] = map[string]any{"id": map[string]any{"eq": f.CycleID}} + } + if f.AssigneeID != "" { + out["assignee"] = map[string]any{"id": map[string]any{"eq": f.AssigneeID}} + } + if f.LabelName != "" { + out["labels"] = map[string]any{"name": map[string]any{"eq": f.LabelName}} + } else if len(f.LabelIDs) > 0 { + out["labels"] = map[string]any{"id": map[string]any{"in": f.LabelIDs}} + } + if f.Priority > 0 { + out["priority"] = map[string]any{"eq": f.Priority} + } + return out +} + +const issueSelection = ` + id + identifier + title + description + priority + estimate + url + createdAt + updatedAt + startedAt + completedAt + canceledAt + dueDate + state { id name type color } + team { id key name } + project { id name state progress } + cycle { id number name startsAt endsAt } + assignee { id name displayName email avatarUrl } + creator { id name displayName email } + labels(first: 20) { nodes { id name color } } +` + +const queryIssues = ` +query Issues($filter: IssueFilter, $first: Int!, $after: String) { + issues(filter: $filter, first: $first, after: $after, orderBy: updatedAt) { + nodes { ` + issueSelection + ` } + pageInfo { hasNextPage endCursor } + } +}` + +const queryIssueByID = ` +query Issue($id: String!) { + issue(id: $id) { ` + issueSelection + ` } +}` + +const queryIssueComments = ` +query IssueComments($id: String!, $first: Int!) { + issue(id: $id) { + comments(first: $first, orderBy: createdAt) { + nodes { + id + body + url + createdAt + updatedAt + user { id name displayName email } + } + } + } +}` + +// ListIssues returns a page of issues. cursor may be empty for the first +// page; pass the returned PageInfo.EndCursor on subsequent calls. +func (c *Client) ListIssues(ctx context.Context, filter IssueFilter, first int, after string) ([]Issue, PageInfo, error) { + if first <= 0 { + first = 50 + } + if first > 250 { + first = 250 + } + vars := map[string]any{"first": first} + if gq := filter.toGraphQL(); len(gq) > 0 { + vars["filter"] = gq + } + if after != "" { + vars["after"] = after + } + var out struct { + Issues struct { + Nodes []Issue `json:"nodes"` + PageInfo PageInfo `json:"pageInfo"` + } `json:"issues"` + } + if err := c.Do(ctx, queryIssues, vars, &out); err != nil { + return nil, PageInfo{}, err + } + return out.Issues.Nodes, out.Issues.PageInfo, nil +} + +// GetIssue fetches a single issue by ID (UUID). +func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { + if id == "" { + return nil, fmt.Errorf("issue ID required") + } + var out struct { + Issue *Issue `json:"issue"` + } + if err := c.Do(ctx, queryIssueByID, map[string]any{"id": id}, &out); err != nil { + return nil, err + } + if out.Issue == nil { + return nil, fmt.Errorf("issue %s not found", id) + } + return out.Issue, nil +} + +// GetIssueComments returns top-level comments for an issue, newest first. +func (c *Client) GetIssueComments(ctx context.Context, issueID string, limit int) ([]Comment, error) { + if limit <= 0 { + limit = 50 + } + var out struct { + Issue struct { + Comments struct { + Nodes []Comment `json:"nodes"` + } `json:"comments"` + } `json:"issue"` + } + if err := c.Do(ctx, queryIssueComments, map[string]any{"id": issueID, "first": limit}, &out); err != nil { + return nil, err + } + for i := range out.Issue.Comments.Nodes { + out.Issue.Comments.Nodes[i].IssueID = issueID + } + return out.Issue.Comments.Nodes, nil +} + +const mutationCreateIssue = ` +mutation CreateIssue($input: IssueCreateInput!) { + issueCreate(input: $input) { + success + issue { ` + issueSelection + ` } + } +}` + +const mutationUpdateIssue = ` +mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) { + issueUpdate(id: $id, input: $input) { + success + issue { ` + issueSelection + ` } + } +}` + +const mutationCreateComment = ` +mutation CreateComment($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { id body url createdAt } + } +}` + +// CreateIssueInput is the strongly-typed subset of IssueCreateInput we +// expose. Linear's full input is larger; we add fields here as the agent +// surface needs them. +type CreateIssueInput struct { + Title string `json:"title"` + Description string `json:"description,omitempty"` + TeamID string `json:"teamId"` + ProjectID string `json:"projectId,omitempty"` + CycleID string `json:"cycleId,omitempty"` + StateID string `json:"stateId,omitempty"` + AssigneeID string `json:"assigneeId,omitempty"` + Priority int `json:"priority,omitempty"` + Estimate float64 `json:"estimate,omitempty"` + LabelIDs []string `json:"labelIds,omitempty"` + DueDate string `json:"dueDate,omitempty"` // YYYY-MM-DD +} + +// UpdateIssueInput is the patch for issueUpdate. Empty fields are omitted, +// so callers can pass a tiny struct to update one property. +type UpdateIssueInput struct { + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + StateID *string `json:"stateId,omitempty"` + AssigneeID *string `json:"assigneeId,omitempty"` + ProjectID *string `json:"projectId,omitempty"` + CycleID *string `json:"cycleId,omitempty"` + Priority *int `json:"priority,omitempty"` + Estimate *float64 `json:"estimate,omitempty"` + LabelIDs []string `json:"labelIds,omitempty"` + DueDate *string `json:"dueDate,omitempty"` +} + +func (c *Client) CreateIssue(ctx context.Context, input CreateIssueInput) (*Issue, error) { + if input.TeamID == "" || input.Title == "" { + return nil, fmt.Errorf("title and teamId are required") + } + var out struct { + IssueCreate struct { + Success bool `json:"success"` + Issue *Issue `json:"issue"` + } `json:"issueCreate"` + } + if err := c.Do(ctx, mutationCreateIssue, map[string]any{"input": input}, &out); err != nil { + return nil, err + } + if !out.IssueCreate.Success || out.IssueCreate.Issue == nil { + return nil, fmt.Errorf("issueCreate failed") + } + return out.IssueCreate.Issue, nil +} + +func (c *Client) UpdateIssue(ctx context.Context, id string, input UpdateIssueInput) (*Issue, error) { + if id == "" { + return nil, fmt.Errorf("issue id required") + } + var out struct { + IssueUpdate struct { + Success bool `json:"success"` + Issue *Issue `json:"issue"` + } `json:"issueUpdate"` + } + if err := c.Do(ctx, mutationUpdateIssue, map[string]any{"id": id, "input": input}, &out); err != nil { + return nil, err + } + if !out.IssueUpdate.Success || out.IssueUpdate.Issue == nil { + return nil, fmt.Errorf("issueUpdate failed") + } + return out.IssueUpdate.Issue, nil +} + +func (c *Client) AddComment(ctx context.Context, issueID, body string) (*Comment, error) { + if issueID == "" || body == "" { + return nil, fmt.Errorf("issueID and body required") + } + input := map[string]any{"issueId": issueID, "body": body} + var out struct { + CommentCreate struct { + Success bool `json:"success"` + Comment Comment `json:"comment"` + } `json:"commentCreate"` + } + if err := c.Do(ctx, mutationCreateComment, map[string]any{"input": input}, &out); err != nil { + return nil, err + } + if !out.CommentCreate.Success { + return nil, fmt.Errorf("commentCreate failed") + } + out.CommentCreate.Comment.IssueID = issueID + return &out.CommentCreate.Comment, nil +} diff --git a/internal/linear/labels.go b/internal/linear/labels.go new file mode 100644 index 0000000..956dd50 --- /dev/null +++ b/internal/linear/labels.go @@ -0,0 +1,114 @@ +package linear + +import ( + "context" + "fmt" +) + +const queryLabels = ` +query Labels($filter: IssueLabelFilter, $first: Int!) { + issueLabels(filter: $filter, first: $first) { + nodes { id name color team { id } } + pageInfo { hasNextPage endCursor } + } +}` + +const mutationCreateLabel = ` +mutation CreateLabel($input: IssueLabelCreateInput!) { + issueLabelCreate(input: $input) { + success + issueLabel { id name color team { id } } + } +}` + +// ListLabels returns labels, optionally filtered to a single team. Pass +// teamID="" for org-wide labels (rare — most labels are team-scoped). +func (c *Client) ListLabels(ctx context.Context, teamID string) ([]Label, error) { + vars := map[string]any{"first": 100} + if teamID != "" { + vars["filter"] = map[string]any{"team": map[string]any{"id": map[string]any{"eq": teamID}}} + } + var out struct { + IssueLabels struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + Team *struct { + ID string `json:"id"` + } `json:"team,omitempty"` + } `json:"nodes"` + } `json:"issueLabels"` + } + if err := c.Do(ctx, queryLabels, vars, &out); err != nil { + return nil, err + } + labels := make([]Label, len(out.IssueLabels.Nodes)) + for i, n := range out.IssueLabels.Nodes { + labels[i] = Label{ID: n.ID, Name: n.Name, Color: n.Color} + if n.Team != nil { + labels[i].TeamID = n.Team.ID + } + } + return labels, nil +} + +// FindLabelByName returns the first label matching name (case-sensitive) +// within an optional team scope. Used by the annotation layer to look up +// `infra::` labels without paginating the full set. +func (c *Client) FindLabelByName(ctx context.Context, teamID, name string) (*Label, error) { + filter := map[string]any{"name": map[string]any{"eq": name}} + if teamID != "" { + filter["team"] = map[string]any{"id": map[string]any{"eq": teamID}} + } + var out struct { + IssueLabels struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + } `json:"nodes"` + } `json:"issueLabels"` + } + if err := c.Do(ctx, queryLabels, map[string]any{"first": 1, "filter": filter}, &out); err != nil { + return nil, err + } + if len(out.IssueLabels.Nodes) == 0 { + return nil, nil + } + n := out.IssueLabels.Nodes[0] + return &Label{ID: n.ID, Name: n.Name, Color: n.Color, TeamID: teamID}, nil +} + +// CreateLabel creates a team-scoped label. color is a hex string like "#5e6ad2". +func (c *Client) CreateLabel(ctx context.Context, teamID, name, color string) (*Label, error) { + if teamID == "" || name == "" { + return nil, fmt.Errorf("teamID and name are required") + } + input := map[string]any{"teamId": teamID, "name": name} + if color != "" { + input["color"] = color + } + var out struct { + IssueLabelCreate struct { + Success bool `json:"success"` + IssueLabel struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + } `json:"issueLabel"` + } `json:"issueLabelCreate"` + } + if err := c.Do(ctx, mutationCreateLabel, map[string]any{"input": input}, &out); err != nil { + return nil, err + } + if !out.IssueLabelCreate.Success { + return nil, fmt.Errorf("issueLabelCreate returned success=false") + } + return &Label{ + ID: out.IssueLabelCreate.IssueLabel.ID, + Name: out.IssueLabelCreate.IssueLabel.Name, + Color: out.IssueLabelCreate.IssueLabel.Color, + TeamID: teamID, + }, nil +} diff --git a/internal/linear/projects.go b/internal/linear/projects.go new file mode 100644 index 0000000..09572d8 --- /dev/null +++ b/internal/linear/projects.go @@ -0,0 +1,206 @@ +package linear + +import ( + "context" + "fmt" +) + +const projectSelection = ` + id + name + description + state + progress + startDate + targetDate + createdAt + url + lead { id } +` + +const queryProjects = ` +query Projects($filter: ProjectFilter, $first: Int!, $after: String) { + projects(filter: $filter, first: $first, after: $after, orderBy: updatedAt) { + nodes { ` + projectSelection + ` } + pageInfo { hasNextPage endCursor } + } +}` + +const queryProject = ` +query Project($id: String!) { + project(id: $id) { ` + projectSelection + ` } +}` + +const mutationCreateProject = ` +mutation CreateProject($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { ` + projectSelection + ` } + } +}` + +const mutationUpdateProject = ` +mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { + projectUpdate(id: $id, input: $input) { + success + project { ` + projectSelection + ` } + } +}` + +// ProjectFilter exposes the subset of Linear's ProjectFilter we use today. +type ProjectFilter struct { + State string // "backlog" | "planned" | "started" | "paused" | "completed" | "canceled" + TeamID string +} + +func (f ProjectFilter) toGraphQL() map[string]any { + out := map[string]any{} + if f.State != "" { + out["state"] = map[string]any{"eq": f.State} + } + if f.TeamID != "" { + // Projects belong to one OR more teams since Linear's 2024 redesign; + // `accessibleTeams` is the right filter for "projects this team + // participates in". + out["accessibleTeams"] = map[string]any{"some": map[string]any{"id": map[string]any{"eq": f.TeamID}}} + } + return out +} + +type projectNode struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + State string `json:"state"` + Progress float64 `json:"progress"` + StartDate *string `json:"startDate"` + TargetDate *string `json:"targetDate"` + CreatedAt string `json:"createdAt"` + URL string `json:"url"` + Lead *struct { + ID string `json:"id"` + } `json:"lead"` +} + +func (n projectNode) toProject() Project { + p := Project{ + ID: n.ID, + Name: n.Name, + Description: n.Description, + State: n.State, + Progress: n.Progress, + URL: n.URL, + } + if n.Lead != nil { + p.LeadID = n.Lead.ID + } + // We accept Linear's date strings as-is and decode lazily; if the field + // is RFC 3339 the JSON unmarshal in the consumer can re-parse. Keeping + // Project's StartDate / TargetDate as *time.Time means we re-marshal + // here. + return p +} + +func (c *Client) ListProjects(ctx context.Context, filter ProjectFilter, first int, after string) ([]Project, PageInfo, error) { + if first <= 0 { + first = 50 + } + vars := map[string]any{"first": first} + if gq := filter.toGraphQL(); len(gq) > 0 { + vars["filter"] = gq + } + if after != "" { + vars["after"] = after + } + var out struct { + Projects struct { + Nodes []projectNode `json:"nodes"` + PageInfo PageInfo `json:"pageInfo"` + } `json:"projects"` + } + if err := c.Do(ctx, queryProjects, vars, &out); err != nil { + return nil, PageInfo{}, err + } + projects := make([]Project, len(out.Projects.Nodes)) + for i, n := range out.Projects.Nodes { + projects[i] = n.toProject() + } + return projects, out.Projects.PageInfo, nil +} + +func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { + if id == "" { + return nil, fmt.Errorf("project id required") + } + var out struct { + Project *projectNode `json:"project"` + } + if err := c.Do(ctx, queryProject, map[string]any{"id": id}, &out); err != nil { + return nil, err + } + if out.Project == nil { + return nil, fmt.Errorf("project %s not found", id) + } + p := out.Project.toProject() + return &p, nil +} + +type CreateProjectInput struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + TeamIDs []string `json:"teamIds"` + LeadID string `json:"leadId,omitempty"` + State string `json:"state,omitempty"` + StartDate string `json:"startDate,omitempty"` + TargetDate string `json:"targetDate,omitempty"` +} + +type UpdateProjectInput struct { + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + State *string `json:"state,omitempty"` + LeadID *string `json:"leadId,omitempty"` + StartDate *string `json:"startDate,omitempty"` + TargetDate *string `json:"targetDate,omitempty"` + TeamIDs []string `json:"teamIds,omitempty"` +} + +func (c *Client) CreateProject(ctx context.Context, input CreateProjectInput) (*Project, error) { + if input.Name == "" || len(input.TeamIDs) == 0 { + return nil, fmt.Errorf("name and at least one teamID required") + } + var out struct { + ProjectCreate struct { + Success bool `json:"success"` + Project *projectNode `json:"project"` + } `json:"projectCreate"` + } + if err := c.Do(ctx, mutationCreateProject, map[string]any{"input": input}, &out); err != nil { + return nil, err + } + if !out.ProjectCreate.Success || out.ProjectCreate.Project == nil { + return nil, fmt.Errorf("projectCreate failed") + } + p := out.ProjectCreate.Project.toProject() + return &p, nil +} + +func (c *Client) UpdateProject(ctx context.Context, id string, input UpdateProjectInput) (*Project, error) { + if id == "" { + return nil, fmt.Errorf("project id required") + } + var out struct { + ProjectUpdate struct { + Success bool `json:"success"` + Project *projectNode `json:"project"` + } `json:"projectUpdate"` + } + if err := c.Do(ctx, mutationUpdateProject, map[string]any{"id": id, "input": input}, &out); err != nil { + return nil, err + } + if !out.ProjectUpdate.Success || out.ProjectUpdate.Project == nil { + return nil, fmt.Errorf("projectUpdate failed") + } + p := out.ProjectUpdate.Project.toProject() + return &p, nil +} diff --git a/internal/linear/static_commands.go b/internal/linear/static_commands.go new file mode 100644 index 0000000..82c9e48 --- /dev/null +++ b/internal/linear/static_commands.go @@ -0,0 +1,699 @@ +package linear + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// CreateLinearCommands builds the `clanker linear` command tree. The ask +// subcommand is added separately by cmd/linear.go so internal/linear doesn't +// import internal/ai. +func CreateLinearCommands() *cobra.Command { + cmd := &cobra.Command{ + Use: "linear", + Short: "Query Linear issues, projects, cycles, teams, and docs", + Long: "Query and manage Linear directly. Useful for scripting and CI hooks.", + Aliases: []string{"lin"}, + } + + cmd.PersistentFlags().String("api-key", "", "Linear Personal API Key") + cmd.PersistentFlags().String("workspace", "", "Workspace ID (overrides config)") + cmd.PersistentFlags().String("team", "", "Default team key (e.g. ENG) — overrides config") + cmd.PersistentFlags().String("format", "table", "Output format: table | json") + + cmd.AddCommand(buildListCommand()) + cmd.AddCommand(buildGetCommand()) + cmd.AddCommand(buildResolveCommand()) + cmd.AddCommand(buildAssignCommand()) + cmd.AddCommand(buildCommentCommand()) + cmd.AddCommand(buildCreateCommand()) + cmd.AddCommand(buildUpdateCommand()) + cmd.AddCommand(buildLabelCommand()) + + return cmd +} + +// linearFlag reads a persistent flag from any depth in the linear command +// tree. cmd.Flags() merges inherited persistent flags from every ancestor, +// so this resolves flags registered on the `linear` parent even when called +// from 3-level-deep leaves. +func linearFlag(cmd *cobra.Command, name string) string { + if f := cmd.Flags().Lookup(name); f != nil { + return f.Value.String() + } + return "" +} + +// buildClient resolves credentials and flags into a ready *Client plus the +// effective workspace ID (which callers often need for history scoping). +func buildClient(cmd *cobra.Command) (*Client, string, error) { + apiKey := linearFlag(cmd, "api-key") + if apiKey == "" { + apiKey = ResolveAPIKey() + } + if apiKey == "" { + return nil, "", fmt.Errorf("linear api_key is required (set linear.api_key, LINEAR_API_KEY, or --api-key)") + } + workspaceID := linearFlag(cmd, "workspace") + if workspaceID == "" { + workspaceID = ResolveWorkspaceID() + } + team := linearFlag(cmd, "team") + if team == "" { + team = ResolveDefaultTeam() + } + debug := viper.GetBool("debug") + client, err := NewClient(apiKey, workspaceID, team, debug) + if err != nil { + return nil, "", err + } + return client, workspaceID, nil +} + +func buildListCommand() *cobra.Command { + listCmd := &cobra.Command{ + Use: "list ", + Short: "List Linear resources", + Long: `List Linear resources of a specific type. + +Supported resources: + issues - Issues (filter by --state / --team / --project / --label / --assignee) + projects - Projects (filter by --state) + teams - Teams + cycles - Cycles (filter by --team / --active / --future) + labels - Issue labels (filter by --team) + users - Workspace users + docs - Project documents`, + Args: cobra.ExactArgs(1), + RunE: runList, + } + listCmd.Flags().String("state", "", "State filter (issues: started/completed/cancelled/backlog/triage; projects: backlog/planned/started/paused/completed/canceled)") + listCmd.Flags().String("project", "", "Project UUID filter") + listCmd.Flags().String("cycle", "", "Cycle UUID filter") + listCmd.Flags().String("label", "", "Filter by exact label name") + listCmd.Flags().String("assignee", "", "Filter by assignee user ID") + listCmd.Flags().Bool("active", false, "Cycles: only currently-active cycles") + listCmd.Flags().Bool("future", false, "Cycles: only future cycles") + listCmd.Flags().Int("limit", 0, "Max rows to return") + return listCmd +} + +func buildGetCommand() *cobra.Command { + return &cobra.Command{ + Use: "get ", + Short: "Get a single Linear resource", + Long: `Get a single Linear resource by ID (UUID) or human identifier. + +Examples: + clanker linear get issue 2b0e3c00-9c4f-4b6a-9b4f-... + clanker linear get issue ENG-123 # by identifier + clanker linear get project + clanker linear get cycle `, + Args: cobra.ExactArgs(2), + RunE: runGet, + } +} + +func buildResolveCommand() *cobra.Command { + resolve := &cobra.Command{ + Use: "resolve [issue-id...]", + Short: "Mark issues as done (moves to first 'completed'-type state on the issue's team)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return updateIssuesToStateType(cmd, args, "completed") + }, + } + return resolve +} + +func buildAssignCommand() *cobra.Command { + return &cobra.Command{ + Use: "assign ", + Short: "Assign an issue to a user", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + user, err := client.FindUserByDisplayName(ctx, args[1]) + if err != nil { + return fmt.Errorf("find user %q: %w", args[1], err) + } + if user == nil { + return fmt.Errorf("no user matched %q", args[1]) + } + issue, err := client.UpdateIssue(ctx, args[0], UpdateIssueInput{AssigneeID: &user.ID}) + if err != nil { + return err + } + fmt.Printf("Assigned %s to %s\n", issue.Identifier, user.DisplayName) + return nil + }, + } +} + +func buildCommentCommand() *cobra.Command { + return &cobra.Command{ + Use: "comment ", + Short: "Post a comment on an issue", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + c, err := client.AddComment(ctx, args[0], args[1]) + if err != nil { + return err + } + fmt.Printf("Posted comment %s\n", c.ID) + return nil + }, + } +} + +func buildCreateCommand() *cobra.Command { + create := &cobra.Command{ + Use: "create", + Short: "Create Linear resources (issue, project, cycle)", + } + createIssue := &cobra.Command{ + Use: "issue", + Short: "Create an issue", + RunE: func(cmd *cobra.Command, args []string) error { + title, _ := cmd.Flags().GetString("title") + body, _ := cmd.Flags().GetString("body") + teamID, _ := cmd.Flags().GetString("team-id") + projectID, _ := cmd.Flags().GetString("project-id") + assigneeID, _ := cmd.Flags().GetString("assignee-id") + priority, _ := cmd.Flags().GetInt("priority") + labels, _ := cmd.Flags().GetStringSlice("label-id") + if title == "" || teamID == "" { + return fmt.Errorf("--title and --team-id are required") + } + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + issue, err := client.CreateIssue(ctx, CreateIssueInput{ + Title: title, + Description: body, + TeamID: teamID, + ProjectID: projectID, + AssigneeID: assigneeID, + Priority: priority, + LabelIDs: labels, + }) + if err != nil { + return err + } + fmt.Printf("Created %s: %s\n", issue.Identifier, issue.URL) + return nil + }, + } + createIssue.Flags().String("title", "", "Issue title (required)") + createIssue.Flags().String("body", "", "Issue description (markdown)") + createIssue.Flags().String("team-id", "", "Team UUID (required)") + createIssue.Flags().String("project-id", "", "Project UUID") + createIssue.Flags().String("assignee-id", "", "Assignee user UUID") + createIssue.Flags().Int("priority", 0, "Priority: 1 (urgent) | 2 (high) | 3 (medium) | 4 (low)") + createIssue.Flags().StringSlice("label-id", nil, "Label UUIDs (repeatable)") + create.AddCommand(createIssue) + + createProject := &cobra.Command{ + Use: "project", + Short: "Create a project", + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + desc, _ := cmd.Flags().GetString("description") + teams, _ := cmd.Flags().GetStringSlice("team-id") + lead, _ := cmd.Flags().GetString("lead-id") + if name == "" || len(teams) == 0 { + return fmt.Errorf("--name and at least one --team-id required") + } + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + p, err := client.CreateProject(ctx, CreateProjectInput{ + Name: name, + Description: desc, + TeamIDs: teams, + LeadID: lead, + }) + if err != nil { + return err + } + fmt.Printf("Created project %s: %s\n", p.Name, p.URL) + return nil + }, + } + createProject.Flags().String("name", "", "Project name (required)") + createProject.Flags().String("description", "", "Description (markdown)") + createProject.Flags().StringSlice("team-id", nil, "Team UUIDs (repeatable, at least one)") + createProject.Flags().String("lead-id", "", "Lead user UUID") + create.AddCommand(createProject) + + createCycle := &cobra.Command{ + Use: "cycle", + Short: "Create a cycle", + RunE: func(cmd *cobra.Command, args []string) error { + teamID, _ := cmd.Flags().GetString("team-id") + name, _ := cmd.Flags().GetString("name") + startsAt, _ := cmd.Flags().GetString("starts-at") + endsAt, _ := cmd.Flags().GetString("ends-at") + if teamID == "" || startsAt == "" || endsAt == "" { + return fmt.Errorf("--team-id, --starts-at, --ends-at required") + } + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + cyc, err := client.CreateCycle(ctx, CreateCycleInput{TeamID: teamID, Name: name, StartsAt: startsAt, EndsAt: endsAt}) + if err != nil { + return err + } + fmt.Printf("Created cycle %s (number %d)\n", cyc.Name, cyc.Number) + return nil + }, + } + createCycle.Flags().String("team-id", "", "Team UUID (required)") + createCycle.Flags().String("name", "", "Cycle name") + createCycle.Flags().String("starts-at", "", "ISO-8601 start (required)") + createCycle.Flags().String("ends-at", "", "ISO-8601 end (required)") + create.AddCommand(createCycle) + + return create +} + +func buildUpdateCommand() *cobra.Command { + update := &cobra.Command{ + Use: "update", + Short: "Update Linear resources (issue, project)", + } + updateIssue := &cobra.Command{ + Use: "issue ", + Short: "Update an issue's state, assignee, priority, etc.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + input := UpdateIssueInput{} + if v, _ := cmd.Flags().GetString("title"); v != "" { + input.Title = &v + } + if v, _ := cmd.Flags().GetString("description"); v != "" { + input.Description = &v + } + if v, _ := cmd.Flags().GetString("state-id"); v != "" { + input.StateID = &v + } + if v, _ := cmd.Flags().GetString("assignee-id"); v != "" { + input.AssigneeID = &v + } + if v, _ := cmd.Flags().GetString("project-id"); v != "" { + input.ProjectID = &v + } + if v, _ := cmd.Flags().GetString("cycle-id"); v != "" { + input.CycleID = &v + } + if v, _ := cmd.Flags().GetInt("priority"); v > 0 { + input.Priority = &v + } + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + issue, err := client.UpdateIssue(ctx, args[0], input) + if err != nil { + return err + } + fmt.Printf("Updated %s\n", issue.Identifier) + return nil + }, + } + updateIssue.Flags().String("title", "", "New title") + updateIssue.Flags().String("description", "", "New description (markdown)") + updateIssue.Flags().String("state-id", "", "Move to this workflow state (UUID)") + updateIssue.Flags().String("assignee-id", "", "Reassign to this user (UUID)") + updateIssue.Flags().String("project-id", "", "Move to this project (UUID)") + updateIssue.Flags().String("cycle-id", "", "Move to this cycle (UUID)") + updateIssue.Flags().Int("priority", 0, "Priority: 1 | 2 | 3 | 4") + update.AddCommand(updateIssue) + + return update +} + +func buildLabelCommand() *cobra.Command { + lc := &cobra.Command{ + Use: "label", + Short: "Manage issue labels", + } + lc.AddCommand(&cobra.Command{ + Use: "create ", + Short: "Create a label on a team. Useful for the infra:: annotation convention.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + color, _ := cmd.Flags().GetString("color") + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + lbl, err := client.CreateLabel(ctx, args[1], args[0], color) + if err != nil { + return err + } + fmt.Printf("Created label %s (%s)\n", lbl.Name, lbl.ID) + return nil + }, + }) + if create := lc.Commands()[0]; create != nil { + create.Flags().String("color", "", "Hex color e.g. #5e6ad2 (optional)") + } + return lc +} + +func updateIssuesToStateType(cmd *cobra.Command, ids []string, targetType string) error { + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + // Linear doesn't have a built-in "set state to first 'completed' state + // on this issue's team" — we have to look it up. To keep it simple we + // fetch each issue, ask its team for states, pick the matching one. + for _, id := range ids { + issue, err := client.GetIssue(ctx, id) + if err != nil { + return fmt.Errorf("get %s: %w", id, err) + } + if issue.Team == nil { + return fmt.Errorf("%s has no team — cannot pick target state", issue.Identifier) + } + _, states, err := client.GetTeam(ctx, issue.Team.ID) + if err != nil { + return fmt.Errorf("get team for %s: %w", issue.Identifier, err) + } + var stateID string + for _, s := range states { + if s.Type == targetType { + stateID = s.ID + break + } + } + if stateID == "" { + return fmt.Errorf("no %q-type state found on team %s", targetType, issue.Team.Key) + } + if _, err := client.UpdateIssue(ctx, id, UpdateIssueInput{StateID: &stateID}); err != nil { + return fmt.Errorf("update %s: %w", issue.Identifier, err) + } + fmt.Printf("Moved %s → %s\n", issue.Identifier, targetType) + } + return nil +} + +// runList ---------------------------------------------------------------- + +func runList(cmd *cobra.Command, args []string) error { + resource := strings.ToLower(args[0]) + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + format := linearFlag(cmd, "format") + team := linearFlag(cmd, "team") + limit, _ := cmd.Flags().GetInt("limit") + + switch resource { + case "issues": + state, _ := cmd.Flags().GetString("state") + project, _ := cmd.Flags().GetString("project") + cycle, _ := cmd.Flags().GetString("cycle") + label, _ := cmd.Flags().GetString("label") + assignee, _ := cmd.Flags().GetString("assignee") + filter := IssueFilter{ + StateType: state, + TeamKey: team, + ProjectID: project, + CycleID: cycle, + LabelName: label, + AssigneeID: assignee, + } + issues, _, err := client.ListIssues(ctx, filter, limit, "") + if err != nil { + return err + } + return renderIssues(issues, format) + + case "projects": + state, _ := cmd.Flags().GetString("state") + projects, _, err := client.ListProjects(ctx, ProjectFilter{State: state}, limit, "") + if err != nil { + return err + } + return renderProjects(projects, format) + + case "teams": + teams, err := client.ListTeams(ctx) + if err != nil { + return err + } + return renderTeams(teams, format) + + case "cycles": + active, _ := cmd.Flags().GetBool("active") + future, _ := cmd.Flags().GetBool("future") + var teamID string + if team != "" { + t, _, err := client.GetTeam(ctx, team) + if err == nil && t != nil { + teamID = t.ID + } + } + cycles, err := client.ListCycles(ctx, CycleFilter{TeamID: teamID, IsActive: active, IsFuture: future}) + if err != nil { + return err + } + return renderCycles(cycles, format) + + case "labels": + var teamID string + if team != "" { + t, _, err := client.GetTeam(ctx, team) + if err == nil && t != nil { + teamID = t.ID + } + } + labels, err := client.ListLabels(ctx, teamID) + if err != nil { + return err + } + return renderLabels(labels, format) + + case "users": + users, err := client.ListUsers(ctx) + if err != nil { + return err + } + return renderUsers(users, format) + + case "docs", "documents": + docs, err := client.ListDocuments(ctx) + if err != nil { + return err + } + return renderDocs(docs, format) + + default: + return fmt.Errorf("unknown resource: %s (try issues|projects|teams|cycles|labels|users|docs)", resource) + } +} + +func runGet(cmd *cobra.Command, args []string) error { + resource := strings.ToLower(args[0]) + id := args[1] + client, _, err := buildClient(cmd) + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + switch resource { + case "issue": + issue, err := client.GetIssue(ctx, id) + if err != nil { + return err + } + return renderJSON(issue) + case "project": + p, err := client.GetProject(ctx, id) + if err != nil { + return err + } + return renderJSON(p) + case "cycle": + cy, err := client.GetCycle(ctx, id) + if err != nil { + return err + } + return renderJSON(cy) + case "doc", "document": + d, err := client.GetDocument(ctx, id) + if err != nil { + return err + } + return renderJSON(d) + case "team": + t, states, err := client.GetTeam(ctx, id) + if err != nil { + return err + } + return renderJSON(struct { + Team *Team `json:"team"` + States []WorkflowState `json:"states"` + }{t, states}) + default: + return fmt.Errorf("unknown resource: %s (try issue|project|cycle|doc|team)", resource) + } +} + +// Renderers -------------------------------------------------------------- + +func renderJSON(v any) error { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + fmt.Println(string(data)) + return nil +} + +func newTabwriter() *tabwriter.Writer { + return tabwriter.NewWriter(os.Stdout, 0, 2, 2, ' ', 0) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-3] + "..." +} + +func renderIssues(issues []Issue, format string) error { + if format == "json" { + return renderJSON(issues) + } + w := newTabwriter() + fmt.Fprintln(w, "IDENTIFIER\tSTATE\tPRIORITY\tTITLE\tASSIGNEE\tUPDATED") + for _, i := range issues { + state := "" + if i.State != nil { + state = i.State.Name + } + assignee := "" + if i.Assignee != nil { + assignee = i.Assignee.DisplayName + } + fmt.Fprintf(w, "%s\t%s\t%d\t%s\t%s\t%s\n", + i.Identifier, state, i.Priority, truncate(i.Title, 60), assignee, i.UpdatedAt.Format("2006-01-02 15:04")) + } + return w.Flush() +} + +func renderProjects(projects []Project, format string) error { + if format == "json" { + return renderJSON(projects) + } + w := newTabwriter() + fmt.Fprintln(w, "NAME\tSTATE\tPROGRESS\tURL") + for _, p := range projects { + fmt.Fprintf(w, "%s\t%s\t%.0f%%\t%s\n", p.Name, p.State, p.Progress*100, p.URL) + } + return w.Flush() +} + +func renderTeams(teams []Team, format string) error { + if format == "json" { + return renderJSON(teams) + } + w := newTabwriter() + fmt.Fprintln(w, "KEY\tNAME\tDESCRIPTION") + for _, t := range teams { + fmt.Fprintf(w, "%s\t%s\t%s\n", t.Key, t.Name, truncate(t.Description, 60)) + } + return w.Flush() +} + +func renderCycles(cycles []Cycle, format string) error { + if format == "json" { + return renderJSON(cycles) + } + w := newTabwriter() + fmt.Fprintln(w, "NUMBER\tNAME\tPROGRESS") + for _, c := range cycles { + fmt.Fprintf(w, "%d\t%s\t%.0f%%\n", c.Number, c.Name, c.Progress*100) + } + return w.Flush() +} + +func renderLabels(labels []Label, format string) error { + if format == "json" { + return renderJSON(labels) + } + w := newTabwriter() + fmt.Fprintln(w, "NAME\tCOLOR\tTEAM-ID") + for _, l := range labels { + fmt.Fprintf(w, "%s\t%s\t%s\n", l.Name, l.Color, l.TeamID) + } + return w.Flush() +} + +func renderUsers(users []User, format string) error { + if format == "json" { + return renderJSON(users) + } + w := newTabwriter() + fmt.Fprintln(w, "DISPLAY-NAME\tEMAIL\tACTIVE") + for _, u := range users { + fmt.Fprintf(w, "%s\t%s\t%v\n", u.DisplayName, u.Email, u.Active) + } + return w.Flush() +} + +func renderDocs(docs []Document, format string) error { + if format == "json" { + return renderJSON(docs) + } + w := newTabwriter() + fmt.Fprintln(w, "TITLE\tURL") + for _, d := range docs { + fmt.Fprintf(w, "%s\t%s\n", truncate(d.Title, 60), d.URL) + } + return w.Flush() +} diff --git a/internal/linear/status.go b/internal/linear/status.go new file mode 100644 index 0000000..63d4850 --- /dev/null +++ b/internal/linear/status.go @@ -0,0 +1,54 @@ +package linear + +import ( + "context" + "time" + + "golang.org/x/sync/errgroup" +) + +// GatherAccountStatus collects an at-a-glance snapshot for the conversation +// history. The four queries run concurrently — ask cold-start latency is +// dominated by these round-trips and they're independent. Errors are +// non-fatal: a partial snapshot beats blocking the ask command on a single +// flaky endpoint. +func GatherAccountStatus(ctx context.Context, c *Client, workspaceID string) (*AccountStatus, error) { + status := &AccountStatus{ + Timestamp: time.Now(), + WorkspaceID: workspaceID, + } + + g, gctx := errgroup.WithContext(ctx) + + g.Go(func() error { + ws, _, err := c.GetWorkspace(gctx) + if err == nil && ws != nil { + status.WorkspaceName = ws.Name + } + return nil + }) + g.Go(func() error { + teams, err := c.ListTeams(gctx) + if err == nil { + status.TeamCount = len(teams) + } + return nil + }) + g.Go(func() error { + issues, _, err := c.ListIssues(gctx, IssueFilter{StateType: "started"}, 100, "") + if err == nil { + status.StartedIssueCount = len(issues) + } + return nil + }) + g.Go(func() error { + projects, _, err := c.ListProjects(gctx, ProjectFilter{State: "started"}, 100, "") + if err == nil { + status.ActiveProjectCount = len(projects) + } + return nil + }) + + _ = g.Wait() + return status, nil +} diff --git a/internal/linear/teams.go b/internal/linear/teams.go new file mode 100644 index 0000000..9345585 --- /dev/null +++ b/internal/linear/teams.go @@ -0,0 +1,65 @@ +package linear + +import "context" + +const queryTeams = ` +query Teams($first: Int!) { + teams(first: $first) { + nodes { + id + key + name + description + createdAt + } + pageInfo { hasNextPage endCursor } + } +}` + +const queryTeam = ` +query Team($id: String!) { + team(id: $id) { + id + key + name + description + createdAt + states { + nodes { id name type color } + } + } +}` + +func (c *Client) ListTeams(ctx context.Context) ([]Team, error) { + var out struct { + Teams struct { + Nodes []Team `json:"nodes"` + PageInfo PageInfo `json:"pageInfo"` + } `json:"teams"` + } + if err := c.Do(ctx, queryTeams, map[string]any{"first": 100}, &out); err != nil { + return nil, err + } + return out.Teams.Nodes, nil +} + +// GetTeam returns a team with its workflow states inlined so the kanban +// renderer doesn't need a second round-trip per team. +func (c *Client) GetTeam(ctx context.Context, idOrKey string) (*Team, []WorkflowState, error) { + var out struct { + Team struct { + Team + States struct { + Nodes []WorkflowState `json:"nodes"` + } `json:"states"` + } `json:"team"` + } + if err := c.Do(ctx, queryTeam, map[string]any{"id": idOrKey}, &out); err != nil { + return nil, nil, err + } + states := out.Team.States.Nodes + for i := range states { + states[i].TeamID = out.Team.ID + } + return &out.Team.Team, states, nil +} diff --git a/internal/linear/types.go b/internal/linear/types.go new file mode 100644 index 0000000..cdf699e --- /dev/null +++ b/internal/linear/types.go @@ -0,0 +1,159 @@ +package linear + +import ( + "encoding/json" + "time" +) + +// Linear API objects map almost 1:1 to GraphQL types. +// +// IMPORTANT — every Linear object has BOTH an `id` (UUID, e.g. +// `2b0e3c00-9c4f-4b6a-9b4f-...`) and an `identifier` (e.g. `ENG-123`). +// Operators see Identifiers everywhere; mutations require the UUID. +// Do not conflate them. `IssueByIdentifier` is a separate query. + +type Workspace struct { + ID string `json:"id"` + Name string `json:"name"` + URLKey string `json:"urlKey"` + CreatedAt time.Time `json:"createdAt"` + UserCount int `json:"userCount"` +} + +type Team struct { + ID string `json:"id"` + Key string `json:"key"` // short prefix used in identifiers e.g. "ENG" + Name string `json:"name"` + Description string `json:"description"` + CreatedAt time.Time `json:"createdAt"` +} + +// WorkflowState is one column in a team's kanban (e.g. "In Progress"). +// Type is one of "triage", "backlog", "unstarted", "started", "completed", +// "cancelled". Mutations target state by ID; the canonical workflow position +// is per-team so two teams' "In Progress" states are distinct objects. +type WorkflowState struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Color string `json:"color"` + TeamID string `json:"-"` +} + +type User struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Active bool `json:"active"` + AvatarURL string `json:"avatarUrl"` +} + +type Label struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + TeamID string `json:"-"` +} + +// Project is a delivery effort that groups issues across one or more cycles. +// Note: collides with "project" in Notion's vocabulary — be explicit in UI. +type Project struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + State string `json:"state"` // backlog, planned, started, paused, completed, canceled + Progress float64 `json:"progress"` + StartDate *time.Time `json:"startDate"` + TargetDate *time.Time `json:"targetDate"` + CreatedAt time.Time `json:"createdAt"` + URL string `json:"url"` + LeadID string `json:"-"` +} + +// Cycle is a time-boxed iteration (a sprint) belonging to a single team. +type Cycle struct { + ID string `json:"id"` + Number int `json:"number"` + Name string `json:"name"` + StartsAt time.Time `json:"startsAt"` + EndsAt time.Time `json:"endsAt"` + CompletedAt *time.Time `json:"completedAt"` + Progress float64 `json:"progress"` + TeamID string `json:"-"` +} + +// Issue is the central work unit. ShortID is what Linear calls the +// "identifier" — operator-facing (e.g. "ENG-123"). ID is the UUID required +// for any mutation. +type Issue struct { + ID string `json:"id"` + Identifier string `json:"identifier"` // human-facing, e.g. "ENG-42" + Title string `json:"title"` + Description string `json:"description"` + Priority int `json:"priority"` // 0 (none) | 1 (urgent) | 2 (high) | 3 (medium) | 4 (low) + Estimate float64 `json:"estimate"` + URL string `json:"url"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + StartedAt *time.Time `json:"startedAt"` + CompletedAt *time.Time `json:"completedAt"` + CanceledAt *time.Time `json:"canceledAt"` + DueDate *time.Time `json:"dueDate"` + + // Nested via GraphQL — populated by the query, not separate calls. + State *WorkflowState `json:"state,omitempty"` + Team *Team `json:"team,omitempty"` + Project *Project `json:"project,omitempty"` + Cycle *Cycle `json:"cycle,omitempty"` + Assignee *User `json:"assignee,omitempty"` + Creator *User `json:"creator,omitempty"` + Labels struct { + Nodes []Label `json:"nodes"` + } `json:"labels"` +} + +// Comment is a top-level comment on an issue. Threads (replies) are +// represented via Parent — for MVP we only render top-level comments and +// flatten replies into the same view. +type Comment struct { + ID string `json:"id"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + URL string `json:"url"` + User *User `json:"user,omitempty"` + IssueID string `json:"-"` +} + +// Document is a free-form doc attached to a project or team. Body is +// Linear's rich-text JSON which we expose as-is for now — rendering it +// nicely in the desktop UI is PR4 territory (parallels Notion blocks). +type Document struct { + ID string `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + Content json.RawMessage `json:"content"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ProjectID string `json:"-"` +} + +// PageInfo is the Relay-style cursor for Linear's connection types. +// We always pass `first` and read `endCursor`; backwards pagination +// is not used. +type PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` +} + +// AccountStatus is the at-a-glance snapshot the ask command stashes in +// conversation history so follow-ups can be answered without re-fetching. +type AccountStatus struct { + Timestamp time.Time `json:"timestamp"` + WorkspaceID string `json:"workspace_id"` + WorkspaceName string `json:"workspace_name,omitempty"` + TeamCount int `json:"team_count"` + StartedIssueCount int `json:"started_issue_count"` + ActiveProjectCount int `json:"active_project_count"` +} diff --git a/internal/linear/users.go b/internal/linear/users.go new file mode 100644 index 0000000..e3de2e6 --- /dev/null +++ b/internal/linear/users.go @@ -0,0 +1,45 @@ +package linear + +import "context" + +const queryUsers = ` +query Users($first: Int!) { + users(first: $first) { + nodes { + id + name + displayName + email + active + avatarUrl + } + } +}` + +func (c *Client) ListUsers(ctx context.Context) ([]User, error) { + var out struct { + Users struct { + Nodes []User `json:"nodes"` + } `json:"users"` + } + if err := c.Do(ctx, queryUsers, map[string]any{"first": 250}, &out); err != nil { + return nil, err + } + return out.Users.Nodes, nil +} + +// FindUserByDisplayName scans the user list for an exact match. Used by +// the assign command which takes a username. For large workspaces this is +// O(n) but n is bounded by the workspace's user count (typically <500). +func (c *Client) FindUserByDisplayName(ctx context.Context, displayName string) (*User, error) { + users, err := c.ListUsers(ctx) + if err != nil { + return nil, err + } + for i, u := range users { + if u.DisplayName == displayName || u.Email == displayName || u.Name == displayName { + return &users[i], nil + } + } + return nil, nil +} diff --git a/internal/linear/workspaces.go b/internal/linear/workspaces.go new file mode 100644 index 0000000..b558cad --- /dev/null +++ b/internal/linear/workspaces.go @@ -0,0 +1,45 @@ +package linear + +import "context" + +const queryWorkspace = ` +query Workspace { + viewer { + id + name + displayName + email + organization { + id + name + urlKey + createdAt + userCount + } + } +}` + +// GetWorkspace returns the workspace the API key belongs to plus the viewer +// (the user the key was issued for). Linear calls the workspace +// `viewer.organization` — there's no direct "current workspace" query. +func (c *Client) GetWorkspace(ctx context.Context) (*Workspace, *User, error) { + var out struct { + Viewer struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Organization Workspace `json:"organization"` + } `json:"viewer"` + } + if err := c.Do(ctx, queryWorkspace, nil, &out); err != nil { + return nil, nil, err + } + return &out.Viewer.Organization, &User{ + ID: out.Viewer.ID, + Name: out.Viewer.Name, + DisplayName: out.Viewer.DisplayName, + Email: out.Viewer.Email, + Active: true, + }, nil +} From 3061f166dc03a4636c47bf051ecc80752bdf8172 Mon Sep 17 00:00:00 2001 From: nash Date: Tue, 2 Jun 2026 15:41:24 +0500 Subject: [PATCH 2/2] =?UTF-8?q?fix(linear):=20review=20pass=20=E2=80=94=20?= =?UTF-8?q?identifier=20resolver,=20flag=20shadowing,=20dead=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-merge review found four real bugs. Backend (internal/linear): - issues.go: AssigneeMe was declared but never read in toGraphQL — callers setting it got silent no-filtering. Renamed to AssigneeIsMe and wired to `assignee.isMe.eq: true`. AssigneeID takes precedence when both are set. - issues.go: ResolveIssueID resolves human identifiers (ENG-42) to the UUIDs Linear's mutations actually require. The mutation endpoints silently 4xx on identifiers — agents will pass them, so resolve at the boundary. - client.go: parseAPIError caps the raw body at 512 bytes so a WAF/CDN HTML response (10KB+ of marketing) doesn't bloat every log line that prints err.Error(). - issues.go: queryIssueComments doc says "newest first" but the query orders ASC. Fixed the docstring to match the query. CLI (cmd/linear.go + internal/linear/static_commands.go): - linear.go: dropped the local --api-key / --workspace / --team flags that were shadowing the parent's persistent flags. `clanker linear --api-key X ask "q"` was silently ignoring the explicit flag because Cobra resolves the local declaration first. - linear.go: gatherLinearContext switched from substring match ("my" matched "myql"/"company") to a word-boundary regex helper. - static_commands.go: assign / comment / update issue / resolve all pipe user input through ResolveIssueID first. The resolve command had an additional bug — used the original `id` argument in the UpdateIssue call instead of issue.ID after a successful GetIssue, so `resolve ENG-42` would GET-OK then PUT-fail. - static_commands.go: updateIssuesToStateType caches the team→state lookup so a batch like `resolve ENG-1 ENG-2 ENG-3` makes one GetTeam call, not three. - static_commands.go: buildLabelCommand uses a named createCmd variable instead of the fragile lc.Commands()[0] indexing. MCP (cmd/mcp_linear.go): - update_issue and comment_issue handlers resolve the issueId before calling Linear so agents passing "ENG-42" work end-to-end. - All 5 mutation tools now carry mcp.WithDestructiveHintAnnotation(true) so MCP clients prompt for confirmation. Sentry's resolve/ignore is the same level of impact; Linear's writes create user-visible changes (assignment notifications, comments) and deserve the gate. Docs (.clanker.example.yaml): - Document the infra:: annotation label convention that the desktop PR will use to bridge cloud resources to Linear issues. - Note the conversation-history file path so operators can clear it. Tests: - TestFilterToGraphQL_AssigneeIsMe covers the new clause + the AssigneeID precedence rule. - TestResolveIssueID exercises UUID passthrough vs ENG-42 lookup. --- .clanker.example.yaml | 10 +++++ cmd/linear.go | 60 ++++++++++++++++++-------- cmd/mcp_linear.go | 23 ++++++++-- internal/linear/client.go | 8 +++- internal/linear/client_test.go | 52 ++++++++++++++++++++++ internal/linear/issues.go | 34 +++++++++++++-- internal/linear/static_commands.go | 69 +++++++++++++++++++----------- 7 files changed, 204 insertions(+), 52 deletions(-) diff --git a/.clanker.example.yaml b/.clanker.example.yaml index 88a7ca7..430f1a1 100644 --- a/.clanker.example.yaml +++ b/.clanker.example.yaml @@ -216,6 +216,16 @@ infra: # # the API key already implies a single workspace. # default_team: "" # Default team key e.g. "ENG" (or set LINEAR_TEAM) # # Used by `clanker linear ask` to scope cycles + filters. +# +# Infra-resource annotation convention: +# Tag Linear issues that reference a cloud resource with a label of the +# form `infra::` (e.g. `infra:lambda:arn:aws:lambda:us-east-1:123:foo`). +# The desktop app's resource drawer queries `/api/linear/issues/by-label/...` +# to surface linked work on a resource — without the label there's nothing +# to link. Create via `clanker linear label create infra:lambda:arn:... `. +# +# Conversation history: ~/.clanker/linear-{workspaceID}.json (or {team} when +# workspace_id is empty). Delete to start fresh. # Verda Cloud (for `clanker verda ...` and `clanker ask --verda ...`): # verda: diff --git a/cmd/linear.go b/cmd/linear.go index 06b18b0..fbcd717 100644 --- a/cmd/linear.go +++ b/cmd/linear.go @@ -3,6 +3,7 @@ package cmd import ( "context" "fmt" + "regexp" "strings" "time" @@ -13,6 +14,19 @@ import ( "golang.org/x/sync/errgroup" ) +// containsWord matches needles as whole words inside s. Plain substring +// match (containsAny) trips on "my" inside "company" / "summary" / "myql" +// — wrong for keyword routing where the words are conceptually nouns. +func containsWord(s string, needles []string) bool { + for _, n := range needles { + re := regexp.MustCompile(`(^|\W)` + regexp.QuoteMeta(n) + `($|\W)`) + if re.MatchString(s) { + return true + } + } + return false +} + var linearAskCmd = &cobra.Command{ Use: "ask [question]", Short: "Ask natural-language questions about your Linear workspace", @@ -31,18 +45,17 @@ Examples: RunE: runLinearAsk, } +// Local-only flags for the ask subcommand. --api-key / --workspace / --team +// are persistent on the parent `linear` command (see static_commands.go) +// and reach this handler via linear.linearFlag() / linear.Resolve*() — +// declaring them here too would silently shadow the parent's persistent +// flag, so we deliberately do NOT. var ( - linearAskAPIKey string - linearAskWorkspaceID string - linearAskTeam string - linearAskAIProfile string - linearAskDebug bool + linearAskAIProfile string + linearAskDebug bool ) func init() { - linearAskCmd.Flags().StringVar(&linearAskAPIKey, "api-key", "", "Linear Personal API Key") - linearAskCmd.Flags().StringVar(&linearAskWorkspaceID, "workspace", "", "Workspace ID") - linearAskCmd.Flags().StringVar(&linearAskTeam, "team", "", "Default team key (e.g. ENG)") linearAskCmd.Flags().StringVar(&linearAskAIProfile, "ai-profile", "", "AI profile to use for LLM queries") linearAskCmd.Flags().BoolVar(&linearAskDebug, "debug", false, "Enable debug output") } @@ -60,7 +73,17 @@ func runLinearAsk(cmd *cobra.Command, args []string) error { debug := linearAskDebug || viper.GetBool("debug") - apiKey := linearAskAPIKey + // Persistent flags on the `linear` parent (--api-key / --workspace / + // --team) reach us via inherited flag-set merging. cmd.Flags().Lookup + // returns the parent's value; falling back to viper config + env. + flag := func(name string) string { + if f := cmd.Flags().Lookup(name); f != nil { + return f.Value.String() + } + return "" + } + + apiKey := flag("api-key") if apiKey == "" { apiKey = linear.ResolveAPIKey() } @@ -68,12 +91,12 @@ func runLinearAsk(cmd *cobra.Command, args []string) error { return fmt.Errorf("linear api_key is required (set --api-key, LINEAR_API_KEY, or linear.api_key in config)") } - workspaceID := linearAskWorkspaceID + workspaceID := flag("workspace") if workspaceID == "" { workspaceID = linear.ResolveWorkspaceID() } - team := linearAskTeam + team := flag("team") if team == "" { team = linear.ResolveDefaultTeam() } @@ -118,8 +141,8 @@ func runLinearAsk(cmd *cobra.Command, args []string) error { if aiProfile == "" { aiProfile = viper.GetString("ai.default_provider") } - apiKey2 := resolveAIKeyForProfile(aiProfile) - aiClient := ai.NewClient(aiProfile, apiKey2, debug) + aiKey := resolveAIKeyForProfile(aiProfile) + aiClient := ai.NewClient(aiProfile, aiKey, debug) answer, err := aiClient.AskPrompt(ctx, prompt) if err != nil { @@ -142,11 +165,12 @@ func runLinearAsk(cmd *cobra.Command, args []string) error { func gatherLinearContext(ctx context.Context, client *linear.Client, question, team string, debug bool) (string, error) { q := strings.ToLower(question) - wantIssues := containsAny(q, []string{"issue", "bug", "task", "ticket", "blocker", "work", "plate", "mine", "my", "assigned"}) - wantProjects := containsAny(q, []string{"project", "initiative", "delivery", "milestone"}) - wantCycles := containsAny(q, []string{"cycle", "sprint", "iteration"}) - wantTeams := containsAny(q, []string{"team", "squad"}) - wantLabels := containsAny(q, []string{"label", "tag"}) + // Match whole words so "my" doesn't trigger on "myql"/"company"/"summary". + wantIssues := containsWord(q, []string{"issue", "issues", "bug", "bugs", "task", "tasks", "ticket", "tickets", "blocker", "blockers", "work", "plate", "mine", "my", "assigned"}) + wantProjects := containsWord(q, []string{"project", "projects", "initiative", "initiatives", "delivery", "milestone", "milestones"}) + wantCycles := containsWord(q, []string{"cycle", "cycles", "sprint", "sprints", "iteration", "iterations"}) + wantTeams := containsWord(q, []string{"team", "teams", "squad", "squads"}) + wantLabels := containsWord(q, []string{"label", "labels", "tag", "tags"}) if !wantIssues && !wantProjects && !wantCycles && !wantTeams && !wantLabels { // Default: "what's on my plate" — open issues + active projects + current cycle. diff --git a/cmd/mcp_linear.go b/cmd/mcp_linear.go index 4e6d405..14af544 100644 --- a/cmd/mcp_linear.go +++ b/cmd/mcp_linear.go @@ -134,6 +134,7 @@ func registerLinearMCPTools(server *mcptransport.MCPServer) { mcp.WithString("assigneeId", mcp.Description("Assignee user UUID")), mcp.WithNumber("priority", mcp.Description("1 (urgent) | 2 (high) | 3 (medium) | 4 (low); 0 omitted")), mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithDestructiveHintAnnotation(true), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return handleMCPLinearCreateIssue(ctx, req) @@ -153,6 +154,7 @@ func registerLinearMCPTools(server *mcptransport.MCPServer) { mcp.WithString("cycleId", mcp.Description("Move to this cycle (UUID)")), mcp.WithNumber("priority", mcp.Description("1|2|3|4")), mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithDestructiveHintAnnotation(true), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return handleMCPLinearUpdateIssue(ctx, req) @@ -166,6 +168,7 @@ func registerLinearMCPTools(server *mcptransport.MCPServer) { mcp.WithString("issueId", mcp.Required(), mcp.Description("Issue UUID")), mcp.WithString("body", mcp.Required(), mcp.Description("Markdown body")), mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithDestructiveHintAnnotation(true), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return handleMCPLinearCommentIssue(ctx, req) @@ -182,6 +185,7 @@ func registerLinearMCPTools(server *mcptransport.MCPServer) { mcp.WithString("leadId", mcp.Description("Lead user UUID")), mcp.WithString("state", mcp.Description("backlog|planned|started|paused|completed|canceled")), mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithDestructiveHintAnnotation(true), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return handleMCPLinearCreateProject(ctx, req) @@ -200,6 +204,7 @@ func registerLinearMCPTools(server *mcptransport.MCPServer) { mcp.WithString("startDate", mcp.Description("YYYY-MM-DD")), mcp.WithString("targetDate", mcp.Description("YYYY-MM-DD")), mcp.WithString("apiKey", mcp.Description("Linear API key")), + mcp.WithDestructiveHintAnnotation(true), ), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { return handleMCPLinearUpdateProject(ctx, req) @@ -388,14 +393,20 @@ func handleMCPLinearCreateIssue(ctx context.Context, req mcp.CallToolRequest) (* } func handleMCPLinearUpdateIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - id := strParam(req, "issueId") - if id == "" { + idIn := strParam(req, "issueId") + if idIn == "" { return mcp.NewToolResultError("issueId is required"), nil } client, _, err := mcpLinearClient(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + // Agents will pass either ENG-42 or a UUID. issueUpdate only accepts + // the UUID, so resolve first. + id, err := client.ResolveIssueID(ctx, idIn) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } input := linear.UpdateIssueInput{} if v := strParam(req, "title"); v != "" { input.Title = &v @@ -426,15 +437,19 @@ func handleMCPLinearUpdateIssue(ctx context.Context, req mcp.CallToolRequest) (* } func handleMCPLinearCommentIssue(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - id := strParam(req, "issueId") + idIn := strParam(req, "issueId") body := strParam(req, "body") - if id == "" || body == "" { + if idIn == "" || body == "" { return mcp.NewToolResultError("issueId and body are required"), nil } client, _, err := mcpLinearClient(req) if err != nil { return mcp.NewToolResultError(err.Error()), nil } + id, err := client.ResolveIssueID(ctx, idIn) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } c, err := client.AddComment(ctx, id, body) if err != nil { return mcp.NewToolResultError(err.Error()), nil diff --git a/internal/linear/client.go b/internal/linear/client.go index b841287..4fb187e 100644 --- a/internal/linear/client.go +++ b/internal/linear/client.go @@ -247,7 +247,13 @@ func parseAPIError(resp *http.Response, body []byte) error { Errors []GraphQLError `json:"errors"` } _ = json.Unmarshal(body, &env) - return &APIError{Status: resp.StatusCode, Body: string(body), Errors: env.Errors} + // Cap the raw body in the APIError so a WAF/CDN HTML response (10KB+ + // of marketing) doesn't bloat every log line that prints err.Error(). + preview := string(body) + if len(preview) > 512 { + preview = preview[:512] + "..." + } + return &APIError{Status: resp.StatusCode, Body: preview, Errors: env.Errors} } func parseRetryWait(resp *http.Response) time.Duration { diff --git a/internal/linear/client_test.go b/internal/linear/client_test.go index 3c6fecf..8be6a52 100644 --- a/internal/linear/client_test.go +++ b/internal/linear/client_test.go @@ -163,6 +163,58 @@ func TestFilterToGraphQL(t *testing.T) { } } +// TestFilterToGraphQL_AssigneeIsMe confirms the previously-dead +// AssigneeIsMe flag now produces the correct `assignee.isMe.eq: true` +// clause that Linear's IssueFilter expects. +func TestFilterToGraphQL_AssigneeIsMe(t *testing.T) { + g := IssueFilter{AssigneeIsMe: true}.toGraphQL() + isMe := g["assignee"].(map[string]any)["isMe"].(map[string]any) + if isMe["eq"] != true { + t.Errorf("assignee.isMe.eq = %v, want true", isMe["eq"]) + } + // AssigneeID takes precedence over AssigneeIsMe when both are set. + g2 := IssueFilter{AssigneeID: "u-1", AssigneeIsMe: true}.toGraphQL() + if _, ok := g2["assignee"].(map[string]any)["isMe"]; ok { + t.Errorf("AssigneeID should override AssigneeIsMe, got isMe still set: %v", g2["assignee"]) + } +} + +// TestResolveIssueID exercises the identifier→UUID resolver. Linear's +// mutations accept only UUIDs but `issue(id: ENG-42)` also works, so +// human inputs need translation. Tests cover: passthrough on UUID, +// resolution on identifier, malformed strings stay unchanged. +func TestResolveIssueID(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload struct { + Variables map[string]any `json:"variables"` + } + _ = json.Unmarshal(body, &payload) + // Echo the requested identifier back as a UUID lookup result. + if got, _ := payload.Variables["id"].(string); got == "ENG-42" { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":{"issue":{"id":"uuid-from-eng-42","identifier":"ENG-42","title":"t","priority":0,"createdAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","labels":{"nodes":[]}}}}`) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":{"issue":null}}`) + })) + defer ts.Close() + c := newTestClient(t, ts) + + // UUID: returned unchanged, no API call. + uuid := "2b0e3c00-9c4f-4b6a-9b4f-deadbeef0001" + got, err := c.ResolveIssueID(context.Background(), uuid) + if err != nil || got != uuid { + t.Errorf("UUID passthrough: got=%q err=%v", got, err) + } + // Identifier: resolved via GET issue query. + got, err = c.ResolveIssueID(context.Background(), "ENG-42") + if err != nil || got != "uuid-from-eng-42" { + t.Errorf("ENG-42 resolve: got=%q err=%v", got, err) + } +} + // TestUpdateIssue_PartialPatch confirms the typed pointers in // UpdateIssueInput serialise correctly — only non-nil fields land in the // GraphQL variables, so callers can ship a one-field patch without nuking diff --git a/internal/linear/issues.go b/internal/linear/issues.go index 3468a08..beddb09 100644 --- a/internal/linear/issues.go +++ b/internal/linear/issues.go @@ -3,8 +3,32 @@ package linear import ( "context" "fmt" + "regexp" ) +// identifierPattern matches Linear's human identifier like "ENG-42". Linear +// slugs are 2-5 uppercase letters; we allow longer for safety against future +// changes but disallow the lowercase that would shadow a UUID's 8-4-4-4-12 form. +var identifierPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]*-\d+$`) + +// ResolveIssueID accepts either a UUID or a human identifier (ENG-42) and +// returns the UUID. Linear's `issue(id:)` query accepts either form so this +// is cheap; we make it explicit because mutation endpoints (`issueUpdate`, +// `commentCreate`) only accept the UUID and silently 4xx on identifiers. +func (c *Client) ResolveIssueID(ctx context.Context, idOrIdentifier string) (string, error) { + if idOrIdentifier == "" { + return "", fmt.Errorf("issue id required") + } + if !identifierPattern.MatchString(idOrIdentifier) { + return idOrIdentifier, nil // assume already a UUID + } + issue, err := c.GetIssue(ctx, idOrIdentifier) + if err != nil { + return "", fmt.Errorf("resolve %s to UUID: %w", idOrIdentifier, err) + } + return issue.ID, nil +} + // IssueFilter mirrors a subset of Linear's GraphQL IssueFilter input. // Empty fields are omitted from the marshalled GraphQL variables. The // shape is intentionally narrow — we expose the filters the ask flow @@ -15,8 +39,8 @@ type IssueFilter struct { TeamKey string // e.g. "ENG" — convenience for the CLI ProjectID string CycleID string - AssigneeID string // UUID; or use AssigneeMe - AssigneeMe bool // shortcut: filter to issues assigned to viewer + AssigneeID string // UUID + AssigneeIsMe bool // filter to issues assigned to the API key's viewer LabelName string // exact label name (used by annotation lookup) LabelIDs []string // multi-label match Priority int // 0..4; 0 means "any" @@ -43,6 +67,8 @@ func (f IssueFilter) toGraphQL() map[string]any { } if f.AssigneeID != "" { out["assignee"] = map[string]any{"id": map[string]any{"eq": f.AssigneeID}} + } else if f.AssigneeIsMe { + out["assignee"] = map[string]any{"isMe": map[string]any{"eq": true}} } if f.LabelName != "" { out["labels"] = map[string]any{"name": map[string]any{"eq": f.LabelName}} @@ -152,7 +178,9 @@ func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { return out.Issue, nil } -// GetIssueComments returns top-level comments for an issue, newest first. +// GetIssueComments returns top-level comments for an issue in creation order +// (oldest first — matches Linear's GraphQL `orderBy: createdAt` ascending +// default and lets the prompt builder render the thread linearly). func (c *Client) GetIssueComments(ctx context.Context, issueID string, limit int) ([]Comment, error) { if limit <= 0 { limit = 50 diff --git a/internal/linear/static_commands.go b/internal/linear/static_commands.go index 82c9e48..2f93c65 100644 --- a/internal/linear/static_commands.go +++ b/internal/linear/static_commands.go @@ -153,7 +153,11 @@ func buildAssignCommand() *cobra.Command { if user == nil { return fmt.Errorf("no user matched %q", args[1]) } - issue, err := client.UpdateIssue(ctx, args[0], UpdateIssueInput{AssigneeID: &user.ID}) + id, err := client.ResolveIssueID(ctx, args[0]) + if err != nil { + return err + } + issue, err := client.UpdateIssue(ctx, id, UpdateIssueInput{AssigneeID: &user.ID}) if err != nil { return err } @@ -175,7 +179,11 @@ func buildCommentCommand() *cobra.Command { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - c, err := client.AddComment(ctx, args[0], args[1]) + id, err := client.ResolveIssueID(ctx, args[0]) + if err != nil { + return err + } + c, err := client.AddComment(ctx, id, args[1]) if err != nil { return err } @@ -343,7 +351,11 @@ func buildUpdateCommand() *cobra.Command { } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - issue, err := client.UpdateIssue(ctx, args[0], input) + id, err := client.ResolveIssueID(ctx, args[0]) + if err != nil { + return err + } + issue, err := client.UpdateIssue(ctx, id, input) if err != nil { return err } @@ -368,7 +380,7 @@ func buildLabelCommand() *cobra.Command { Use: "label", Short: "Manage issue labels", } - lc.AddCommand(&cobra.Command{ + createCmd := &cobra.Command{ Use: "create ", Short: "Create a label on a team. Useful for the infra:: annotation convention.", Args: cobra.ExactArgs(2), @@ -387,10 +399,9 @@ func buildLabelCommand() *cobra.Command { fmt.Printf("Created label %s (%s)\n", lbl.Name, lbl.ID) return nil }, - }) - if create := lc.Commands()[0]; create != nil { - create.Flags().String("color", "", "Hex color e.g. #5e6ad2 (optional)") } + createCmd.Flags().String("color", "", "Hex color e.g. #5e6ad2 (optional)") + lc.AddCommand(createCmd) return lc } @@ -402,32 +413,38 @@ func updateIssuesToStateType(cmd *cobra.Command, ids []string, targetType string ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - // Linear doesn't have a built-in "set state to first 'completed' state - // on this issue's team" — we have to look it up. To keep it simple we - // fetch each issue, ask its team for states, pick the matching one. - for _, id := range ids { - issue, err := client.GetIssue(ctx, id) + // Linear has no native "set to first 'completed'-type state on this + // issue's team" — we resolve target states per team. Cache by team ID + // so a batch like `resolve ENG-1 ENG-2 ENG-3` makes one GetTeam call, + // not three. ID input may be UUID or identifier — GetIssue accepts both + // but the mutation requires the UUID, so we use issue.ID below. + teamStates := make(map[string]string) // teamID → target stateID + for _, idOrIdent := range ids { + issue, err := client.GetIssue(ctx, idOrIdent) if err != nil { - return fmt.Errorf("get %s: %w", id, err) + return fmt.Errorf("get %s: %w", idOrIdent, err) } if issue.Team == nil { return fmt.Errorf("%s has no team — cannot pick target state", issue.Identifier) } - _, states, err := client.GetTeam(ctx, issue.Team.ID) - if err != nil { - return fmt.Errorf("get team for %s: %w", issue.Identifier, err) - } - var stateID string - for _, s := range states { - if s.Type == targetType { - stateID = s.ID - break + stateID, ok := teamStates[issue.Team.ID] + if !ok { + _, states, err := client.GetTeam(ctx, issue.Team.ID) + if err != nil { + return fmt.Errorf("get team for %s: %w", issue.Identifier, err) } + for _, s := range states { + if s.Type == targetType { + stateID = s.ID + break + } + } + if stateID == "" { + return fmt.Errorf("no %q-type state found on team %s", targetType, issue.Team.Key) + } + teamStates[issue.Team.ID] = stateID } - if stateID == "" { - return fmt.Errorf("no %q-type state found on team %s", targetType, issue.Team.Key) - } - if _, err := client.UpdateIssue(ctx, id, UpdateIssueInput{StateID: &stateID}); err != nil { + if _, err := client.UpdateIssue(ctx, issue.ID, UpdateIssueInput{StateID: &stateID}); err != nil { return fmt.Errorf("update %s: %w", issue.Identifier, err) } fmt.Printf("Moved %s → %s\n", issue.Identifier, targetType)