From d49f5bc2be28b3835042ccd2073a2711b24dcb60 Mon Sep 17 00:00:00 2001 From: Stuart Fenton Date: Wed, 7 Jan 2026 20:10:22 +0000 Subject: [PATCH] feat: add agent support with delegation, labels, and mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `--delegate` flag to assign issues to agents - Add `--label` flag for issue creation (repeatable, case-insensitive) - Add `linctl agent ` to view agent session status and activities - Add `linctl agent mention ` to @mention agents - Add GetTeamLabels(), GetOrganization(), MentionAgent(), DeleteComment() API functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/agent.go | 290 ++++++++++++++++++++++++++++++++++++++++ cmd/agent_test.go | 51 +++++++ cmd/comment.go | 34 +++++ cmd/comment_test.go | 133 ++++++++++++++++++ cmd/issue.go | 59 ++++++++ cmd/issue_test.go | 105 +++++++++++++++ pkg/api/queries.go | 268 +++++++++++++++++++++++++++++++++++-- pkg/api/queries_test.go | 221 ++++++++++++++++++++++++++++++ 8 files changed, 1153 insertions(+), 8 deletions(-) create mode 100644 cmd/agent.go create mode 100644 cmd/agent_test.go create mode 100644 cmd/comment_test.go create mode 100644 cmd/issue_test.go create mode 100644 pkg/api/queries_test.go diff --git a/cmd/agent.go b/cmd/agent.go new file mode 100644 index 0000000..58c489c --- /dev/null +++ b/cmd/agent.go @@ -0,0 +1,290 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/dorkitude/linctl/pkg/api" + "github.com/dorkitude/linctl/pkg/auth" + "github.com/dorkitude/linctl/pkg/output" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +var agentCmd = &cobra.Command{ + Use: "agent [issue-id]", + Short: "View agent session for an issue", + Long: `View the agent session status and activity stream for an issue. + +Examples: + linctl agent ENG-80 # View agent session + linctl agent ENG-80 --json # Output as JSON`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error("Not authenticated. Run 'linctl auth' first.", plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + issue, err := client.GetIssueAgentSession(context.Background(), args[0]) + if err != nil { + output.Error(fmt.Sprintf("Failed to fetch issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Find agent session from comments + var session *api.AgentSession + if issue.Comments != nil { + for _, comment := range issue.Comments.Nodes { + if comment.AgentSession != nil { + session = comment.AgentSession + break + } + } + } + + // Check if there's a delegate but no session yet + if session == nil && issue.Delegate == nil { + output.Info(fmt.Sprintf("No agent session found for %s", issue.Identifier), plaintext, jsonOut) + return + } + + if jsonOut { + result := map[string]interface{}{ + "issue": issue.Identifier, + "title": issue.Title, + } + if issue.Delegate != nil { + result["delegate"] = issue.Delegate + } + if session != nil { + result["agentSession"] = session + } + output.JSON(result) + return + } + + if plaintext { + fmt.Printf("# Agent Session for %s\n\n", issue.Identifier) + fmt.Printf("**Title**: %s\n", issue.Title) + if issue.Delegate != nil { + fmt.Printf("**Delegate**: %s (%s)\n", issue.Delegate.Name, issue.Delegate.DisplayName) + } + if session != nil { + fmt.Printf("**Status**: %s\n", session.Status) + if session.AppUser != nil { + fmt.Printf("**Agent**: %s (%s)\n", session.AppUser.Name, session.AppUser.DisplayName) + } + fmt.Printf("**Started**: %s\n", session.CreatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf("**Updated**: %s\n", session.UpdatedAt.Format("2006-01-02 15:04:05")) + + if session.Activities != nil && len(session.Activities.Nodes) > 0 { + fmt.Printf("\n## Activity Stream\n\n") + for _, activity := range session.Activities.Nodes { + activityType := "unknown" + body := "" + if t, ok := activity.Content["type"].(string); ok { + activityType = t + } + if b, ok := activity.Content["body"].(string); ok { + body = b + } else if action, ok := activity.Content["action"].(string); ok { + param, _ := activity.Content["parameter"].(string) + body = fmt.Sprintf("%s: %s", action, param) + } + fmt.Printf("### [%s] %s\n", activityType, activity.CreatedAt.Format("15:04:05")) + if body != "" { + fmt.Printf("%s\n\n", body) + } + } + } + } else { + fmt.Printf("**Status**: delegated (no session yet)\n") + } + return + } + + // Rich display + fmt.Printf("%s %s\n", + color.New(color.FgCyan, color.Bold).Sprint(issue.Identifier), + color.New(color.FgWhite, color.Bold).Sprint(issue.Title)) + + // Delegate info + if issue.Delegate != nil { + fmt.Printf("\n%s %s\n", + color.New(color.FgYellow).Sprint("Delegate:"), + color.New(color.FgCyan).Sprint(issue.Delegate.DisplayName)) + } + + if session == nil { + fmt.Printf("\n%s\n", color.New(color.FgWhite, color.Faint).Sprint("Delegated but no session started yet")) + return + } + + // Status with color + statusColor := color.New(color.FgWhite) + switch session.Status { + case "active": + statusColor = color.New(color.FgGreen) + case "complete": + statusColor = color.New(color.FgBlue) + case "awaitingInput": + statusColor = color.New(color.FgYellow) + case "error": + statusColor = color.New(color.FgRed) + case "pending": + statusColor = color.New(color.FgMagenta) + } + + fmt.Printf("%s %s\n", + color.New(color.FgYellow).Sprint("Status:"), + statusColor.Sprint(session.Status)) + + if session.AppUser != nil { + fmt.Printf("%s %s\n", + color.New(color.FgYellow).Sprint("Agent:"), + color.New(color.FgCyan).Sprint(session.AppUser.DisplayName)) + } + + fmt.Printf("%s %s\n", + color.New(color.FgYellow).Sprint("Started:"), + session.CreatedAt.Format("2006-01-02 15:04:05")) + + // Activity stream + if session.Activities != nil && len(session.Activities.Nodes) > 0 { + fmt.Printf("\n%s\n", color.New(color.FgYellow, color.Bold).Sprint("Activity Stream:")) + + for _, activity := range session.Activities.Nodes { + activityType := "unknown" + body := "" + if t, ok := activity.Content["type"].(string); ok { + activityType = t + } + // Get body or action+parameter depending on type + if b, ok := activity.Content["body"].(string); ok { + body = b + } else if action, ok := activity.Content["action"].(string); ok { + param, _ := activity.Content["parameter"].(string) + body = fmt.Sprintf("%s: %s", action, param) + } + + // Color by type + typeColor := color.New(color.FgWhite) + switch activityType { + case "thought": + typeColor = color.New(color.FgMagenta) + case "response": + typeColor = color.New(color.FgGreen) + case "action": + typeColor = color.New(color.FgBlue) + case "error": + typeColor = color.New(color.FgRed) + } + + timestamp := color.New(color.FgWhite, color.Faint).Sprint(activity.CreatedAt.Format("15:04:05")) + fmt.Printf("\n %s [%s]\n", timestamp, typeColor.Sprint(activityType)) + + if body != "" { + // Indent body text + lines := strings.Split(body, "\n") + for _, line := range lines { + if len(line) > 80 { + line = line[:77] + "..." + } + fmt.Printf(" %s\n", line) + } + } + } + + if session.Activities.PageInfo.HasNextPage { + fmt.Printf("\n%s More activities available\n", + color.New(color.FgYellow).Sprint("â„šī¸")) + } + } else { + fmt.Printf("\n%s\n", color.New(color.FgWhite, color.Faint).Sprint("No activities yet")) + } + }, +} + +var agentMentionCmd = &cobra.Command{ + Use: "mention [issue-id] [message]", + Short: "@mention an agent with a message", + Long: `@mention an agent on an issue to trigger them with a message. + +Examples: + linctl agent mention ENG-80 "Fix this bug" + linctl agent mention ENG-80 "Please update the authentication flow to use JWT tokens"`, + Args: cobra.ExactArgs(2), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error("Not authenticated. Run 'linctl auth' first.", plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + issueID := args[0] + message := args[1] + + // Get the issue to find the delegated agent + issue, err := client.GetIssueAgentSession(context.Background(), issueID) + if err != nil { + output.Error(fmt.Sprintf("Failed to fetch issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Find agent display name from delegate or existing session + var agentDisplayName string + if issue.Delegate != nil && issue.Delegate.DisplayName != "" { + agentDisplayName = issue.Delegate.DisplayName + } else if issue.Comments != nil { + for _, comment := range issue.Comments.Nodes { + if comment.AgentSession != nil && comment.AgentSession.AppUser != nil { + agentDisplayName = comment.AgentSession.AppUser.DisplayName + break + } + } + } + + if agentDisplayName == "" { + output.Error(fmt.Sprintf("No agent found for %s", issueID), plaintext, jsonOut) + os.Exit(1) + } + + // @mention the agent to trigger them + commentID, err := client.MentionAgent(context.Background(), issue.ID, agentDisplayName, message) + if err != nil { + output.Error(fmt.Sprintf("Failed to mention agent: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + if jsonOut { + output.JSON(map[string]interface{}{ + "success": true, + "commentId": commentID, + "issue": issueID, + "agent": agentDisplayName, + "message": message, + }) + return + } + + fmt.Printf("✓ @%s mentioned on %s\n", agentDisplayName, issue.Identifier) + }, +} + +func init() { + rootCmd.AddCommand(agentCmd) + agentCmd.AddCommand(agentMentionCmd) +} diff --git a/cmd/agent_test.go b/cmd/agent_test.go new file mode 100644 index 0000000..43867f6 --- /dev/null +++ b/cmd/agent_test.go @@ -0,0 +1,51 @@ +package cmd + +import ( + "testing" +) + +func TestAgentCommandExists(t *testing.T) { + if agentCmd == nil { + t.Fatal("agentCmd should not be nil") + } + + if agentCmd.Use != "agent [issue-id]" { + t.Errorf("Expected Use 'agent [issue-id]', got '%s'", agentCmd.Use) + } + + if agentCmd.Short != "View agent session for an issue" { + t.Errorf("Expected Short description mismatch, got '%s'", agentCmd.Short) + } +} + +func TestAgentMentionCommandExists(t *testing.T) { + if agentMentionCmd == nil { + t.Fatal("agentMentionCmd should not be nil") + } + + if agentMentionCmd.Use != "mention [issue-id] [message]" { + t.Errorf("Expected Use 'mention [issue-id] [message]', got '%s'", agentMentionCmd.Use) + } + + if agentMentionCmd.Short != "@mention an agent with a message" { + t.Errorf("Expected Short '@mention an agent with a message', got '%s'", agentMentionCmd.Short) + } +} + +func TestAgentMentionRequiresTwoArgs(t *testing.T) { + err := agentMentionCmd.Args(agentMentionCmd, []string{"ENG-80"}) + if err == nil { + t.Error("Expected error with only 1 arg") + } + + err = agentMentionCmd.Args(agentMentionCmd, []string{"ENG-80", "message"}) + if err != nil { + t.Errorf("Expected no error with 2 args, got: %v", err) + } + + err = agentMentionCmd.Args(agentMentionCmd, []string{"ENG-80", "message", "extra"}) + if err == nil { + t.Error("Expected error with 3 args") + } +} + diff --git a/cmd/comment.go b/cmd/comment.go index 955a74e..42f1025 100644 --- a/cmd/comment.go +++ b/cmd/comment.go @@ -171,6 +171,39 @@ var commentCreateCmd = &cobra.Command{ }, } +var commentDeleteCmd = &cobra.Command{ + Use: "delete COMMENT-ID", + Aliases: []string{"rm"}, + Short: "Delete a comment", + Long: `Delete a comment by its ID.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + commentID := args[0] + + authHeader, err := auth.GetAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + client := api.NewClient(authHeader) + + err = client.DeleteComment(context.Background(), commentID) + if err != nil { + output.Error(fmt.Sprintf("Failed to delete comment: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + if jsonOut { + output.JSON(map[string]interface{}{"success": true, "deleted": commentID}) + } else { + fmt.Printf("✓ Deleted comment %s\n", commentID) + } + }, +} + // formatTimeAgo formats a time as a human-readable "time ago" string func formatTimeAgo(t time.Time) string { duration := time.Since(t) @@ -214,6 +247,7 @@ func init() { rootCmd.AddCommand(commentCmd) commentCmd.AddCommand(commentListCmd) commentCmd.AddCommand(commentCreateCmd) + commentCmd.AddCommand(commentDeleteCmd) // List command flags commentListCmd.Flags().IntP("limit", "l", 50, "Maximum number of comments to return") diff --git a/cmd/comment_test.go b/cmd/comment_test.go new file mode 100644 index 0000000..25abcea --- /dev/null +++ b/cmd/comment_test.go @@ -0,0 +1,133 @@ +package cmd + +import ( + "testing" +) + +func TestCommentCommandExists(t *testing.T) { + if commentCmd == nil { + t.Fatal("commentCmd should not be nil") + } + + if commentCmd.Use != "comment" { + t.Errorf("Expected Use 'comment', got '%s'", commentCmd.Use) + } + + if commentCmd.Short != "Manage issue comments" { + t.Errorf("Expected Short description mismatch, got '%s'", commentCmd.Short) + } +} + +func TestCommentListCommandExists(t *testing.T) { + if commentListCmd == nil { + t.Fatal("commentListCmd should not be nil") + } + + if commentListCmd.Use != "list ISSUE-ID" { + t.Errorf("Expected Use 'list ISSUE-ID', got '%s'", commentListCmd.Use) + } + + // Check aliases + if len(commentListCmd.Aliases) != 1 || commentListCmd.Aliases[0] != "ls" { + t.Errorf("Expected alias 'ls', got %v", commentListCmd.Aliases) + } +} + +func TestCommentListRequiresOneArg(t *testing.T) { + err := commentListCmd.Args(commentListCmd, []string{}) + if err == nil { + t.Error("Expected error with 0 args") + } + + err = commentListCmd.Args(commentListCmd, []string{"ENG-123"}) + if err != nil { + t.Errorf("Expected no error with 1 arg, got: %v", err) + } + + err = commentListCmd.Args(commentListCmd, []string{"ENG-123", "extra"}) + if err == nil { + t.Error("Expected error with 2 args") + } +} + +func TestCommentCreateCommandExists(t *testing.T) { + if commentCreateCmd == nil { + t.Fatal("commentCreateCmd should not be nil") + } + + if commentCreateCmd.Use != "create ISSUE-ID" { + t.Errorf("Expected Use 'create ISSUE-ID', got '%s'", commentCreateCmd.Use) + } + + // Check aliases + expectedAliases := []string{"add", "new"} + if len(commentCreateCmd.Aliases) != len(expectedAliases) { + t.Errorf("Expected %d aliases, got %d", len(expectedAliases), len(commentCreateCmd.Aliases)) + } +} + +func TestCommentCreateBodyFlag(t *testing.T) { + flag := commentCreateCmd.Flags().Lookup("body") + if flag == nil { + t.Fatal("commentCreateCmd should have --body flag") + } + if flag.Shorthand != "b" { + t.Errorf("Expected shorthand 'b', got '%s'", flag.Shorthand) + } +} + +func TestCommentDeleteCommandExists(t *testing.T) { + if commentDeleteCmd == nil { + t.Fatal("commentDeleteCmd should not be nil") + } + + if commentDeleteCmd.Use != "delete COMMENT-ID" { + t.Errorf("Expected Use 'delete COMMENT-ID', got '%s'", commentDeleteCmd.Use) + } + + if commentDeleteCmd.Short != "Delete a comment" { + t.Errorf("Expected Short 'Delete a comment', got '%s'", commentDeleteCmd.Short) + } + + // Check aliases + if len(commentDeleteCmd.Aliases) != 1 || commentDeleteCmd.Aliases[0] != "rm" { + t.Errorf("Expected alias 'rm', got %v", commentDeleteCmd.Aliases) + } +} + +func TestCommentDeleteRequiresOneArg(t *testing.T) { + err := commentDeleteCmd.Args(commentDeleteCmd, []string{}) + if err == nil { + t.Error("Expected error with 0 args") + } + + err = commentDeleteCmd.Args(commentDeleteCmd, []string{"comment-123"}) + if err != nil { + t.Errorf("Expected no error with 1 arg, got: %v", err) + } + + err = commentDeleteCmd.Args(commentDeleteCmd, []string{"comment-123", "extra"}) + if err == nil { + t.Error("Expected error with 2 args") + } +} + +func TestCommentListFlags(t *testing.T) { + // Check limit flag + limitFlag := commentListCmd.Flags().Lookup("limit") + if limitFlag == nil { + t.Fatal("commentListCmd should have --limit flag") + } + if limitFlag.Shorthand != "l" { + t.Errorf("Expected shorthand 'l', got '%s'", limitFlag.Shorthand) + } + + // Check sort flag + sortFlag := commentListCmd.Flags().Lookup("sort") + if sortFlag == nil { + t.Fatal("commentListCmd should have --sort flag") + } + if sortFlag.Shorthand != "o" { + t.Errorf("Expected shorthand 'o', got '%s'", sortFlag.Shorthand) + } +} diff --git a/cmd/issue.go b/cmd/issue.go index f9e35ef..ec7b04b 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -890,6 +890,46 @@ var issueCreateCmd = &cobra.Command{ input["assigneeId"] = viewer.ID } + // Handle delegate + delegate, _ := cmd.Flags().GetString("delegate") + if delegate != "" { + delegateUser, err := client.FindUserByIdentifier(context.Background(), delegate) + if err != nil { + output.Error(fmt.Sprintf("Failed to find delegate user: %v", err), plaintext, jsonOut) + os.Exit(1) + } + input["delegateId"] = delegateUser.ID + } + + // Handle labels + labelNames, _ := cmd.Flags().GetStringSlice("label") + if len(labelNames) > 0 { + // Get team labels + teamLabels, err := client.GetTeamLabels(context.Background(), teamKey) + if err != nil { + output.Error(fmt.Sprintf("Failed to get team labels: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Build map for case-insensitive lookup + labelMap := make(map[string]string) + for _, label := range teamLabels { + labelMap[strings.ToLower(label.Name)] = label.ID + } + + // Look up label IDs + var labelIds []string + for _, name := range labelNames { + id, ok := labelMap[strings.ToLower(name)] + if !ok { + output.Error(fmt.Sprintf("Label not found: %s", name), plaintext, jsonOut) + os.Exit(1) + } + labelIds = append(labelIds, id) + } + input["labelIds"] = labelIds + } + // Create issue issue, err := client.CreateIssue(context.Background(), input) if err != nil { @@ -925,6 +965,7 @@ Examples: linctl issue update LIN-123 --state "In Progress" linctl issue update LIN-123 --priority 1 linctl issue update LIN-123 --due-date "2024-12-31" + linctl issue update LIN-123 --delegate agent-name linctl issue update LIN-123 --title "New title" --assignee me --priority 2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { @@ -993,6 +1034,21 @@ Examples: } } + // Handle delegate update + if cmd.Flags().Changed("delegate") { + delegate, _ := cmd.Flags().GetString("delegate") + if delegate == "" || delegate == "none" { + input["delegateId"] = nil + } else { + delegateUser, err := client.FindUserByIdentifier(context.Background(), delegate) + if err != nil { + output.Error(fmt.Sprintf("Failed to find delegate user: %v", err), plaintext, jsonOut) + os.Exit(1) + } + input["delegateId"] = delegateUser.ID + } + } + // Handle state update if cmd.Flags().Changed("state") { stateName, _ := cmd.Flags().GetString("state") @@ -1108,6 +1164,8 @@ func init() { issueCreateCmd.Flags().StringP("team", "t", "", "Team key (required)") issueCreateCmd.Flags().Int("priority", 3, "Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)") issueCreateCmd.Flags().BoolP("assign-me", "m", false, "Assign to yourself") + issueCreateCmd.Flags().String("delegate", "", "Delegate to agent (email, name, or displayName)") + issueCreateCmd.Flags().StringSlice("label", []string{}, "Label name(s) to apply (can be repeated)") _ = issueCreateCmd.MarkFlagRequired("title") _ = issueCreateCmd.MarkFlagRequired("team") @@ -1118,4 +1176,5 @@ func init() { issueUpdateCmd.Flags().StringP("state", "s", "", "State name (e.g., 'Todo', 'In Progress', 'Done')") issueUpdateCmd.Flags().Int("priority", -1, "Priority (0=None, 1=Urgent, 2=High, 3=Normal, 4=Low)") issueUpdateCmd.Flags().String("due-date", "", "Due date (YYYY-MM-DD format, or empty to remove)") + issueUpdateCmd.Flags().String("delegate", "", "Delegate to agent (email, name, displayName, or 'none' to remove)") } diff --git a/cmd/issue_test.go b/cmd/issue_test.go new file mode 100644 index 0000000..f856e27 --- /dev/null +++ b/cmd/issue_test.go @@ -0,0 +1,105 @@ +package cmd + +import ( + "testing" +) + +func TestIssueCommandExists(t *testing.T) { + if issueCmd == nil { + t.Fatal("issueCmd should not be nil") + } + + if issueCmd.Use != "issue" { + t.Errorf("Expected Use 'issue', got '%s'", issueCmd.Use) + } +} + +func TestIssueCreateCommandExists(t *testing.T) { + if issueCreateCmd == nil { + t.Fatal("issueCreateCmd should not be nil") + } + + if issueCreateCmd.Use != "create" { + t.Errorf("Expected Use 'create', got '%s'", issueCreateCmd.Use) + } +} + +func TestIssueUpdateCommandExists(t *testing.T) { + if issueUpdateCmd == nil { + t.Fatal("issueUpdateCmd should not be nil") + } + + if issueUpdateCmd.Use != "update [issue-id]" { + t.Errorf("Expected Use 'update [issue-id]', got '%s'", issueUpdateCmd.Use) + } +} + +func TestIssueDelegateFlagOnUpdate(t *testing.T) { + flag := issueUpdateCmd.Flags().Lookup("delegate") + if flag == nil { + t.Fatal("issueUpdateCmd should have --delegate flag") + } + if flag.Usage != "Delegate to agent (email, name, displayName, or 'none' to remove)" { + t.Errorf("Unexpected delegate flag usage: %s", flag.Usage) + } +} + +func TestIssueDelegateFlagOnCreate(t *testing.T) { + flag := issueCreateCmd.Flags().Lookup("delegate") + if flag == nil { + t.Fatal("issueCreateCmd should have --delegate flag") + } +} + +func TestIssueLabelFlagOnCreate(t *testing.T) { + flag := issueCreateCmd.Flags().Lookup("label") + if flag == nil { + t.Fatal("issueCreateCmd should have --label flag") + } +} + +func TestIssueCreateRequiredFlags(t *testing.T) { + // Title flag should exist + titleFlag := issueCreateCmd.Flags().Lookup("title") + if titleFlag == nil { + t.Fatal("issueCreateCmd should have --title flag") + } + + // Team flag should exist + teamFlag := issueCreateCmd.Flags().Lookup("team") + if teamFlag == nil { + t.Fatal("issueCreateCmd should have --team flag") + } + if teamFlag.Shorthand != "t" { + t.Errorf("Expected shorthand 't' for team, got '%s'", teamFlag.Shorthand) + } +} + +func TestIssueUpdateFlags(t *testing.T) { + // Title flag + titleFlag := issueUpdateCmd.Flags().Lookup("title") + if titleFlag == nil { + t.Fatal("issueUpdateCmd should have --title flag") + } + + // State flag + stateFlag := issueUpdateCmd.Flags().Lookup("state") + if stateFlag == nil { + t.Fatal("issueUpdateCmd should have --state flag") + } + if stateFlag.Shorthand != "s" { + t.Errorf("Expected shorthand 's' for state, got '%s'", stateFlag.Shorthand) + } + + // Assignee flag + assigneeFlag := issueUpdateCmd.Flags().Lookup("assignee") + if assigneeFlag == nil { + t.Fatal("issueUpdateCmd should have --assignee flag") + } + + // Priority flag + priorityFlag := issueUpdateCmd.Flags().Lookup("priority") + if priorityFlag == nil { + t.Fatal("issueUpdateCmd should have --priority flag") + } +} diff --git a/pkg/api/queries.go b/pkg/api/queries.go index f9e06c0..b90abf9 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "fmt" "time" ) @@ -19,6 +20,13 @@ type User struct { CreatedAt *time.Time `json:"createdAt"` } +// Organization represents a Linear organization/workspace +type Organization struct { + ID string `json:"id"` + Name string `json:"name"` + URLKey string `json:"urlKey"` +} + // Team represents a Linear team type Team struct { ID string `json:"id"` @@ -79,6 +87,8 @@ type Issue struct { SlackIssueComments []SlackComment `json:"slackIssueComments"` ExternalUserCreator *ExternalUser `json:"externalUserCreator"` CustomerTickets []CustomerTicket `json:"customerTickets"` + AgentSession *AgentSession `json:"agentSession"` + Delegate *User `json:"delegate"` } // State represents an issue state @@ -368,6 +378,30 @@ type ProjectLink struct { UpdatedAt time.Time `json:"updatedAt"` } +// AgentSession represents an agent session on an issue +type AgentSession struct { + ID string `json:"id"` + Status string `json:"status"` // pending, active, complete, awaitingInput, error, stale + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + AppUser *User `json:"appUser"` + Activities *AgentActivities `json:"activities"` +} + +// AgentActivities is a paginated list of agent activities +type AgentActivities struct { + Nodes []AgentActivity `json:"nodes"` + PageInfo PageInfo `json:"pageInfo"` +} + +// AgentActivity represents a single activity in an agent session +type AgentActivity struct { + ID string `json:"id"` + CreatedAt time.Time `json:"createdAt"` + Ephemeral bool `json:"ephemeral"` + Content map[string]interface{} `json:"content"` +} + // GetViewer returns the current authenticated user func (c *Client) GetViewer(ctx context.Context) (*User, error) { query := ` @@ -396,6 +430,30 @@ func (c *Client) GetViewer(ctx context.Context) (*User, error) { return &response.Viewer, nil } +// GetOrganization returns the current organization/workspace +func (c *Client) GetOrganization(ctx context.Context) (*Organization, error) { + query := ` + query Organization { + organization { + id + name + urlKey + } + } + ` + + var response struct { + Organization Organization `json:"organization"` + } + + err := c.Execute(ctx, query, nil, &response) + if err != nil { + return nil, err + } + + return &response.Organization, nil +} + // GetIssues returns a list of issues with optional filtering func (c *Client) GetIssues(ctx context.Context, filter map[string]interface{}, first int, after string, orderBy string) (*Issues, error) { query := ` @@ -819,6 +877,89 @@ func (c *Client) GetIssue(ctx context.Context, id string) (*Issue, error) { return &response.Issue, nil } +// GetIssueAgentSession returns the issue with delegate and comments containing agent sessions +func (c *Client) GetIssueAgentSession(ctx context.Context, issueId string) (*Issue, error) { + query := ` + query IssueAgentSession($id: String!) { + issue(id: $id) { + id + identifier + title + state { + name + type + } + assignee { + name + email + } + delegate { + id + name + displayName + email + } + comments(first: 50) { + nodes { + id + body + createdAt + user { + name + displayName + } + agentSession { + id + status + createdAt + updatedAt + appUser { + id + name + displayName + } + activities(first: 50) { + nodes { + id + createdAt + ephemeral + content { + ... on AgentActivityThoughtContent { type body } + ... on AgentActivityResponseContent { type body } + ... on AgentActivityActionContent { type action parameter } + ... on AgentActivityErrorContent { type body } + ... on AgentActivityElicitationContent { type body } + ... on AgentActivityPromptContent { type body } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "id": issueId, + } + + var response struct { + Issue Issue `json:"issue"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.Issue, nil +} + // GetTeams returns a list of teams func (c *Client) GetTeams(ctx context.Context, first int, after string, orderBy string) (*Teams, error) { query := ` @@ -1237,14 +1378,15 @@ func (c *Client) GetTeam(ctx context.Context, key string) (*Team, error) { // Comment represents a Linear comment type Comment struct { - ID string `json:"id"` - Body string `json:"body"` - CreatedAt time.Time `json:"createdAt"` - UpdatedAt time.Time `json:"updatedAt"` - EditedAt *time.Time `json:"editedAt"` - User *User `json:"user"` - Parent *Comment `json:"parent"` - Children *Comments `json:"children"` + ID string `json:"id"` + Body string `json:"body"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + EditedAt *time.Time `json:"editedAt"` + User *User `json:"user"` + Parent *Comment `json:"parent"` + Children *Comments `json:"children"` + AgentSession *AgentSession `json:"agentSession"` } // Comments represents a paginated list of comments @@ -1302,6 +1444,47 @@ func (c *Client) GetTeamStates(ctx context.Context, teamKey string) ([]WorkflowS return response.Team.States.Nodes, nil } +// GetTeamLabels returns labels for a specific team +func (c *Client) GetTeamLabels(ctx context.Context, teamKey string) ([]Label, error) { + query := ` + query TeamLabels($key: String!) { + team(id: $key) { + labels { + nodes { + id + name + color + description + parent { + id + name + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "key": teamKey, + } + + var response struct { + Team struct { + Labels struct { + Nodes []Label `json:"nodes"` + } `json:"labels"` + } `json:"team"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return response.Team.Labels.Nodes, nil +} + // GetTeamMembers returns members of a specific team func (c *Client) GetTeamMembers(ctx context.Context, teamKey string) (*Users, error) { query := ` @@ -1420,6 +1603,22 @@ func (c *Client) GetUser(ctx context.Context, email string) (*User, error) { return &response.User, nil } +// FindUserByIdentifier finds a user by email, name, or displayName +func (c *Client) FindUserByIdentifier(ctx context.Context, identifier string) (*User, error) { + users, err := c.GetUsers(ctx, 100, "", "") + if err != nil { + return nil, err + } + + for _, user := range users.Nodes { + if user.Email == identifier || user.Name == identifier || user.DisplayName == identifier { + return &user, nil + } + } + + return nil, fmt.Errorf("user not found: %s", identifier) +} + // GetIssueComments returns comments for a specific issue func (c *Client) GetIssueComments(ctx context.Context, issueID string, first int, after string, orderBy string) (*Comments, error) { query := ` @@ -1513,3 +1712,56 @@ func (c *Client) CreateComment(ctx context.Context, issueID string, body string) return &response.CommentCreate.Comment, nil } + +// DeleteComment deletes a comment by ID +func (c *Client) DeleteComment(ctx context.Context, commentID string) error { + query := ` + mutation DeleteComment($id: String!) { + commentDelete(id: $id) { + success + } + } + ` + + variables := map[string]interface{}{ + "id": commentID, + } + + var response struct { + CommentDelete struct { + Success bool `json:"success"` + } `json:"commentDelete"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return err + } + + if !response.CommentDelete.Success { + return fmt.Errorf("failed to delete comment") + } + + return nil +} + +// MentionAgent @mentions an agent in a comment to trigger them +func (c *Client) MentionAgent(ctx context.Context, issueID string, agentDisplayName string, message string) (string, error) { + // Get workspace slug from organization + org, err := c.GetOrganization(ctx) + if err != nil { + return "", fmt.Errorf("failed to get organization: %w", err) + } + + // Create a comment that @mentions the agent using Linear URL format + // Format: https://linear.app/workspace/profiles/displayname + mentionURL := fmt.Sprintf("https://linear.app/%s/profiles/%s", org.URLKey, agentDisplayName) + mentionMessage := fmt.Sprintf("%s\n\n%s", mentionURL, message) + + comment, err := c.CreateComment(ctx, issueID, mentionMessage) + if err != nil { + return "", fmt.Errorf("failed to create comment: %w", err) + } + + return comment.ID, nil +} diff --git a/pkg/api/queries_test.go b/pkg/api/queries_test.go new file mode 100644 index 0000000..e623287 --- /dev/null +++ b/pkg/api/queries_test.go @@ -0,0 +1,221 @@ +package api + +import ( + "encoding/json" + "testing" + "time" +) + +func TestAgentSessionUnmarshal(t *testing.T) { + jsonData := `{ + "id": "test-123", + "status": "active", + "createdAt": "2026-01-06T20:17:02.878Z", + "updatedAt": "2026-01-06T20:21:28.402Z", + "appUser": { + "id": "user-456", + "name": "Test Agent", + "displayName": "testagent" + }, + "activities": { + "nodes": [ + { + "id": "activity-1", + "createdAt": "2026-01-06T20:17:05.000Z", + "ephemeral": false, + "content": {"type": "thought", "body": "Thinking..."} + } + ], + "pageInfo": {"hasNextPage": false, "endCursor": ""} + } + }` + + var session AgentSession + err := json.Unmarshal([]byte(jsonData), &session) + if err != nil { + t.Fatalf("Failed to unmarshal AgentSession: %v", err) + } + + if session.ID != "test-123" { + t.Errorf("Expected ID 'test-123', got '%s'", session.ID) + } + if session.Status != "active" { + t.Errorf("Expected Status 'active', got '%s'", session.Status) + } + if session.AppUser == nil { + t.Fatal("Expected AppUser to be non-nil") + } + if session.AppUser.DisplayName != "testagent" { + t.Errorf("Expected DisplayName 'testagent', got '%s'", session.AppUser.DisplayName) + } + if session.Activities == nil || len(session.Activities.Nodes) != 1 { + t.Fatal("Expected 1 activity") + } + if session.Activities.Nodes[0].Content["type"] != "thought" { + t.Errorf("Expected activity type 'thought', got '%v'", session.Activities.Nodes[0].Content["type"]) + } +} + +func TestAgentActivityContent(t *testing.T) { + tests := []struct { + name string + json string + wantType string + wantBody string + }{ + { + name: "thought content", + json: `{"type": "thought", "body": "Processing request"}`, + wantType: "thought", + wantBody: "Processing request", + }, + { + name: "response content", + json: `{"type": "response", "body": "Task completed"}`, + wantType: "response", + wantBody: "Task completed", + }, + { + name: "action content", + json: `{"type": "action", "action": "Bash", "parameter": "git status"}`, + wantType: "action", + wantBody: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var content map[string]interface{} + err := json.Unmarshal([]byte(tt.json), &content) + if err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if content["type"] != tt.wantType { + t.Errorf("Expected type '%s', got '%v'", tt.wantType, content["type"]) + } + if body, ok := content["body"].(string); ok && body != tt.wantBody { + t.Errorf("Expected body '%s', got '%s'", tt.wantBody, body) + } + }) + } +} + +func TestIssueDelegateField(t *testing.T) { + jsonData := `{ + "id": "issue-123", + "identifier": "ENG-80", + "title": "Test Issue", + "createdAt": "2026-01-06T10:00:00.000Z", + "updatedAt": "2026-01-06T10:00:00.000Z", + "delegate": { + "id": "user-789", + "name": "Test Agent", + "displayName": "testagent", + "email": "agent@example.com" + } + }` + + var issue Issue + err := json.Unmarshal([]byte(jsonData), &issue) + if err != nil { + t.Fatalf("Failed to unmarshal Issue: %v", err) + } + + if issue.Delegate == nil { + t.Fatal("Expected Delegate to be non-nil") + } + if issue.Delegate.DisplayName != "testagent" { + t.Errorf("Expected Delegate DisplayName 'testagent', got '%s'", issue.Delegate.DisplayName) + } +} + +func TestCommentAgentSessionField(t *testing.T) { + jsonData := `{ + "id": "comment-123", + "body": "Test comment", + "createdAt": "2026-01-06T10:00:00.000Z", + "updatedAt": "2026-01-06T10:00:00.000Z", + "agentSession": { + "id": "session-456", + "status": "complete" + } + }` + + var comment Comment + err := json.Unmarshal([]byte(jsonData), &comment) + if err != nil { + t.Fatalf("Failed to unmarshal Comment: %v", err) + } + + if comment.AgentSession == nil { + t.Fatal("Expected AgentSession to be non-nil") + } + if comment.AgentSession.Status != "complete" { + t.Errorf("Expected AgentSession Status 'complete', got '%s'", comment.AgentSession.Status) + } +} + +func TestAgentSessionTimeFields(t *testing.T) { + jsonData := `{ + "id": "test-123", + "status": "active", + "createdAt": "2026-01-06T20:17:02.878Z", + "updatedAt": "2026-01-06T20:21:28.402Z" + }` + + var session AgentSession + err := json.Unmarshal([]byte(jsonData), &session) + if err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + expectedCreated := time.Date(2026, 1, 6, 20, 17, 2, 878000000, time.UTC) + if !session.CreatedAt.Equal(expectedCreated) { + t.Errorf("CreatedAt mismatch: got %v, want %v", session.CreatedAt, expectedCreated) + } +} + +func TestOrganizationUnmarshal(t *testing.T) { + jsonData := `{ + "id": "org-123", + "name": "Acme Corp", + "urlKey": "acme" + }` + + var org Organization + err := json.Unmarshal([]byte(jsonData), &org) + if err != nil { + t.Fatalf("Failed to unmarshal Organization: %v", err) + } + + if org.ID != "org-123" { + t.Errorf("Expected ID 'org-123', got '%s'", org.ID) + } + if org.Name != "Acme Corp" { + t.Errorf("Expected Name 'Acme Corp', got '%s'", org.Name) + } + if org.URLKey != "acme" { + t.Errorf("Expected URLKey 'acme', got '%s'", org.URLKey) + } +} + +func TestLabelUnmarshal(t *testing.T) { + jsonData := `{ + "id": "label-123", + "name": "backend", + "color": "#ff0000" + }` + + var label Label + err := json.Unmarshal([]byte(jsonData), &label) + if err != nil { + t.Fatalf("Failed to unmarshal Label: %v", err) + } + + if label.ID != "label-123" { + t.Errorf("Expected ID 'label-123', got '%s'", label.ID) + } + if label.Name != "backend" { + t.Errorf("Expected Name 'backend', got '%s'", label.Name) + } +}