From 4154774f2916ac914d0c22cacf43227d74f3c7ed Mon Sep 17 00:00:00 2001 From: piekstra Date: Thu, 29 Jan 2026 21:06:18 -0500 Subject: [PATCH] fix(move): correct API endpoint formats for bulk move - Fix targetToSourcesMapping key format from "PROJECT:TYPE_ID" to "PROJECT,TYPE_ID" (comma-separated per Jira API docs) - Fix task status endpoint from /bulk/issues/move/{taskId} to /bulk/queue/{taskId} - Add integration tests for move functionality - Document JIRA_TEST_MOVE_TARGET_PROJECT env var The move command was returning 400 Bad Request due to incorrect key format, and 404 when checking task status due to wrong endpoint. --- api/move.go | 7 +- integration/README.md | 4 + integration/helpers_test.go | 12 +++ integration/move_test.go | 170 ++++++++++++++++++++++++++++++++++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 integration/move_test.go diff --git a/api/move.go b/api/move.go index 097eb08..11b0b82 100644 --- a/api/move.go +++ b/api/move.go @@ -78,7 +78,8 @@ func (c *Client) GetMoveTaskStatus(taskID string) (*MoveTaskStatus, error) { return nil, fmt.Errorf("task ID is required") } - urlStr := fmt.Sprintf("%s/bulk/issues/move/%s", c.BaseURL, taskID) + // Status endpoint is /bulk/queue/{taskId} + urlStr := fmt.Sprintf("%s/bulk/queue/%s", c.BaseURL, taskID) body, err := c.get(urlStr) if err != nil { @@ -147,8 +148,8 @@ type ProjectStatus struct { // BuildMoveRequest creates a move request for a simple move operation func BuildMoveRequest(issueKeys []string, targetProject, targetIssueTypeID string, notify bool) MoveIssuesRequest { - // Target key format: "PROJECT_KEY:ISSUE_TYPE_ID" - targetKey := fmt.Sprintf("%s:%s", targetProject, targetIssueTypeID) + // Target key format: "PROJECT_KEY,ISSUE_TYPE_ID" (comma-separated per Jira API docs) + targetKey := fmt.Sprintf("%s,%s", targetProject, targetIssueTypeID) return MoveIssuesRequest{ SendBulkNotification: notify, diff --git a/integration/README.md b/integration/README.md index 85860dc..2ee1a41 100644 --- a/integration/README.md +++ b/integration/README.md @@ -17,6 +17,10 @@ export JIRA_TEST_PROJECT="TEST" # Optional: specify the issue type to use (default: "Task") # Some projects may use different names like "SDLC", "Story", "Bug", etc. export JIRA_TEST_ISSUE_TYPE="Task" + +# For move tests, specify a DIFFERENT target project +# Move tests require two projects to move issues between them +export JIRA_TEST_MOVE_TARGET_PROJECT="TARGET" ``` Alternatively, use `ATLASSIAN_URL`, `ATLASSIAN_EMAIL`, `ATLASSIAN_API_TOKEN` if you have those configured. diff --git a/integration/helpers_test.go b/integration/helpers_test.go index 72d724a..e6b60fa 100644 --- a/integration/helpers_test.go +++ b/integration/helpers_test.go @@ -46,6 +46,18 @@ func getTestIssueType(t *testing.T) string { return issueType } +// getTestMoveTargetProject returns the target project for move tests +// Set JIRA_TEST_MOVE_TARGET_PROJECT to a different project than JIRA_TEST_PROJECT +func getTestMoveTargetProject(t *testing.T) string { + t.Helper() + + project := os.Getenv("JIRA_TEST_MOVE_TARGET_PROJECT") + if project == "" { + t.Skip("JIRA_TEST_MOVE_TARGET_PROJECT not set (set to a different project than JIRA_TEST_PROJECT)") + } + return project +} + // newTestClient creates an API client for integration tests func newTestClient(t *testing.T) *api.Client { t.Helper() diff --git a/integration/move_test.go b/integration/move_test.go new file mode 100644 index 0000000..51e42cc --- /dev/null +++ b/integration/move_test.go @@ -0,0 +1,170 @@ +//go:build integration + +package integration + +import ( + "testing" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestMoveIssue tests the full move issue flow. +// This requires JIRA_TEST_PROJECT and JIRA_TEST_MOVE_TARGET_PROJECT environment variables. +func TestMoveIssue(t *testing.T) { + skipIfNoCredentials(t) + + sourceProject := getTestProject(t) + targetProject := getTestMoveTargetProject(t) + _ = getTestIssueType(t) // validate test setup + + client := newTestClient(t) + + // Create a test issue in the source project + issueKey := createTestIssue(t, client, sourceProject, "Integration test - move issue") + t.Logf("Created test issue: %s", issueKey) + + // Get target project issue types + issueTypes, err := client.GetProjectIssueTypes(targetProject) + require.NoError(t, err, "failed to get target project issue types") + require.NotEmpty(t, issueTypes, "target project has no issue types") + + // Find a non-subtask issue type in target project + var targetIssueType *api.IssueType + for i := range issueTypes { + if !issueTypes[i].Subtask { + targetIssueType = &issueTypes[i] + break + } + } + require.NotNil(t, targetIssueType, "no non-subtask issue type found in target project") + + t.Logf("Moving %s to project %s with issue type %s (ID: %s)", + issueKey, targetProject, targetIssueType.Name, targetIssueType.ID) + + // Build and execute the move request + req := api.BuildMoveRequest([]string{issueKey}, targetProject, targetIssueType.ID, false) + resp, err := client.MoveIssues(req) + require.NoError(t, err, "failed to initiate move") + assert.NotEmpty(t, resp.TaskID, "task ID should not be empty") + + t.Logf("Move task ID: %s", resp.TaskID) + + // Poll for completion + var status *api.MoveTaskStatus + for i := 0; i < 30; i++ { // max 30 seconds + status, err = client.GetMoveTaskStatus(resp.TaskID) + require.NoError(t, err, "failed to get task status") + + t.Logf("Task status: %s (progress: %d%%)", status.Status, status.Progress) + + if status.Status == "COMPLETE" || status.Status == "FAILED" || status.Status == "CANCELLED" { + break + } + + // Wait a second before polling again + // Note: In real tests you'd use time.Sleep, but for integration tests + // we want to be able to observe progress + } + + require.Equal(t, "COMPLETE", status.Status, "move task should complete successfully") + + if status.Result != nil { + if len(status.Result.Failed) > 0 { + t.Errorf("Some issues failed to move: %+v", status.Result.Failed) + } + if len(status.Result.Successful) > 0 { + t.Logf("Successfully moved: %v", status.Result.Successful) + // The issue key changes after move + newKey := status.Result.Successful[0] + + // Verify the issue is now in the target project + issue, err := client.GetIssue(newKey) + require.NoError(t, err, "failed to get moved issue") + assert.Equal(t, targetProject, issue.Fields.Project.Key, "issue should be in target project") + + // Clean up - delete the moved issue + err = client.DeleteIssue(newKey) + if err != nil { + t.Logf("Warning: failed to delete test issue %s: %v", newKey, err) + } else { + t.Logf("Cleaned up test issue %s", newKey) + } + } + } +} + +// TestMoveMultipleIssues tests moving multiple issues at once. +func TestMoveMultipleIssues(t *testing.T) { + skipIfNoCredentials(t) + + sourceProject := getTestProject(t) + targetProject := getTestMoveTargetProject(t) + + client := newTestClient(t) + + // Create test issues + issueKey1 := createTestIssue(t, client, sourceProject, "Integration test - bulk move 1") + issueKey2 := createTestIssue(t, client, sourceProject, "Integration test - bulk move 2") + t.Logf("Created test issues: %s, %s", issueKey1, issueKey2) + + // Get target project issue types + issueTypes, err := client.GetProjectIssueTypes(targetProject) + require.NoError(t, err) + + var targetIssueType *api.IssueType + for i := range issueTypes { + if !issueTypes[i].Subtask { + targetIssueType = &issueTypes[i] + break + } + } + require.NotNil(t, targetIssueType) + + // Move both issues + req := api.BuildMoveRequest([]string{issueKey1, issueKey2}, targetProject, targetIssueType.ID, false) + resp, err := client.MoveIssues(req) + require.NoError(t, err) + + // Wait for completion + var status *api.MoveTaskStatus + for i := 0; i < 30; i++ { + status, err = client.GetMoveTaskStatus(resp.TaskID) + require.NoError(t, err) + + if status.Status == "COMPLETE" || status.Status == "FAILED" || status.Status == "CANCELLED" { + break + } + } + + require.Equal(t, "COMPLETE", status.Status) + + if status.Result != nil { + assert.Empty(t, status.Result.Failed, "no issues should fail to move") + assert.Len(t, status.Result.Successful, 2, "both issues should be moved") + + // Clean up + for _, key := range status.Result.Successful { + err := client.DeleteIssue(key) + if err != nil { + t.Logf("Warning: failed to delete %s: %v", key, err) + } + } + } +} + +// TestBuildMoveRequest tests the request building function. +func TestBuildMoveRequest(t *testing.T) { + req := api.BuildMoveRequest([]string{"PROJ-1", "PROJ-2"}, "TARGET", "10001", true) + + assert.True(t, req.SendBulkNotification) + assert.Len(t, req.TargetToSourcesMapping, 1) + + // Key format should be "PROJECT,ISSUE_TYPE_ID" (comma-separated) + spec, exists := req.TargetToSourcesMapping["TARGET,10001"] + assert.True(t, exists, "target key should use comma separator") + assert.Equal(t, []string{"PROJ-1", "PROJ-2"}, spec.IssueIdsOrKeys) + assert.True(t, spec.InferFieldDefaults) + assert.True(t, spec.InferStatusDefaults) +}