Skip to content
Merged
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
120 changes: 116 additions & 4 deletions tools/jtk/internal/cmd/issues/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package issues
import (
"fmt"
"strings"
"time"

"github.com/spf13/cobra"

Expand All @@ -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 <issue-key>",
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

Expand All @@ -38,24 +46,25 @@ 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)
},
}

cmd.Flags().StringVarP(&summary, "summary", "s", "", "New summary")
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")
}

Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
}
128 changes: 121 additions & 7 deletions tools/jtk/internal/cmd/issues/update_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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{}
Expand All @@ -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")
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading