diff --git a/tools/jtk/internal/cmd/issues/update.go b/tools/jtk/internal/cmd/issues/update.go index 4858ac9..9a3ba54 100644 --- a/tools/jtk/internal/cmd/issues/update.go +++ b/tools/jtk/internal/cmd/issues/update.go @@ -3,6 +3,7 @@ package issues import ( "fmt" "strings" + "time" "github.com/spf13/cobra" @@ -16,18 +17,25 @@ func newUpdateCmd(opts *root.Options) *cobra.Command { var description string var parent string var assignee string + var issueType string var fields []string cmd := &cobra.Command{ Use: "update ", Short: "Update an issue", - Long: "Update fields on an existing Jira issue.", + Long: `Update fields on an existing Jira issue. + +To change the issue type, use --type. This uses the Jira Cloud bulk move API +transparently (since the standard update API does not support type changes).`, Example: ` # Update summary jtk issues update PROJ-123 --summary "New summary" # Update description jtk issues update PROJ-123 --description "Updated description" + # Change issue type + jtk issues update PROJ-123 --type Story + # Move issue under a different parent/epic jtk issues update PROJ-123 --parent PROJ-100 @@ -38,7 +46,7 @@ func newUpdateCmd(opts *root.Options) *cobra.Command { jtk issues update PROJ-123 --field priority=High --field "Story Points"=5`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runUpdate(opts, args[0], summary, description, parent, assignee, fields) + return runUpdate(opts, args[0], summary, description, parent, assignee, issueType, fields) }, } @@ -46,16 +54,17 @@ func newUpdateCmd(opts *root.Options) *cobra.Command { cmd.Flags().StringVarP(&description, "description", "d", "", "New description") cmd.Flags().StringVar(&parent, "parent", "", "Parent issue key (epic or parent issue)") cmd.Flags().StringVarP(&assignee, "assignee", "a", "", "Assignee (account ID, email, or \"me\")") + cmd.Flags().StringVarP(&issueType, "type", "t", "", "New issue type (uses bulk move API)") cmd.Flags().StringArrayVarP(&fields, "field", "f", nil, "Fields to update (key=value)") return cmd } -func runUpdate(opts *root.Options, issueKey, summary, description, parent, assignee string, fieldArgs []string) error { +func runUpdate(opts *root.Options, issueKey, summary, description, parent, assignee, issueType string, fieldArgs []string) error { v := opts.View() // Validate that at least one field is being updated before making API calls - if summary == "" && description == "" && parent == "" && assignee == "" && len(fieldArgs) == 0 { + if summary == "" && description == "" && parent == "" && assignee == "" && issueType == "" && len(fieldArgs) == 0 { return fmt.Errorf("no fields specified to update") } @@ -64,6 +73,14 @@ func runUpdate(opts *root.Options, issueKey, summary, description, parent, assig return err } + // Handle type change via the move API + if issueType != "" { + if err := changeIssueType(client, v, issueKey, issueType); err != nil { + return err + } + } + + // Handle other field updates via the standard update API fields := make(map[string]interface{}) if summary != "" { @@ -119,6 +136,11 @@ func runUpdate(opts *root.Options, issueKey, summary, description, parent, assig } } + // If only --type was specified, we're already done + if len(fields) == 0 { + return nil + } + req := api.BuildUpdateRequest(fields) if err := client.UpdateIssue(issueKey, req); err != nil { @@ -128,3 +150,93 @@ func runUpdate(opts *root.Options, issueKey, summary, description, parent, assig v.Success("Updated issue %s", issueKey) return nil } + +func changeIssueType(client *api.Client, v interface { + Info(string, ...interface{}) + Success(string, ...interface{}) +}, issueKey, targetTypeName string) error { + // Get the issue to find its project + issue, err := client.GetIssue(issueKey) + if err != nil { + return fmt.Errorf("failed to get issue: %w", err) + } + + if issue.Fields.Project == nil { + return fmt.Errorf("issue %s has no project information", issueKey) + } + projectKey := issue.Fields.Project.Key + + // Check if the type is already correct + if issue.Fields.IssueType != nil && strings.EqualFold(issue.Fields.IssueType.Name, targetTypeName) { + v.Info("Issue %s is already type %s", issueKey, targetTypeName) + return nil + } + + // Get available issue types in the project + issueTypes, err := client.GetProjectIssueTypes(projectKey) + if err != nil { + return fmt.Errorf("failed to get project issue types: %w", err) + } + + var targetIssueType *api.IssueType + for i := range issueTypes { + if strings.EqualFold(issueTypes[i].Name, targetTypeName) { + targetIssueType = &issueTypes[i] + break + } + } + + if targetIssueType == nil { + var available []string + for _, t := range issueTypes { + if !t.Subtask { + available = append(available, t.Name) + } + } + return fmt.Errorf("issue type %q not found in project %s (available: %s)", targetTypeName, projectKey, strings.Join(available, ", ")) + } + + v.Info("Changing %s type to %s...", issueKey, targetIssueType.Name) + + // Use the move API to change the type within the same project + req := api.BuildMoveRequest([]string{issueKey}, projectKey, targetIssueType.ID, false) + + resp, err := client.MoveIssues(req) + if err != nil { + if strings.Contains(err.Error(), "404") || strings.Contains(err.Error(), "not found") { + return fmt.Errorf("type change failed - this feature requires Jira Cloud") + } + return fmt.Errorf("failed to change issue type: %w", err) + } + + // Wait for completion + for { + status, err := client.GetMoveTaskStatus(resp.TaskID) + if err != nil { + return fmt.Errorf("failed to get task status: %w", err) + } + + switch status.Status { + case "COMPLETE": + if status.Result != nil && len(status.Result.Failed) > 0 { + for _, failed := range status.Result.Failed { + return fmt.Errorf("type change failed for %s: %s", failed.IssueKey, strings.Join(failed.Errors, ", ")) + } + } + v.Success("Changed %s type to %s", issueKey, targetIssueType.Name) + return nil + + case "FAILED": + return fmt.Errorf("type change failed") + + case "CANCELLED": + return fmt.Errorf("type change was cancelled") + + case "ENQUEUED", "RUNNING": + time.Sleep(1 * time.Second) + + default: + return fmt.Errorf("unknown task status: %s", status.Status) + } + } +} diff --git a/tools/jtk/internal/cmd/issues/update_test.go b/tools/jtk/internal/cmd/issues/update_test.go index 0ee1790..60bdcf7 100644 --- a/tools/jtk/internal/cmd/issues/update_test.go +++ b/tools/jtk/internal/cmd/issues/update_test.go @@ -43,7 +43,7 @@ func TestRunUpdate_RequestBodyNoDoubleQuoting(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(opts, "PROJ-123", "Updated summary", "Updated description", "", "", nil) + err = runUpdate(opts, "PROJ-123", "Updated summary", "Updated description", "", "", "", nil) require.NoError(t, err) require.NotEmpty(t, capturedBody) @@ -95,6 +95,120 @@ func TestNewUpdateCmd(t *testing.T) { assigneeFlag := cmd.Flags().Lookup("assignee") require.NotNil(t, assigneeFlag) assert.Equal(t, "a", assigneeFlag.Shorthand) + + typeFlag := cmd.Flags().Lookup("type") + require.NotNil(t, typeFlag) + assert.Equal(t, "t", typeFlag.Shorthand) +} + +func TestRunUpdate_TypeChange(t *testing.T) { + var moveBody []byte + moveCompleted := false + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/rest/api/3/issue/PROJ-123" && r.Method == "GET": + json.NewEncoder(w).Encode(api.Issue{ + Key: "PROJ-123", + ID: "10001", + Fields: api.IssueFields{ + Project: &api.Project{Key: "PROJ"}, + IssueType: &api.IssueType{ID: "10000", Name: "Epic"}, + }, + }) + case r.URL.Path == "/rest/api/3/project/PROJ" && r.Method == "GET": + json.NewEncoder(w).Encode(struct { + IssueTypes []api.IssueType `json:"issueTypes"` + }{ + IssueTypes: []api.IssueType{ + {ID: "10000", Name: "Epic"}, + {ID: "10001", Name: "Task"}, + {ID: "10002", Name: "Story"}, + }, + }) + case r.URL.Path == "/rest/api/3/bulk/issues/move" && r.Method == "POST": + moveBody, _ = io.ReadAll(r.Body) + moveCompleted = true + json.NewEncoder(w).Encode(api.MoveIssuesResponse{TaskID: "task-123"}) + case r.URL.Path == "/rest/api/3/bulk/queue/task-123" && r.Method == "GET": + json.NewEncoder(w).Encode(api.MoveTaskStatus{ + TaskID: "task-123", + Status: "COMPLETE", + Progress: 100, + Result: &api.MoveTaskResult{Successful: []string{"PROJ-123"}}, + }) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + URL: server.URL, + Email: "test@example.com", + APIToken: "token", + }) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{ + Output: "table", + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + err = runUpdate(opts, "PROJ-123", "", "", "", "", "Task", nil) + require.NoError(t, err) + assert.True(t, moveCompleted, "should have called the move API") + + // Verify move request body + var moveReq api.MoveIssuesRequest + err = json.Unmarshal(moveBody, &moveReq) + require.NoError(t, err) + + // The target key should be "PROJ,10001" (project key, Task type ID) + spec, ok := moveReq.TargetToSourcesMapping["PROJ,10001"] + require.True(t, ok, "should have mapping for PROJ,10001") + assert.Equal(t, []string{"PROJ-123"}, spec.IssueIdsOrKeys) +} + +func TestRunUpdate_TypeAlreadyCorrect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/rest/api/3/issue/PROJ-123" && r.Method == "GET" { + json.NewEncoder(w).Encode(api.Issue{ + Key: "PROJ-123", + ID: "10001", + Fields: api.IssueFields{ + Project: &api.Project{Key: "PROJ"}, + IssueType: &api.IssueType{ID: "10001", Name: "Task"}, + }, + }) + return + } + // No move API should be called + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{ + URL: server.URL, + Email: "test@example.com", + APIToken: "token", + }) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{ + Output: "table", + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + } + opts.SetAPIClient(client) + + // Should succeed without calling move API since it's already the right type + err = runUpdate(opts, "PROJ-123", "", "", "", "", "Task", nil) + require.NoError(t, err) } func TestRunUpdate_SummaryOnly(t *testing.T) { @@ -125,7 +239,7 @@ func TestRunUpdate_SummaryOnly(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(opts, "PROJ-123", "New summary", "", "", "", nil) + err = runUpdate(opts, "PROJ-123", "New summary", "", "", "", "", nil) require.NoError(t, err) var reqBody map[string]interface{} @@ -145,7 +259,7 @@ func TestRunUpdate_NoFieldsError(t *testing.T) { Stderr: &bytes.Buffer{}, } - err := runUpdate(opts, "PROJ-123", "", "", "", "", nil) + err := runUpdate(opts, "PROJ-123", "", "", "", "", "", nil) assert.Error(t, err) assert.Contains(t, err.Error(), "no fields specified") } @@ -178,7 +292,7 @@ func TestRunUpdate_ParentOnly(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(opts, "PROJ-456", "", "", "PROJ-100", "", nil) + err = runUpdate(opts, "PROJ-456", "", "", "PROJ-100", "", "", nil) require.NoError(t, err) require.NotEmpty(t, capturedBody) @@ -221,7 +335,7 @@ func TestRunUpdate_ParentWithSummary(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(opts, "PROJ-456", "Updated title", "", "PROJ-200", "", nil) + err = runUpdate(opts, "PROJ-456", "Updated title", "", "PROJ-200", "", "", nil) require.NoError(t, err) require.NotEmpty(t, capturedBody) @@ -310,7 +424,7 @@ func TestRunUpdate_AssigneeOnly(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(opts, "PROJ-789", "", "", "", "61292e4c4f29230069621c5f", nil) + err = runUpdate(opts, "PROJ-789", "", "", "", "61292e4c4f29230069621c5f", "", nil) require.NoError(t, err) require.NotEmpty(t, capturedBody) @@ -359,7 +473,7 @@ func TestRunUpdate_AssigneeMe(t *testing.T) { } opts.SetAPIClient(client) - err = runUpdate(opts, "PROJ-789", "", "", "", "me", nil) + err = runUpdate(opts, "PROJ-789", "", "", "", "me", "", nil) require.NoError(t, err) require.NotEmpty(t, capturedBody)