Skip to content
Open
119 changes: 104 additions & 15 deletions cmd/issue.go
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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",
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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("✓"),
Expand All @@ -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))
}
}
},
}
Expand Down Expand Up @@ -1049,18 +1117,37 @@ 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)
os.Exit(1)
}

// 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)
Expand Down Expand Up @@ -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")

Expand All @@ -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)")
}
52 changes: 52 additions & 0 deletions cmd/issue_cobra_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

36 changes: 36 additions & 0 deletions cmd/issue_flag_defaults_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

41 changes: 41 additions & 0 deletions cmd/issue_help_test.go
Original file line number Diff line number Diff line change
@@ -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
}

Loading