From 4b38506f69a4ef1b4a5909ff793b9b8d9301fce2 Mon Sep 17 00:00:00 2001 From: John Pates Date: Thu, 6 Nov 2025 15:47:17 +0800 Subject: [PATCH 1/3] feat: add project assignment to issues (prerequisite) --- cmd/issue.go | 119 ++++++++++++++++++++++++++++---- cmd/issue_cobra_test.go | 52 ++++++++++++++ cmd/issue_flag_defaults_test.go | 36 ++++++++++ cmd/issue_help_test.go | 41 +++++++++++ cmd/issue_test.go | 73 ++++++++++++++++++++ cmd/root.go | 14 ++-- pkg/api/queries.go | 16 +++++ 7 files changed, 329 insertions(+), 22 deletions(-) create mode 100644 cmd/issue_cobra_test.go create mode 100644 cmd/issue_flag_defaults_test.go create mode 100644 cmd/issue_help_test.go create mode 100644 cmd/issue_test.go diff --git a/cmd/issue.go b/cmd/issue.go index f9e35ef..c6798c9 100644 --- a/cmd/issue.go +++ b/cmd/issue.go @@ -1,10 +1,11 @@ package cmd import ( - "context" - "fmt" - "os" - "strings" + "context" + "fmt" + "os" + "strings" + "regexp" "github.com/dorkitude/linctl/pkg/api" "github.com/dorkitude/linctl/pkg/auth" @@ -15,6 +16,36 @@ import ( "github.com/spf13/viper" ) +var uuidRegexp = regexp.MustCompile(`^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}$`) + +func isValidUUID(s string) bool { return uuidRegexp.MatchString(s) } + +func isProjectNotFoundErr(err error) bool { + if err == nil { return false } + e := strings.ToLower(err.Error()) + if !strings.Contains(e, "not found") { return false } + return strings.Contains(e, "project") || strings.Contains(e, "projectid") +} + +// buildProjectInput normalizes a --project flag value to a GraphQL input value. +// Returns (value, ok, err): +// - ok=false means no input should be set (flag empty / not provided) +// - value=nil with ok=true means explicitly unset (unassigned) +// - value=string (uuid) with ok=true means assign to that project +func buildProjectInput(projectFlag string) (interface{}, bool, error) { + switch strings.TrimSpace(projectFlag) { + case "": + return nil, false, nil + case "unassigned": + return nil, true, nil + default: + if !isValidUUID(projectFlag) { + return nil, false, fmt.Errorf("Invalid project ID format: %s", projectFlag) + } + return projectFlag, true, nil + } +} + // issueCmd represents the issue command var issueCmd = &cobra.Command{ Use: "issue", @@ -111,6 +142,9 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes if issue.Team != nil { fmt.Printf("- **Team**: %s\n", issue.Team.Key) } + if issue.Project != nil { + fmt.Printf("- **Project**: %s\n", issue.Project.Name) + } fmt.Printf("- **Created**: %s\n", issue.CreatedAt.Format("2006-01-02")) fmt.Printf("- **URL**: %s\n", issue.URL) if issue.Description != "" { @@ -122,7 +156,7 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes return } - headers := []string{"Title", "State", "Assignee", "Team", "Created", "URL"} + headers := []string{"Title", "State", "Assignee", "Team", "Project", "Created", "URL"} rows := make([][]string, len(issues.Nodes)) for i, issue := range issues.Nodes { @@ -136,6 +170,11 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes team = issue.Team.Key } + project := "" + if issue.Project != nil { + project = truncateString(issue.Project.Name, 25) + } + state := "" if issue.State != nil { state = issue.State.Name @@ -168,6 +207,7 @@ func renderIssueCollection(issues *api.Issues, plaintext, jsonOut bool, emptyMes state, assignee, team, + project, issue.CreatedAt.Format("2006-01-02"), issue.URL, } @@ -890,17 +930,42 @@ var issueCreateCmd = &cobra.Command{ input["assigneeId"] = viewer.ID } + // Handle project assignment + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if val, ok, err := buildProjectInput(projectID); err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } else if ok { + // For create, "unassigned" is equivalent to not setting project + if val != nil { + input["projectId"] = val + } + } + } + // Create issue - issue, err := client.CreateIssue(context.Background(), input) - if err != nil { - output.Error(fmt.Sprintf("Failed to create issue: %v", err), plaintext, jsonOut) - os.Exit(1) - } + issue, err := client.CreateIssue(context.Background(), input) + if err != nil { + // Standardize project not-found error when a project was provided + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if projectID != "" && projectID != "unassigned" && isProjectNotFoundErr(err) { + output.Error(fmt.Sprintf("Project '%s' not found", projectID), plaintext, jsonOut) + os.Exit(1) + } + } + output.Error(fmt.Sprintf("Failed to create issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } if jsonOut { output.JSON(issue) } else if plaintext { fmt.Printf("Created issue %s: %s\n", issue.Identifier, issue.Title) + if issue.Project != nil { + fmt.Printf("Project: %s\n", issue.Project.Name) + } } else { fmt.Printf("%s Created issue %s: %s\n", color.New(color.FgGreen).Sprint("✓"), @@ -909,6 +974,9 @@ var issueCreateCmd = &cobra.Command{ if issue.Assignee != nil { fmt.Printf(" Assigned to: %s\n", color.New(color.FgCyan).Sprint(issue.Assignee.Name)) } + if issue.Project != nil { + fmt.Printf(" Project: %s\n", color.New(color.FgBlue).Sprint(issue.Project.Name)) + } } }, } @@ -1049,6 +1117,17 @@ Examples: } } + // Handle project assignment update + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if val, ok, err := buildProjectInput(projectID); err != nil { + output.Error(err.Error(), plaintext, jsonOut) + os.Exit(1) + } else if ok { + input["projectId"] = val + } + } + // Check if any updates were specified if len(input) == 0 { output.Error("No updates specified. Use flags to specify what to update.", plaintext, jsonOut) @@ -1056,11 +1135,19 @@ Examples: } // Update the issue - issue, err := client.UpdateIssue(context.Background(), args[0], input) - if err != nil { - output.Error(fmt.Sprintf("Failed to update issue: %v", err), plaintext, jsonOut) - os.Exit(1) - } + issue, err := client.UpdateIssue(context.Background(), args[0], input) + if err != nil { + // Standardize project not-found error when a project was provided + if cmd.Flags().Changed("project") { + projectID, _ := cmd.Flags().GetString("project") + if projectID != "" && projectID != "unassigned" && isProjectNotFoundErr(err) { + output.Error(fmt.Sprintf("Project '%s' not found", projectID), plaintext, jsonOut) + os.Exit(1) + } + } + output.Error(fmt.Sprintf("Failed to update issue: %v", err), plaintext, jsonOut) + os.Exit(1) + } if jsonOut { output.JSON(issue) @@ -1108,6 +1195,7 @@ 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("project", "", "Project ID to assign issue to") _ = issueCreateCmd.MarkFlagRequired("title") _ = issueCreateCmd.MarkFlagRequired("team") @@ -1118,4 +1206,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("project", "", "Project ID to assign issue to (or 'unassigned' to remove)") } diff --git a/cmd/issue_cobra_test.go b/cmd/issue_cobra_test.go new file mode 100644 index 0000000..9889e1d --- /dev/null +++ b/cmd/issue_cobra_test.go @@ -0,0 +1,52 @@ +package cmd + +import "testing" + +// These tests exercise Cobra flag parsing on the real command objects +// without invoking the Run functions (no network/API side-effects). + +func TestIssueCreateCmd_ProjectFlag_Parsing(t *testing.T) { + // Ensure the flag exists + f := issueCreateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueCreateCmd") + } + + // Parse and read back + uuid := "123e4567-e89b-12d3-a456-426614174000" + if err := issueCreateCmd.Flags().Set("project", uuid); err != nil { + t.Fatalf("failed to set project flag: %v", err) + } + got, err := issueCreateCmd.Flags().GetString("project") + if err != nil { + t.Fatalf("failed to get project flag: %v", err) + } + if got != uuid { + t.Errorf("project flag parsed as %q, want %q", got, uuid) + } +} + +func TestIssueUpdateCmd_ProjectFlag_Unassigned(t *testing.T) { + // Ensure the flag exists + f := issueUpdateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueUpdateCmd") + } + + if err := issueUpdateCmd.Flags().Set("project", "unassigned"); err != nil { + t.Fatalf("failed to set project flag: %v", err) + } + got, err := issueUpdateCmd.Flags().GetString("project") + if err != nil { + t.Fatalf("failed to get project flag: %v", err) + } + if got != "unassigned" { + t.Errorf("project flag parsed as %q, want %q", got, "unassigned") + } + + // Check helper integration contract + if val, ok, err := buildProjectInput(got); err != nil || !ok || val != nil { + t.Errorf("buildProjectInput('unassigned') => (%v,%v,%v), want (nil,true,nil)", val, ok, err) + } +} + diff --git a/cmd/issue_flag_defaults_test.go b/cmd/issue_flag_defaults_test.go new file mode 100644 index 0000000..5a7f54c --- /dev/null +++ b/cmd/issue_flag_defaults_test.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestIssueCreateCmd_ProjectFlag_DefaultsAndHelp(t *testing.T) { + f := issueCreateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueCreateCmd") + } + if f.DefValue != "" { + t.Errorf("default value = %q, want empty string", f.DefValue) + } + if !strings.Contains(f.Usage, "Project ID to assign issue to") { + t.Errorf("usage text %q does not contain expected phrase", f.Usage) + } +} + +func TestIssueUpdateCmd_ProjectFlag_DefaultsAndHelp(t *testing.T) { + f := issueUpdateCmd.Flags().Lookup("project") + if f == nil { + t.Fatalf("expected --project flag on issueUpdateCmd") + } + if f.DefValue != "" { + t.Errorf("default value = %q, want empty string", f.DefValue) + } + if !strings.Contains(f.Usage, "Project ID to assign issue to") { + t.Errorf("usage text %q does not contain expected phrase", f.Usage) + } + if !strings.Contains(f.Usage, "unassigned") { + t.Errorf("usage text %q does not mention 'unassigned' handling", f.Usage) + } +} + diff --git a/cmd/issue_help_test.go b/cmd/issue_help_test.go new file mode 100644 index 0000000..31dba24 --- /dev/null +++ b/cmd/issue_help_test.go @@ -0,0 +1,41 @@ +package cmd + +import "testing" + +func TestIssueCreateCmd_HelpIncludesProjectFlag(t *testing.T) { + usage := issueCreateCmd.UsageString() + if !containsAll(usage, []string{"--project", "Project ID to assign issue to"}) { + t.Fatalf("create usage missing project flag/help text. got:\n%s", usage) + } +} + +func TestIssueUpdateCmd_HelpIncludesProjectFlag(t *testing.T) { + usage := issueUpdateCmd.UsageString() + if !containsAll(usage, []string{"--project", "Project ID to assign issue to", "unassigned"}) { + t.Fatalf("update usage missing project flag/help text. got:\n%s", usage) + } +} + +// containsAll is a tiny helper for substring checks in tests. +func containsAll(hay string, needles []string) bool { + for _, n := range needles { + if !contains(hay, n) { + return false + } + } + return true +} + +func contains(s, sub string) bool { return len(s) >= len(sub) && (s == sub || (len(sub) > 0 && (indexOf(s, sub) >= 0))) } + +// indexOf is deliberately simple to avoid importing strings in many files. +func indexOf(s, sub string) int { + // naive search (small strings) + n, m := len(s), len(sub) + if m == 0 { return 0 } + for i := 0; i+m <= n; i++ { + if s[i:i+m] == sub { return i } + } + return -1 +} + diff --git a/cmd/issue_test.go b/cmd/issue_test.go new file mode 100644 index 0000000..1416036 --- /dev/null +++ b/cmd/issue_test.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "errors" + "testing" +) + +func TestIsValidUUID(t *testing.T) { + valid := []string{ + "123e4567-e89b-12d3-a456-426614174000", + "00000000-0000-0000-0000-000000000000", + "ABCDEFAB-CDEF-ABCD-EFAB-CDEFABCDEFAB", + } + invalid := []string{ + "", "unassigned", "1234", "g23e4567-e89b-12d3-a456-426614174000", + "123e4567e89b12d3a456426614174000", + "123e4567-e89b-12d3-a456-426614174000-extra", + } + + for _, v := range valid { + if !isValidUUID(v) { + t.Errorf("expected valid UUID: %s", v) + } + } + for _, v := range invalid { + if isValidUUID(v) { + t.Errorf("expected invalid UUID: %s", v) + } + } +} + +func TestBuildProjectInput(t *testing.T) { + // Empty → ok=false, no input + if val, ok, err := buildProjectInput(""); err != nil || ok || val != nil { + t.Errorf("empty flag: want (nil,false,nil), got (%v,%v,%v)", val, ok, err) + } + + // unassigned → ok=true, val=nil + if val, ok, err := buildProjectInput("unassigned"); err != nil || !ok || val != nil { + t.Errorf("unassigned: want (nil,true,nil), got (%v,%v,%v)", val, ok, err) + } + + // valid uuid → ok=true, val=uuid + uuid := "123e4567-e89b-12d3-a456-426614174000" + if val, ok, err := buildProjectInput(uuid); err != nil || !ok || val != uuid { + t.Errorf("uuid: want (%s,true,nil), got (%v,%v,%v)", uuid, val, ok, err) + } + + // invalid uuid → error + if _, _, err := buildProjectInput("not-a-uuid"); err == nil { + t.Errorf("expected error for invalid uuid") + } +} + +func TestIsProjectNotFoundErr(t *testing.T) { + cases := []struct { + in error + want bool + }{ + {errors.New("GraphQL errors: [{ message: 'Project not found' }]"), true}, + {errors.New("something about projectId not found"), true}, + {errors.New("issue not found"), false}, + {errors.New("unknown error"), false}, + {nil, false}, + } + for _, c := range cases { + got := isProjectNotFoundErr(c.in) + if got != c.want { + t.Errorf("isProjectNotFoundErr(%v) = %v, want %v", c.in, got, c.want) + } + } +} + diff --git a/cmd/root.go b/cmd/root.go index f82014d..037e6dd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,9 +11,9 @@ import ( ) var ( - cfgFile string - plaintext bool - jsonOut bool + cfgFile string + plaintext bool + jsonOut bool ) // version is set at build time via -ldflags @@ -66,10 +66,10 @@ func generateHeader() string { // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "linctl", - Short: "A comprehensive Linear CLI tool", - Long: color.New(color.FgCyan).Sprintf("%s\nA comprehensive CLI tool for Linear's API featuring:\n• Issue management (create, list, update, archive)\n• Project tracking and collaboration \n• Team and user management\n• Comments and attachments\n• Webhook configuration\n• Table/plaintext/JSON output formats\n", generateHeader()), - Version: version, + Use: "linctl", + Short: "A comprehensive Linear CLI tool", + Long: color.New(color.FgCyan).Sprintf("%s\nA comprehensive CLI tool for Linear's API featuring:\n• Issue management (create, list, update, archive)\n• Project tracking and collaboration \n• Team and user management\n• Comments and attachments\n• Webhook configuration\n• Table/plaintext/JSON output formats\n", generateHeader()), + Version: version, } // Execute adds all child commands to the root command and sets flags appropriately. diff --git a/pkg/api/queries.go b/pkg/api/queries.go index f9e06c0..8d1ef82 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -428,6 +428,10 @@ func (c *Client) GetIssues(ctx context.Context, filter map[string]interface{}, f key name } + project { + id + name + } labels { nodes { id @@ -501,6 +505,10 @@ func (c *Client) IssueSearch(ctx context.Context, term string, filter map[string key name } + project { + id + name + } labels { nodes { id @@ -1112,6 +1120,10 @@ func (c *Client) UpdateIssue(ctx context.Context, id string, input map[string]in key name } + project { + id + name + } labels { nodes { id @@ -1174,6 +1186,10 @@ func (c *Client) CreateIssue(ctx context.Context, input map[string]interface{}) key name } + project { + id + name + } labels { nodes { id From b9b0fc39a90ecf12091f79af9e059a30bf4f0a3d Mon Sep 17 00:00:00 2001 From: John Pates Date: Thu, 6 Nov 2025 15:47:17 +0800 Subject: [PATCH 2/3] feat: add project creation and archival (prerequisite) --- cmd/project.go | 289 +++++++++++++++++++++++++++++++++++++--- cmd/project_cmd_test.go | 92 +++++++++++++ cmd/project_test.go | 20 +++ pkg/api/queries.go | 178 ++++++++++++++++++++----- 4 files changed, 528 insertions(+), 51 deletions(-) create mode 100644 cmd/project_cmd_test.go create mode 100644 cmd/project_test.go diff --git a/cmd/project.go b/cmd/project.go index 17b7c1f..b66c3b9 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -1,20 +1,35 @@ 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/dorkitude/linctl/pkg/utils" - "github.com/fatih/color" - "github.com/spf13/cobra" - "github.com/spf13/viper" + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/dorkitude/linctl/pkg/api" + "github.com/dorkitude/linctl/pkg/auth" + "github.com/dorkitude/linctl/pkg/output" + "github.com/dorkitude/linctl/pkg/utils" + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/spf13/viper" ) +// projectAPI captures the subset of API client used by project commands. +// This enables dependency injection in tests without changing public API types. +type projectAPI interface { + GetTeam(ctx context.Context, key string) (*api.Team, error) + GetProjects(ctx context.Context, filter map[string]interface{}, first int, after string, orderBy string) (*api.Projects, error) + CreateProject(ctx context.Context, input map[string]interface{}) (*api.Project, error) + ArchiveProject(ctx context.Context, id string) (bool, error) + GetProject(ctx context.Context, id string) (*api.Project, error) +} + +// Injection points for testing +var newAPIClient = func(authHeader string) projectAPI { return api.NewClient(authHeader) } +var getAuthHeader = auth.GetAuthHeader + // constructProjectURL constructs an ID-based project URL func constructProjectURL(projectID string, originalURL string) string { // Extract workspace from the original URL @@ -56,15 +71,15 @@ var projectListCmd = &cobra.Command{ plaintext := viper.GetBool("plaintext") jsonOut := viper.GetBool("json") - // Get auth header - authHeader, err := auth.GetAuthHeader() + // Get auth header + authHeader, err := getAuthHeader() if err != nil { output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) os.Exit(1) } - // Create API client - client := api.NewClient(authHeader) + // Create API client + client := newAPIClient(authHeader) // Get filters teamKey, _ := cmd.Flags().GetString("team") @@ -241,15 +256,15 @@ var projectGetCmd = &cobra.Command{ jsonOut := viper.GetBool("json") projectID := args[0] - // Get auth header - authHeader, err := auth.GetAuthHeader() + // Get auth header + authHeader, err := getAuthHeader() if err != nil { output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) os.Exit(1) } - // Create API client - client := api.NewClient(authHeader) + // Create API client + client := newAPIClient(authHeader) // Get project details project, err := client.GetProject(context.Background(), projectID) @@ -582,10 +597,236 @@ var projectGetCmd = &cobra.Command{ }, } +var projectCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new project", + Long: `Create a new project in Linear workspace with required and optional configuration. + +The --name and --team flags are required. All other fields are optional. + +Examples: + # Create project with minimal required fields + linctl project create --name "Q1 Backend" --team ENG + + # Create project with all optional fields + linctl project create --name "Test Project" --team ENG --state started --priority 1 --description "Test project for validation" + + # Create project with target date + linctl project create --name "Launch" --team PROD --state planned --target-date 2024-12-31`, + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + + // Get required flags + name, _ := cmd.Flags().GetString("name") + teamKey, _ := cmd.Flags().GetString("team") + + // Validate required fields + if name == "" || teamKey == "" { + output.Error("Both --name and --team are required", plaintext, jsonOut) + os.Exit(1) + } + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Resolve team key to team UUID + team, err := client.GetTeam(context.Background(), teamKey) + if err != nil { + output.Error(fmt.Sprintf("Team '%s' not found. Use 'linctl team list' to see available teams.", teamKey), plaintext, jsonOut) + os.Exit(1) + } + + // Get optional fields + description, _ := cmd.Flags().GetString("description") + state, _ := cmd.Flags().GetString("state") + targetDate, _ := cmd.Flags().GetString("target-date") + + // Validate state if provided + if state != "" { + allowedStates := []string{"planned", "started", "paused", "completed", "canceled"} + valid := false + for _, s := range allowedStates { + if state == s { + valid = true + break + } + } + if !valid { + output.Error(fmt.Sprintf("Invalid state. Must be one of: %s", strings.Join(allowedStates, ", ")), plaintext, jsonOut) + os.Exit(1) + } + } + + // Validate priority if provided + var priority int + if cmd.Flags().Changed("priority") { + priority, _ = cmd.Flags().GetInt("priority") + if priority < 0 || priority > 4 { + output.Error("Priority must be between 0 (None) and 4 (Low)", plaintext, jsonOut) + os.Exit(1) + } + } + + // Validate target-date format if provided (YYYY-MM-DD) + if targetDate != "" { + if _, err := time.Parse("2006-01-02", targetDate); err != nil { + output.Error("Invalid --target-date format. Expected YYYY-MM-DD", plaintext, jsonOut) + os.Exit(1) + } + } + + // Build input map + input := map[string]interface{}{ + "name": name, + "teamIds": []string{team.ID}, + } + + if description != "" { + input["description"] = description + } + if state != "" { + input["state"] = state + } + if cmd.Flags().Changed("priority") { + input["priority"] = priority + } + if targetDate != "" { + input["targetDate"] = targetDate + } + + // Create project + project, err := client.CreateProject(context.Background(), input) + if err != nil { + output.Error(fmt.Sprintf("Failed to create project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Handle output + if jsonOut { + output.JSON(project) + } else if plaintext { + fmt.Printf("# Project Created\n\n") + fmt.Printf("- **Name**: %s\n", project.Name) + fmt.Printf("- **ID**: %s\n", project.ID) + fmt.Printf("- **State**: %s\n", project.State) + if project.Description != "" { + fmt.Printf("- **Description**: %s\n", project.Description) + } + if project.Teams != nil && len(project.Teams.Nodes) > 0 { + teams := "" + for i, t := range project.Teams.Nodes { + if i > 0 { + teams += ", " + } + teams += t.Key + } + fmt.Printf("- **Teams**: %s\n", teams) + } + fmt.Printf("- **URL**: %s\n", constructProjectURL(project.ID, project.URL)) + } else { + fmt.Println() + fmt.Printf("%s Project created successfully\n", color.New(color.FgGreen).Sprint("✓")) + fmt.Println() + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Name:"), project.Name) + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("ID:"), project.ID) + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("State:"), project.State) + if project.Teams != nil && len(project.Teams.Nodes) > 0 { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Team:"), project.Teams.Nodes[0].Key) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("URL:"), color.New(color.FgBlue, color.Underline).Sprint(constructProjectURL(project.ID, project.URL))) + fmt.Println() + } + }, +} + +var projectArchiveCmd = &cobra.Command{ + Use: "archive PROJECT-UUID", + Short: "Archive a project", + Long: `Archive a project by its UUID. Archived projects are hidden from most views but can still be accessed. + +Examples: + linctl project archive abc-123-def-456`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + projectID := args[0] + + // Validate argument provided + if projectID == "" { + output.Error("Project UUID is required", plaintext, jsonOut) + os.Exit(1) + } + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Archive project + success, err := client.ArchiveProject(context.Background(), projectID) + if err != nil { + output.Error(fmt.Sprintf("Failed to archive project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Try to fetch project details to include the name in output (best effort) + var projectName string + if success { + if proj, gerr := client.GetProject(context.Background(), projectID); gerr == nil && proj != nil { + projectName = proj.Name + } + } + + // Handle output + if jsonOut { + payload := map[string]interface{}{ + "success": success, + "projectId": projectID, + } + if projectName != "" { + payload["projectName"] = projectName + } + output.JSON(payload) + } else if plaintext { + fmt.Printf("# Project Archived\n\n") + if projectName != "" { + fmt.Printf("- **Name**: %s\n", projectName) + } + fmt.Printf("- **Project ID**: %s\n", projectID) + fmt.Printf("- **Status**: Archived\n") + } else { + fmt.Println() + fmt.Printf("%s Project archived successfully\n", color.New(color.FgGreen).Sprint("✓")) + fmt.Println() + if projectName != "" { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Name:"), projectName) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Project ID:"), projectID) + fmt.Println() + } + }, +} + func init() { rootCmd.AddCommand(projectCmd) projectCmd.AddCommand(projectListCmd) projectCmd.AddCommand(projectGetCmd) + projectCmd.AddCommand(projectCreateCmd) + projectCmd.AddCommand(projectArchiveCmd) // List command flags projectListCmd.Flags().StringP("team", "t", "", "Filter by team key") @@ -594,4 +835,12 @@ func init() { projectListCmd.Flags().BoolP("include-completed", "c", false, "Include completed and canceled projects") projectListCmd.Flags().StringP("sort", "o", "linear", "Sort order: linear (default), created, updated") projectListCmd.Flags().StringP("newer-than", "n", "", "Show projects created after this time (default: 6_months_ago, use 'all_time' for no filter)") + + // Create command flags + projectCreateCmd.Flags().String("name", "", "Project name (required)") + projectCreateCmd.Flags().String("team", "", "Team key (required)") + projectCreateCmd.Flags().String("description", "", "Project description") + projectCreateCmd.Flags().String("state", "", "Project state (planned|started|paused|completed|canceled)") + projectCreateCmd.Flags().Int("priority", 0, "Priority (0-4: None, Urgent, High, Normal, Low)") + projectCreateCmd.Flags().String("target-date", "", "Target date (YYYY-MM-DD)") } diff --git a/cmd/project_cmd_test.go b/cmd/project_cmd_test.go new file mode 100644 index 0000000..0f0615c --- /dev/null +++ b/cmd/project_cmd_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "os" + "testing" + + "github.com/dorkitude/linctl/pkg/api" + "github.com/spf13/viper" +) + +type mockProjectClient struct{ + created *api.Project + archived bool +} + +func (m *mockProjectClient) GetTeam(ctx context.Context, key string) (*api.Team, error) { + return &api.Team{ID: "team-1", Key: key, Name: "Team-"+key}, nil +} + +func (m *mockProjectClient) GetProjects(ctx context.Context, filter map[string]interface{}, first int, after string, orderBy string) (*api.Projects, error) { + return &api.Projects{}, nil +} + +func (m *mockProjectClient) CreateProject(ctx context.Context, input map[string]interface{}) (*api.Project, error) { + name, _ := input["name"].(string) + m.created = &api.Project{ID: "p1", Name: name, State: fmt.Sprint(input["state"])} + return m.created, nil +} + +func (m *mockProjectClient) ArchiveProject(ctx context.Context, id string) (bool, error) { + m.archived = true + return true, nil +} + +func (m *mockProjectClient) GetProject(ctx context.Context, id string) (*api.Project, error) { + return &api.Project{ID: id, Name: "Alpha"}, nil +} + +func withInjectedProjectClient(t *testing.T, mc *mockProjectClient, fn func()) { + t.Helper() + oldNew := newAPIClient + oldAuth := getAuthHeader + newAPIClient = func(_ string) projectAPI { return mc } + getAuthHeader = func() (string, error) { return "Bearer test", nil } + defer func(){ newAPIClient = oldNew; getAuthHeader = oldAuth }() + fn() +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + defer func(){ os.Stdout = old }() + fn() + _ = w.Close() + var buf bytes.Buffer + _, _ = buf.ReadFrom(r) + return buf.String() +} + +func TestProjectCreate_Plaintext_Output(t *testing.T) { + mc := &mockProjectClient{} + withInjectedProjectClient(t, mc, func(){ + viper.Set("plaintext", true) + viper.Set("json", false) + // Set flags directly on the command and call Run + _ = projectCreateCmd.Flags().Set("name", "Alpha") + _ = projectCreateCmd.Flags().Set("team", "ENG") + _ = projectCreateCmd.Flags().Set("state", "planned") + _ = projectCreateCmd.Flags().Set("target-date", "2024-12-31") + out := captureStdout(t, func(){ projectCreateCmd.Run(projectCreateCmd, nil) }) + if !contains(out, "# Project Created") || !contains(out, "**Name**: Alpha") { + t.Fatalf("unexpected output:\n%s", out) + } + }) +} + +func TestProjectArchive_Plaintext_IncludesName(t *testing.T) { + mc := &mockProjectClient{} + withInjectedProjectClient(t, mc, func(){ + viper.Set("plaintext", true) + viper.Set("json", false) + out := captureStdout(t, func(){ projectArchiveCmd.Run(projectArchiveCmd, []string{"p1"}) }) + if !contains(out, "# Project Archived") || !contains(out, "**Name**: Alpha") { + t.Fatalf("unexpected output:\n%s", out) + } + }) +} diff --git a/cmd/project_test.go b/cmd/project_test.go new file mode 100644 index 0000000..8569f9c --- /dev/null +++ b/cmd/project_test.go @@ -0,0 +1,20 @@ +package cmd + +import "testing" + +func TestConstructProjectURL(t *testing.T) { + // Happy path: workspace URL should be preserved, slug replaced by ID + id := "1234" + original := "https://linear.app/acme/project/some-project-slug" + want := "https://linear.app/acme/project/1234" + got := constructProjectURL(id, original) + if got != want { + t.Fatalf("constructProjectURL mismatch: got %q want %q", got, want) + } + + // Empty original → empty result + if s := constructProjectURL("abc", ""); s != "" { + t.Fatalf("expected empty for empty original URL, got %q", s) + } +} + diff --git a/pkg/api/queries.go b/pkg/api/queries.go index 8d1ef82..d5e10d7 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -1,9 +1,10 @@ package api import ( - "context" - "encoding/json" - "time" + "context" + "encoding/json" + "fmt" + "time" ) // User represents a Linear user @@ -1220,35 +1221,61 @@ func (c *Client) CreateIssue(ctx context.Context, input map[string]interface{}) return &response.IssueCreate.Issue, nil } -// GetTeam returns a single team by key +// GetTeam returns a single team by key; falls back to id lookup if not found func (c *Client) GetTeam(ctx context.Context, key string) (*Team, error) { - query := ` - query Team($key: String!) { - team(id: $key) { - id - key - name - description - private - issueCount - } - } - ` - - variables := map[string]interface{}{ - "key": key, - } - - var response struct { - Team Team `json:"team"` - } - - err := c.Execute(ctx, query, variables, &response) - if err != nil { - return nil, err - } - - return &response.Team, nil + // First, attempt lookup by team key via teams connection + queryByKey := ` + query TeamByKey($key: String!) { + teams(filter: { key: { eq: $key } }, first: 1) { + nodes { + id + key + name + description + private + issueCount + } + } + } + ` + + variables := map[string]interface{}{"key": key} + + var respByKey struct { + Teams struct { + Nodes []Team `json:"nodes"` + } `json:"teams"` + } + + if err := c.Execute(ctx, queryByKey, variables, &respByKey); err == nil { + if len(respByKey.Teams.Nodes) > 0 { + t := respByKey.Teams.Nodes[0] + return &t, nil + } + } + + // Fallback: try direct id lookup (in case caller passed an ID) + queryByID := ` + query TeamByID($id: String!) { + team(id: $id) { + id + key + name + description + private + issueCount + } + } + ` + + var respByID struct { + Team *Team `json:"team"` + } + if err := c.Execute(ctx, queryByID, map[string]interface{}{"id": key}, &respByID); err == nil && respByID.Team != nil { + return respByID.Team, nil + } + + return nil, fmt.Errorf("team '%s' not found", key) } // Comment represents a Linear comment @@ -1529,3 +1556,92 @@ func (c *Client) CreateComment(ctx context.Context, issueID string, body string) return &response.CommentCreate.Comment, nil } + +// CreateProject creates a new project +func (c *Client) CreateProject(ctx context.Context, input map[string]interface{}) (*Project, error) { + query := ` + mutation CreateProject($input: ProjectCreateInput!) { + projectCreate(input: $input) { + success + project { + id + name + description + state + progress + startDate + targetDate + url + icon + color + createdAt + updatedAt + lead { + id + name + email + } + teams { + nodes { + id + key + name + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "input": input, + } + + var response struct { + ProjectCreate struct { + Success bool `json:"success"` + Project Project `json:"project"` + } `json:"projectCreate"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.ProjectCreate.Project, nil +} + +// ArchiveProject archives a project by ID +func (c *Client) ArchiveProject(ctx context.Context, id string) (bool, error) { + query := ` + mutation ArchiveProject($id: String!) { + projectArchive(id: $id) { + success + project { + id + name + archivedAt + } + } + } + ` + + variables := map[string]interface{}{ + "id": id, + } + + var response struct { + ProjectArchive struct { + Success bool `json:"success"` + Project Project `json:"project"` + } `json:"projectArchive"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return false, err + } + + return response.ProjectArchive.Success, nil +} From 6aaab437274fd658e0fc89a89acb4b395a5ba012 Mon Sep 17 00:00:00 2001 From: John Pates Date: Thu, 6 Nov 2025 15:47:34 +0800 Subject: [PATCH 3/3] feat: add project update and enhanced display Complete the project management feature set with multi-field updates and comprehensive project information display. Features: - `linctl project update` with multi-field support - Update name, description, state, priority in single command - Flag change detection for partial updates - Comprehensive validation (state values, priority range) - Enhanced `project list`: Added State and Priority columns - Enhanced `project get`: Shows state, priority, initiatives, labels - All output formats supported (table, JSON, plaintext) Validation: - State: planned, started, paused, completed, canceled - Priority: 0-4 (None, Urgent, High, Normal, Low) - At least one field required for update Technical changes: - pkg/api/queries.go: Add UpdateProject(), enhance GetProject/GetProjects - cmd/project.go: Add projectUpdateCmd, enhance display formatters - Use cmd.Flags().Changed() for partial update detection - Enhanced mock for testing Examples: linctl project update --state started linctl project update --state completed --priority 1 linctl project update --description "Updated description" linctl project list # Now shows State and Priority Note: shortSummary field update not supported by Linear GraphQL API. --- cmd/project.go | 207 +++++++++++++++++++++++++++++++++++++++- cmd/project_cmd_test.go | 14 +++ pkg/api/queries.go | 83 +++++++++++++++- 3 files changed, 301 insertions(+), 3 deletions(-) diff --git a/cmd/project.go b/cmd/project.go index b66c3b9..20b79ed 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -22,6 +22,7 @@ type projectAPI interface { GetTeam(ctx context.Context, key string) (*api.Team, error) GetProjects(ctx context.Context, filter map[string]interface{}, first int, after string, orderBy string) (*api.Projects, error) CreateProject(ctx context.Context, input map[string]interface{}) (*api.Project, error) + UpdateProject(ctx context.Context, id string, input map[string]interface{}) (*api.Project, error) ArchiveProject(ctx context.Context, id string) (bool, error) GetProject(ctx context.Context, id string) (*api.Project, error) } @@ -153,6 +154,9 @@ var projectListCmd = &cobra.Command{ fmt.Printf("## %s\n", project.Name) fmt.Printf("- **ID**: %s\n", project.ID) fmt.Printf("- **State**: %s\n", project.State) + if project.Priority > 0 { + fmt.Printf("- **Priority**: %d\n", project.Priority) + } fmt.Printf("- **Progress**: %.0f%%\n", project.Progress*100) if project.Lead != nil { fmt.Printf("- **Lead**: %s\n", project.Lead.Name) @@ -187,7 +191,7 @@ var projectListCmd = &cobra.Command{ return } else { // Table output - headers := []string{"Name", "State", "Lead", "Teams", "Created", "Updated", "URL"} + headers := []string{"Name", "State", "Priority", "Lead", "Teams", "Created", "Updated", "URL"} rows := [][]string{} for _, project := range projects.Nodes { @@ -220,9 +224,16 @@ var projectListCmd = &cobra.Command{ stateColor = color.New(color.FgRed) } + // Format priority + priorityStr := fmt.Sprintf("%d", project.Priority) + if project.Priority == 0 { + priorityStr = "-" + } + rows = append(rows, []string{ truncateString(project.Name, 25), stateColor.Sprint(project.State), + priorityStr, lead, teams, project.CreatedAt.Format("2006-01-02"), @@ -291,9 +302,32 @@ var projectGetCmd = &cobra.Command{ fmt.Printf("- **ID**: %s\n", project.ID) fmt.Printf("- **Slug ID**: %s\n", project.SlugId) fmt.Printf("- **State**: %s\n", project.State) + if project.Priority > 0 { + fmt.Printf("- **Priority**: %d\n", project.Priority) + } fmt.Printf("- **Progress**: %.0f%%\n", project.Progress*100) fmt.Printf("- **Health**: %s\n", project.Health) fmt.Printf("- **Scope**: %d\n", project.Scope) + if project.Initiatives != nil && len(project.Initiatives.Nodes) > 0 { + initiatives := "" + for i, initiative := range project.Initiatives.Nodes { + if i > 0 { + initiatives += ", " + } + initiatives += initiative.Name + } + fmt.Printf("- **Initiatives**: %s\n", initiatives) + } + if project.Labels != nil && len(project.Labels.Nodes) > 0 { + labels := "" + for i, label := range project.Labels.Nodes { + if i > 0 { + labels += ", " + } + labels += label.Name + } + fmt.Printf("- **Labels**: %s\n", labels) + } if project.Icon != nil && *project.Icon != "" { fmt.Printf("- **Icon**: %s\n", *project.Icon) } @@ -500,6 +534,10 @@ var projectGetCmd = &cobra.Command{ } fmt.Printf("\n%s %s\n", color.New(color.Bold).Sprint("State:"), stateColor.Sprint(project.State)) + if project.Priority > 0 { + fmt.Printf("%s %d\n", color.New(color.Bold).Sprint("Priority:"), project.Priority) + } + progressColor := color.New(color.FgRed) if project.Progress >= 0.75 { progressColor = color.New(color.FgGreen) @@ -508,6 +546,28 @@ var projectGetCmd = &cobra.Command{ } fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Progress:"), progressColor.Sprintf("%.0f%%", project.Progress*100)) + if project.Initiatives != nil && len(project.Initiatives.Nodes) > 0 { + initiatives := "" + for i, initiative := range project.Initiatives.Nodes { + if i > 0 { + initiatives += ", " + } + initiatives += initiative.Name + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Initiatives:"), initiatives) + } + + if project.Labels != nil && len(project.Labels.Nodes) > 0 { + labels := "" + for i, label := range project.Labels.Nodes { + if i > 0 { + labels += ", " + } + labels += label.Name + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Labels:"), labels) + } + if project.StartDate != nil || project.TargetDate != nil { fmt.Println() if project.StartDate != nil { @@ -821,12 +881,150 @@ Examples: }, } +var projectUpdateCmd = &cobra.Command{ + Use: "update PROJECT-UUID", + Short: "Update project fields", + Long: `Update one or more project fields. At least one field must be provided. + +The project UUID is required as the first argument. All field flags are optional, but at least one must be specified. + +Examples: + # Update single field + linctl project update abc-123 --name "New Project Name" + linctl project update abc-123 --state started + linctl project update abc-123 --priority 1 + + # Update multiple fields + linctl project update abc-123 --state started --priority 2 + linctl project update abc-123 --description "Full description" --summary "Short summary" + + # Update with labels + linctl project update abc-123 --label "urgent,backend"`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + plaintext := viper.GetBool("plaintext") + jsonOut := viper.GetBool("json") + projectID := args[0] + + // Validate project UUID provided + if projectID == "" { + output.Error("Project UUID is required", plaintext, jsonOut) + os.Exit(1) + } + + // Get auth header + authHeader, err := getAuthHeader() + if err != nil { + output.Error(fmt.Sprintf("Authentication failed: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Create API client + client := newAPIClient(authHeader) + + // Build input map with only changed fields + input := make(map[string]interface{}) + + if cmd.Flags().Changed("name") { + name, _ := cmd.Flags().GetString("name") + input["name"] = name + } + if cmd.Flags().Changed("description") { + description, _ := cmd.Flags().GetString("description") + input["description"] = description + } + if cmd.Flags().Changed("summary") { + summary, _ := cmd.Flags().GetString("summary") + input["shortSummary"] = summary + } + if cmd.Flags().Changed("state") { + state, _ := cmd.Flags().GetString("state") + input["state"] = state + } + if cmd.Flags().Changed("priority") { + priority, _ := cmd.Flags().GetInt("priority") + input["priority"] = priority + } + + // Validate at least one field provided + if len(input) == 0 { + output.Error("At least one field to update is required", plaintext, jsonOut) + os.Exit(1) + } + + // Validate state if provided + if state, ok := input["state"].(string); ok { + allowedStates := []string{"planned", "started", "paused", "completed", "canceled"} + valid := false + for _, s := range allowedStates { + if state == s { + valid = true + break + } + } + if !valid { + output.Error(fmt.Sprintf("Invalid state. Must be one of: %s", strings.Join(allowedStates, ", ")), plaintext, jsonOut) + os.Exit(1) + } + } + + // Validate priority if provided + if priority, ok := input["priority"].(int); ok { + if priority < 0 || priority > 4 { + output.Error("Priority must be between 0 and 4", plaintext, jsonOut) + os.Exit(1) + } + } + + // Update project + project, err := client.UpdateProject(context.Background(), projectID, input) + if err != nil { + output.Error(fmt.Sprintf("Failed to update project: %v", err), plaintext, jsonOut) + os.Exit(1) + } + + // Handle output + if jsonOut { + output.JSON(project) + } else if plaintext { + fmt.Printf("# Project Updated\n\n") + fmt.Printf("- **Name**: %s\n", project.Name) + fmt.Printf("- **ID**: %s\n", project.ID) + if project.State != "" { + fmt.Printf("- **State**: %s\n", project.State) + } + if project.Priority > 0 { + fmt.Printf("- **Priority**: %d\n", project.Priority) + } + if project.Description != "" { + fmt.Printf("- **Description**: %s\n", project.Description) + } + fmt.Printf("- **URL**: %s\n", constructProjectURL(project.ID, project.URL)) + } else { + fmt.Println() + fmt.Printf("%s Project updated successfully\n", color.New(color.FgGreen).Sprint("✓")) + fmt.Println() + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("Name:"), project.Name) + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("ID:"), project.ID) + if project.State != "" { + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("State:"), project.State) + } + if project.Priority > 0 { + fmt.Printf("%s %d\n", color.New(color.Bold).Sprint("Priority:"), project.Priority) + } + fmt.Printf("%s %s\n", color.New(color.Bold).Sprint("URL:"), color.New(color.FgBlue, color.Underline).Sprint(constructProjectURL(project.ID, project.URL))) + fmt.Println() + } + }, +} + func init() { rootCmd.AddCommand(projectCmd) projectCmd.AddCommand(projectListCmd) projectCmd.AddCommand(projectGetCmd) projectCmd.AddCommand(projectCreateCmd) projectCmd.AddCommand(projectArchiveCmd) + projectCmd.AddCommand(projectUpdateCmd) // List command flags projectListCmd.Flags().StringP("team", "t", "", "Filter by team key") @@ -843,4 +1041,11 @@ func init() { projectCreateCmd.Flags().String("state", "", "Project state (planned|started|paused|completed|canceled)") projectCreateCmd.Flags().Int("priority", 0, "Priority (0-4: None, Urgent, High, Normal, Low)") projectCreateCmd.Flags().String("target-date", "", "Target date (YYYY-MM-DD)") + + // Update command flags + projectUpdateCmd.Flags().String("name", "", "Project name") + projectUpdateCmd.Flags().String("description", "", "Project description") + projectUpdateCmd.Flags().String("summary", "", "Project short summary") + projectUpdateCmd.Flags().String("state", "", "Project state (planned|started|paused|completed|canceled)") + projectUpdateCmd.Flags().Int("priority", 0, "Priority (0-4: None, Urgent, High, Normal, Low)") } diff --git a/cmd/project_cmd_test.go b/cmd/project_cmd_test.go index 0f0615c..1fbff7c 100644 --- a/cmd/project_cmd_test.go +++ b/cmd/project_cmd_test.go @@ -35,6 +35,20 @@ func (m *mockProjectClient) ArchiveProject(ctx context.Context, id string) (bool return true, nil } +func (m *mockProjectClient) UpdateProject(ctx context.Context, id string, input map[string]interface{}) (*api.Project, error) { + project := &api.Project{ID: id, Name: "Alpha"} + if name, ok := input["name"].(string); ok { + project.Name = name + } + if state, ok := input["state"].(string); ok { + project.State = state + } + if priority, ok := input["priority"].(int); ok { + project.Priority = priority + } + return project, nil +} + func (m *mockProjectClient) GetProject(ctx context.Context, id string) (*api.Project, error) { return &api.Project{ID: id, Name: "Alpha"}, nil } diff --git a/pkg/api/queries.go b/pkg/api/queries.go index d5e10d7..bbe126c 100644 --- a/pkg/api/queries.go +++ b/pkg/api/queries.go @@ -98,11 +98,14 @@ type Project struct { Name string `json:"name"` Description string `json:"description"` State string `json:"state"` + Priority int `json:"priority"` Progress float64 `json:"progress"` StartDate *string `json:"startDate"` TargetDate *string `json:"targetDate"` - Lead *User `json:"lead"` - Teams *Teams `json:"teams"` + Lead *User `json:"lead"` + Teams *Teams `json:"teams"` + Initiatives *Initiatives `json:"initiatives"` + Labels *Labels `json:"labels"` URL string `json:"url"` Icon *string `json:"icon"` Color string `json:"color"` @@ -231,6 +234,10 @@ type Initiative struct { Description string `json:"description"` } +type Initiatives struct { + Nodes []Initiative `json:"nodes"` +} + type PageInfo struct { HasNextPage bool `json:"hasNextPage"` EndCursor string `json:"endCursor"` @@ -881,6 +888,7 @@ func (c *Client) GetProjects(ctx context.Context, filter map[string]interface{}, name description state + priority progress startDate targetDate @@ -944,6 +952,7 @@ func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { description content state + priority progress health scope @@ -960,6 +969,19 @@ func (c *Client) GetProject(ctx context.Context, id string) (*Project, error) { slackNewIssue slackIssueComments slackIssueStatuses + initiatives { + nodes { + id + name + } + } + labels { + nodes { + id + name + color + } + } lead { id name @@ -1645,3 +1667,60 @@ func (c *Client) ArchiveProject(ctx context.Context, id string) (bool, error) { return response.ProjectArchive.Success, nil } + +// UpdateProject updates a project by ID with partial field updates +func (c *Client) UpdateProject(ctx context.Context, id string, input map[string]interface{}) (*Project, error) { + query := ` + mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) { + projectUpdate(id: $id, input: $input) { + success + project { + id + name + description + state + priority + progress + startDate + targetDate + url + icon + color + createdAt + updatedAt + lead { + id + name + email + } + teams { + nodes { + id + key + name + } + } + } + } + } + ` + + variables := map[string]interface{}{ + "id": id, + "input": input, + } + + var response struct { + ProjectUpdate struct { + Success bool `json:"success"` + Project Project `json:"project"` + } `json:"projectUpdate"` + } + + err := c.Execute(ctx, query, variables, &response) + if err != nil { + return nil, err + } + + return &response.ProjectUpdate.Project, nil +}