Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .clanker.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,28 @@ infra:
# host: "sentry.io" # Override for self-hosted Sentry (or set SENTRY_HOST)
# # Single-tenant EU customers use "<org>.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.
#
# Infra-resource annotation convention:
# Tag Linear issues that reference a cloud resource with a label of the
# form `infra:<type>:<id>` (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:... <team-id>`.
#
# 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:
# client_id: "" # Verda OAuth2 client ID (or set VERDA_CLIENT_ID, or run `verda auth login`)
Expand Down
354 changes: 354 additions & 0 deletions cmd/linear.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,354 @@
package cmd

import (
"context"
"fmt"
"regexp"
"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"
)

// 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",
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,
}

// 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 (
linearAskAIProfile string
linearAskDebug bool
)

func init() {
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")

// 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()
}
if apiKey == "" {
return fmt.Errorf("linear api_key is required (set --api-key, LINEAR_API_KEY, or linear.api_key in config)")
}

workspaceID := flag("workspace")
if workspaceID == "" {
workspaceID = linear.ResolveWorkspaceID()
}

team := flag("team")
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")
}
aiKey := resolveAIKeyForProfile(aiProfile)
aiClient := ai.NewClient(aiProfile, aiKey, 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)

// 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.
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()
}
1 change: 1 addition & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ func newClankerMCPServer() *mcptransport.MCPServer {

registerSentryMCPTools(server)
registerTencentMCPTools(server)
registerLinearMCPTools(server)
registerK8sMCPTools(server)

return server
Expand Down
Loading
Loading