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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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