diff --git a/tools/jtk/CHANGELOG.md b/tools/jtk/CHANGELOG.md index e0cc569..2815781 100644 --- a/tools/jtk/CHANGELOG.md +++ b/tools/jtk/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `fields` command group for custom field management: `create`, `delete` (trash), `restore`, `contexts` (list/create/delete), and `options` (list/add/update/delete) ([#155](https://github.com/open-cli-collective/atlassian-cli/issues/155)) - `projects create`, `update`, `delete`, `restore`, `types` commands for full project management ([#106](https://github.com/open-cli-collective/atlassian-cli/pull/106)) - `automation create` command to create rules from JSON files ([#79](https://github.com/open-cli-collective/atlassian-cli/pull/79)) - `automation enable`, `disable`, `update`, `export` commands for full automation rule management ([#76](https://github.com/open-cli-collective/atlassian-cli/pull/76)) diff --git a/tools/jtk/CLAUDE.md b/tools/jtk/CLAUDE.md index 52e9235..0d693cf 100644 --- a/tools/jtk/CLAUDE.md +++ b/tools/jtk/CLAUDE.md @@ -56,6 +56,7 @@ jira-ticket-cli/ │ ├── cmd/ # Cobra commands (one package per resource) │ │ ├── root/ # Root command, Options struct, global flags │ │ ├── issues/ # issues list, get, create, update, delete, search, assign, fields, field-options, types, move +│ │ ├── fields/ # fields list, create, delete, restore, contexts (list/create/delete), options (list/add/update/delete) │ │ ├── projects/ # projects list, get, create, update, delete, restore, types │ │ ├── transitions/ # transitions list, do │ │ ├── comments/ # comments list, add, delete diff --git a/tools/jtk/api/errors.go b/tools/jtk/api/errors.go index 103b654..7df1077 100644 --- a/tools/jtk/api/errors.go +++ b/tools/jtk/api/errors.go @@ -10,6 +10,7 @@ import ( var ( ErrIssueKeyRequired = errors.New("issue key is required") ErrProjectKeyRequired = errors.New("project key is required") + ErrFieldIDRequired = errors.New("field ID is required") ) // APIError is an alias for the shared APIError type diff --git a/tools/jtk/api/field_management.go b/tools/jtk/api/field_management.go new file mode 100644 index 0000000..0591973 --- /dev/null +++ b/tools/jtk/api/field_management.go @@ -0,0 +1,255 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/url" +) + +// CreateFieldRequest represents a request to create a custom field +type CreateFieldRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Description string `json:"description,omitempty"` + SearcherKey string `json:"searcherKey,omitempty"` +} + +// FieldContext represents a custom field context +type FieldContext struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + IsGlobalContext bool `json:"isGlobalContext"` + IsAnyIssueType bool `json:"isAnyIssueType"` +} + +// FieldContextsResponse represents the paginated response from listing contexts +type FieldContextsResponse struct { + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` + Total int `json:"total"` + IsLast bool `json:"isLast"` + Values []FieldContext `json:"values"` +} + +// CreateFieldContextRequest represents a request to create a field context +type CreateFieldContextRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + ProjectIDs []string `json:"projectIds,omitempty"` + IssueTypeIDs []string `json:"issueTypeIds,omitempty"` +} + +// FieldContextOption represents a single option in a context +type FieldContextOption struct { + ID string `json:"id"` + Value string `json:"value"` + Disabled bool `json:"disabled"` +} + +// FieldContextOptionsResponse represents the paginated response from listing context options +type FieldContextOptionsResponse struct { + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` + Total int `json:"total"` + IsLast bool `json:"isLast"` + Values []FieldContextOption `json:"values"` +} + +// CreateFieldContextOptionsRequest represents a request to create options +type CreateFieldContextOptionsRequest struct { + Options []CreateFieldContextOptionEntry `json:"options"` +} + +// CreateFieldContextOptionEntry represents a single option to create +type CreateFieldContextOptionEntry struct { + Value string `json:"value"` + Disabled bool `json:"disabled,omitempty"` +} + +// UpdateFieldContextOptionsRequest represents a request to update options +type UpdateFieldContextOptionsRequest struct { + Options []UpdateFieldContextOptionEntry `json:"options"` +} + +// UpdateFieldContextOptionEntry represents a single option to update +type UpdateFieldContextOptionEntry struct { + ID string `json:"id"` + Value string `json:"value,omitempty"` + Disabled bool `json:"disabled,omitempty"` +} + +// CreateField creates a new custom field +func (c *Client) CreateField(req *CreateFieldRequest) (*Field, error) { + urlStr := fmt.Sprintf("%s/field", c.BaseURL) + body, err := c.post(urlStr, req) + if err != nil { + return nil, err + } + + var field Field + if err := json.Unmarshal(body, &field); err != nil { + return nil, fmt.Errorf("failed to parse created field: %w", err) + } + + return &field, nil +} + +// TrashField moves a custom field to the trash (soft delete) +func (c *Client) TrashField(fieldID string) error { + if fieldID == "" { + return ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/trash", c.BaseURL, url.PathEscape(fieldID)) + _, err := c.post(urlStr, nil) + return err +} + +// RestoreField restores a custom field from the trash +func (c *Client) RestoreField(fieldID string) error { + if fieldID == "" { + return ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/restore", c.BaseURL, url.PathEscape(fieldID)) + _, err := c.post(urlStr, nil) + return err +} + +// GetFieldContexts returns the contexts for a custom field +func (c *Client) GetFieldContexts(fieldID string) (*FieldContextsResponse, error) { + if fieldID == "" { + return nil, ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/context", c.BaseURL, url.PathEscape(fieldID)) + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result FieldContextsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse field contexts: %w", err) + } + + return &result, nil +} + +// GetDefaultFieldContext returns the first context for a field. +// Used when --context is omitted to auto-detect the default context. +func (c *Client) GetDefaultFieldContext(fieldID string) (*FieldContext, error) { + result, err := c.GetFieldContexts(fieldID) + if err != nil { + return nil, err + } + + if len(result.Values) == 0 { + return nil, fmt.Errorf("no contexts found for field %s", fieldID) + } + + return &result.Values[0], nil +} + +// CreateFieldContext creates a new context for a custom field +func (c *Client) CreateFieldContext(fieldID string, req *CreateFieldContextRequest) (*FieldContext, error) { + if fieldID == "" { + return nil, ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/context", c.BaseURL, url.PathEscape(fieldID)) + body, err := c.post(urlStr, req) + if err != nil { + return nil, err + } + + var result FieldContext + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse created field context: %w", err) + } + + return &result, nil +} + +// DeleteFieldContext deletes a field context +func (c *Client) DeleteFieldContext(fieldID, contextID string) error { + if fieldID == "" { + return ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/context/%s", c.BaseURL, url.PathEscape(fieldID), url.PathEscape(contextID)) + _, err := c.delete(urlStr) + return err +} + +// GetFieldContextOptions returns the options for a field context +func (c *Client) GetFieldContextOptions(fieldID, contextID string) (*FieldContextOptionsResponse, error) { + if fieldID == "" { + return nil, ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/context/%s/option", c.BaseURL, url.PathEscape(fieldID), url.PathEscape(contextID)) + body, err := c.get(urlStr) + if err != nil { + return nil, err + } + + var result FieldContextOptionsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse field context options: %w", err) + } + + return &result, nil +} + +// CreateFieldContextOptions creates new options in a field context +func (c *Client) CreateFieldContextOptions(fieldID, contextID string, req *CreateFieldContextOptionsRequest) ([]FieldContextOption, error) { + if fieldID == "" { + return nil, ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/context/%s/option", c.BaseURL, url.PathEscape(fieldID), url.PathEscape(contextID)) + body, err := c.post(urlStr, req) + if err != nil { + return nil, err + } + + var result FieldContextOptionsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse created field context options: %w", err) + } + + return result.Values, nil +} + +// UpdateFieldContextOptions updates existing options in a field context +func (c *Client) UpdateFieldContextOptions(fieldID, contextID string, req *UpdateFieldContextOptionsRequest) ([]FieldContextOption, error) { + if fieldID == "" { + return nil, ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/context/%s/option", c.BaseURL, url.PathEscape(fieldID), url.PathEscape(contextID)) + body, err := c.put(urlStr, req) + if err != nil { + return nil, err + } + + var result FieldContextOptionsResponse + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse updated field context options: %w", err) + } + + return result.Values, nil +} + +// DeleteFieldContextOption deletes an option from a field context +func (c *Client) DeleteFieldContextOption(fieldID, contextID, optionID string) error { + if fieldID == "" { + return ErrFieldIDRequired + } + + urlStr := fmt.Sprintf("%s/field/%s/context/%s/option/%s", c.BaseURL, url.PathEscape(fieldID), url.PathEscape(contextID), url.PathEscape(optionID)) + _, err := c.delete(urlStr) + return err +} diff --git a/tools/jtk/api/field_management_test.go b/tools/jtk/api/field_management_test.go new file mode 100644 index 0000000..e5dbf4a --- /dev/null +++ b/tools/jtk/api/field_management_test.go @@ -0,0 +1,338 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func newTestClient(t *testing.T, server *httptest.Server) *Client { + t.Helper() + client, err := New(ClientConfig{ + URL: "https://test.atlassian.net", + Email: "test@example.com", + APIToken: "test-token", + }) + require.NoError(t, err) + if server != nil { + client.BaseURL = server.URL + "/rest/api/3" + } + return client +} + +func TestCreateField(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/rest/api/3/field", r.URL.Path) + + var req CreateFieldRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Equal(t, "Environment", req.Name) + assert.Equal(t, "com.atlassian.jira.plugin.system.customfieldtypes:select", req.Type) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(Field{ + ID: "customfield_10100", + Name: "Environment", + Custom: true, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + field, err := client.CreateField(&CreateFieldRequest{ + Name: "Environment", + Type: "com.atlassian.jira.plugin.system.customfieldtypes:select", + }) + require.NoError(t, err) + assert.Equal(t, "customfield_10100", field.ID) + assert.Equal(t, "Environment", field.Name) + assert.True(t, field.Custom) +} + +func TestCreateField_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"errorMessages":["Field name already exists"]}`)) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.CreateField(&CreateFieldRequest{Name: "Dupe", Type: "select"}) + assert.Error(t, err) +} + +func TestTrashField(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/trash", r.URL.Path) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := newTestClient(t, server) + err := client.TrashField("customfield_10100") + assert.NoError(t, err) +} + +func TestTrashField_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + err := client.TrashField("") + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestRestoreField(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/restore", r.URL.Path) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client := newTestClient(t, server) + err := client.RestoreField("customfield_10100") + assert.NoError(t, err) +} + +func TestRestoreField_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + err := client.RestoreField("") + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestGetFieldContexts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/context", r.URL.Path) + json.NewEncoder(w).Encode(FieldContextsResponse{ + MaxResults: 50, + Total: 2, + IsLast: true, + Values: []FieldContext{ + {ID: "10001", Name: "Default", IsGlobalContext: true, IsAnyIssueType: true}, + {ID: "10002", Name: "Bug Context", IsGlobalContext: false, IsAnyIssueType: false}, + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + result, err := client.GetFieldContexts("customfield_10100") + require.NoError(t, err) + assert.Len(t, result.Values, 2) + assert.Equal(t, "Default", result.Values[0].Name) + assert.True(t, result.Values[0].IsGlobalContext) +} + +func TestGetFieldContexts_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + _, err := client.GetFieldContexts("") + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestGetDefaultFieldContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(FieldContextsResponse{ + Values: []FieldContext{ + {ID: "10001", Name: "Default"}, + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + ctx, err := client.GetDefaultFieldContext("customfield_10100") + require.NoError(t, err) + assert.Equal(t, "10001", ctx.ID) + assert.Equal(t, "Default", ctx.Name) +} + +func TestGetDefaultFieldContext_NoContexts(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(FieldContextsResponse{Values: []FieldContext{}}) + })) + defer server.Close() + + client := newTestClient(t, server) + _, err := client.GetDefaultFieldContext("customfield_10100") + assert.Error(t, err) + assert.Contains(t, err.Error(), "no contexts found") +} + +func TestCreateFieldContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/context", r.URL.Path) + + var req CreateFieldContextRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Equal(t, "Bug Context", req.Name) + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(FieldContext{ + ID: "10003", + Name: "Bug Context", + }) + })) + defer server.Close() + + client := newTestClient(t, server) + ctx, err := client.CreateFieldContext("customfield_10100", &CreateFieldContextRequest{ + Name: "Bug Context", + }) + require.NoError(t, err) + assert.Equal(t, "10003", ctx.ID) + assert.Equal(t, "Bug Context", ctx.Name) +} + +func TestCreateFieldContext_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + _, err := client.CreateFieldContext("", &CreateFieldContextRequest{Name: "test"}) + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestDeleteFieldContext(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/context/10003", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server) + err := client.DeleteFieldContext("customfield_10100", "10003") + assert.NoError(t, err) +} + +func TestDeleteFieldContext_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + err := client.DeleteFieldContext("", "10003") + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestGetFieldContextOptions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/context/10001/option", r.URL.Path) + json.NewEncoder(w).Encode(FieldContextOptionsResponse{ + MaxResults: 50, + Total: 2, + IsLast: true, + Values: []FieldContextOption{ + {ID: "1", Value: "Production", Disabled: false}, + {ID: "2", Value: "Staging", Disabled: false}, + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + result, err := client.GetFieldContextOptions("customfield_10100", "10001") + require.NoError(t, err) + assert.Len(t, result.Values, 2) + assert.Equal(t, "Production", result.Values[0].Value) +} + +func TestGetFieldContextOptions_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + _, err := client.GetFieldContextOptions("", "10001") + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestCreateFieldContextOptions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/context/10001/option", r.URL.Path) + + var req CreateFieldContextOptionsRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Len(t, req.Options, 1) + assert.Equal(t, "Option A", req.Options[0].Value) + + json.NewEncoder(w).Encode(FieldContextOptionsResponse{ + Values: []FieldContextOption{ + {ID: "3", Value: "Option A"}, + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + options, err := client.CreateFieldContextOptions("customfield_10100", "10001", &CreateFieldContextOptionsRequest{ + Options: []CreateFieldContextOptionEntry{ + {Value: "Option A"}, + }, + }) + require.NoError(t, err) + assert.Len(t, options, 1) + assert.Equal(t, "Option A", options[0].Value) +} + +func TestCreateFieldContextOptions_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + _, err := client.CreateFieldContextOptions("", "10001", &CreateFieldContextOptionsRequest{}) + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestUpdateFieldContextOptions(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/context/10001/option", r.URL.Path) + + var req UpdateFieldContextOptionsRequest + err := json.NewDecoder(r.Body).Decode(&req) + require.NoError(t, err) + assert.Len(t, req.Options, 1) + assert.Equal(t, "3", req.Options[0].ID) + assert.Equal(t, "Option A (updated)", req.Options[0].Value) + + json.NewEncoder(w).Encode(FieldContextOptionsResponse{ + Values: []FieldContextOption{ + {ID: "3", Value: "Option A (updated)"}, + }, + }) + })) + defer server.Close() + + client := newTestClient(t, server) + options, err := client.UpdateFieldContextOptions("customfield_10100", "10001", &UpdateFieldContextOptionsRequest{ + Options: []UpdateFieldContextOptionEntry{ + {ID: "3", Value: "Option A (updated)"}, + }, + }) + require.NoError(t, err) + assert.Len(t, options, 1) + assert.Equal(t, "Option A (updated)", options[0].Value) +} + +func TestUpdateFieldContextOptions_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + _, err := client.UpdateFieldContextOptions("", "10001", &UpdateFieldContextOptionsRequest{}) + assert.ErrorIs(t, err, ErrFieldIDRequired) +} + +func TestDeleteFieldContextOption(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/rest/api/3/field/customfield_10100/context/10001/option/3", r.URL.Path) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client := newTestClient(t, server) + err := client.DeleteFieldContextOption("customfield_10100", "10001", "3") + assert.NoError(t, err) +} + +func TestDeleteFieldContextOption_EmptyID(t *testing.T) { + client := newTestClient(t, nil) + err := client.DeleteFieldContextOption("", "10001", "3") + assert.ErrorIs(t, err, ErrFieldIDRequired) +} diff --git a/tools/jtk/cmd/jtk/main.go b/tools/jtk/cmd/jtk/main.go index 2c0733c..b0ecad2 100644 --- a/tools/jtk/cmd/jtk/main.go +++ b/tools/jtk/cmd/jtk/main.go @@ -13,6 +13,7 @@ import ( "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/comments" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/completion" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/configcmd" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/fields" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/initcmd" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/issues" "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/me" @@ -36,6 +37,7 @@ func run() error { // Register all commands initcmd.Register(rootCmd, opts) configcmd.Register(rootCmd, opts) + fields.Register(rootCmd, opts) issues.Register(rootCmd, opts) transitions.Register(rootCmd, opts) comments.Register(rootCmd, opts) diff --git a/tools/jtk/integration-tests.md b/tools/jtk/integration-tests.md index a6db95b..547c619 100644 --- a/tools/jtk/integration-tests.md +++ b/tools/jtk/integration-tests.md @@ -50,6 +50,13 @@ jtk sprints list -b $BOARD_ID -s active # $AUTO_UUID — pick an enabled automation rule jtk auto list --state ENABLED # Note a UUID from the first column + +# $CUSTOM_FIELD — pick a custom field ID +jtk fields list --custom +# Note an ID, e.g., customfield_10001 + +# $SELECT_FIELD — pick a select/multiselect custom field with options +# (same as $CUSTOM_FIELD if it's a select type) ``` ### Test Data Conventions @@ -522,6 +529,8 @@ Verify each alias produces the same output as the full command: | 8 | `jtk tr list $EXISTING_ISSUE` | `jtk transitions list $EXISTING_ISSUE` | | 9 | `jtk c list $EXISTING_ISSUE --max 1` | `jtk comments list $EXISTING_ISSUE --max 1` | | 10 | `jtk att list $EXISTING_ISSUE` | `jtk attachments list $EXISTING_ISSUE` | +| 11 | `jtk f list --max 1` | `jtk fields list --max 1` | +| 12 | `jtk field list --max 1` | `jtk fields list --max 1` | ### Shell completion @@ -545,12 +554,145 @@ Verify each alias produces the same output as the full command: --- +## 13. Fields (Read-Only) + +### fields list + +| # | Command | Expected Output | +|---|---------|-----------------| +| 1 | `jtk fields list` | Table with columns: ID, NAME, TYPE, CUSTOM | +| 2 | `jtk fields list --custom` | Same table but only rows where CUSTOM = yes | +| 3 | `jtk fields list -o json` | Valid JSON array | + +### fields contexts list + +| # | Command | Expected Output | +|---|---------|-----------------| +| 1 | `jtk fields contexts list $CUSTOM_FIELD` | Table with columns: ID, NAME, GLOBAL, ANY_ISSUE_TYPE | +| 2 | `jtk fields contexts list $CUSTOM_FIELD -o json` | Valid JSON array | +| 3 | `jtk fields contexts list customfield_99999` | Error: 404 | + +### fields options list + +> Options list auto-detects the default context when `--context` is omitted. + +| # | Command | Expected Output | +|---|---------|-----------------| +| 1 | `jtk fields options list $SELECT_FIELD` | Table with columns: ID, VALUE, DISABLED | +| 2 | `jtk fields options list $SELECT_FIELD -o json` | Valid JSON array | + +--- + +## 14. Field Mutations + +Run these steps in order. Each step depends on the previous. + +> Field management requires "Administer Jira" global permission. If you get 403 errors, verify your account has this permission. + +### Create and manage a test field + +1. **Create a select field:** + ```bash + jtk fields create --name "[Test] Integration Select" --type com.atlassian.jira.plugin.system.customfieldtypes:select + ``` + Expected: `✓ Created field customfield_XXXXX ([Test] Integration Select)` + Capture the field ID → `$TEST_FIELD` + +2. **Verify creation:** + ```bash + jtk fields list --custom -o json | jq '.[] | select(.name == "[Test] Integration Select")' + ``` + Expected: JSON object with matching `name` and `id` + +3. **List contexts:** + ```bash + jtk fields contexts list $TEST_FIELD + ``` + Expected: Table showing the default context. Capture context ID → `$TEST_CTX` + +4. **Add options:** + ```bash + jtk fields options add $TEST_FIELD --value "Option A" + ``` + Expected: `✓ Added option XXXXX (Option A)` + ```bash + jtk fields options add $TEST_FIELD --value "Option B" + ``` + Expected: `✓ Added option XXXXX (Option B)` + +5. **List options:** + ```bash + jtk fields options list $TEST_FIELD + ``` + Expected: Table showing Option A and Option B + Capture an option ID → `$OPT_ID` + +6. **Update option:** + ```bash + jtk fields options update $TEST_FIELD --option $OPT_ID --value "Option A (updated)" + ``` + Expected: `✓ Updated option $OPT_ID` + +7. **Verify update:** + ```bash + jtk fields options list $TEST_FIELD + ``` + Expected: Shows "Option A (updated)" instead of "Option A" + +8. **Delete option:** + ```bash + jtk fields options delete $TEST_FIELD --option $OPT_ID --force + ``` + Expected: `✓ Deleted option $OPT_ID from field $TEST_FIELD` + +9. **Create context:** + ```bash + jtk fields contexts create $TEST_FIELD --name "[Test] Context" + ``` + Expected: `✓ Created context XXXXX ([Test] Context)` + Capture context ID → `$NEW_CTX` + +10. **Delete context:** + ```bash + jtk fields contexts delete $TEST_FIELD $NEW_CTX --force + ``` + Expected: `✓ Deleted context $NEW_CTX from field $TEST_FIELD` + +11. **Trash field:** + ```bash + jtk fields delete $TEST_FIELD --force + ``` + Expected: `✓ Trashed field $TEST_FIELD` + +12. **Restore field:** + ```bash + jtk fields restore $TEST_FIELD + ``` + Expected: `✓ Restored field $TEST_FIELD` + +13. **Final cleanup — trash again:** + ```bash + jtk fields delete $TEST_FIELD --force + ``` + Expected: `✓ Trashed field $TEST_FIELD` + +### Error cases + +| # | Command | Expected Output | +|---|---------|-----------------| +| 1 | `jtk fields create` | `Error: required flag(s) "name", "type" not set` | +| 2 | `jtk fields delete customfield_99999 --force` | Error: 404 | +| 3 | `jtk fields contexts list customfield_99999` | Error: 404 | +| 4 | `jtk fields options add customfield_99999 --value "Nope"` | Error | + +--- + ## Test Execution Checklist ### Setup - [ ] `make build-jtk` - [ ] `jtk me` works -- [ ] Discover: `$PROJECT`, `$BOARD_ID`, `$SPRINT_ID`, `$ACCOUNT_ID`, `$AUTO_UUID`, `$EXISTING_ISSUE` +- [ ] Discover: `$PROJECT`, `$BOARD_ID`, `$SPRINT_ID`, `$ACCOUNT_ID`, `$AUTO_UUID`, `$EXISTING_ISSUE`, `$CUSTOM_FIELD`, `$SELECT_FIELD` - [ ] `jtk issues types -p $PROJECT` to learn `$ISSUE_TYPE` ### Config & Init (Section 1) @@ -604,14 +746,26 @@ Verify each alias produces the same output as the full command: ### Global Flags & Aliases (Section 11) - [ ] `--no-color`, `--verbose`, `-o json`, `-o plain` -- [ ] All aliases verified +- [ ] All aliases verified (including `jtk f`, `jtk field`) ### Error Cases (Section 12) - [ ] 404, bad JQL, missing flags +### Fields Read-Only (Section 13) +- [ ] `fields list` (all, custom, JSON) +- [ ] `fields contexts list` (table, JSON, 404) +- [ ] `fields options list` (table, JSON) + +### Field Mutations (Section 14) +- [ ] Create field → list contexts → add options → update option → delete option +- [ ] Create context → delete context +- [ ] Trash field → restore → trash again (cleanup) +- [ ] Error cases (missing flags, 404) + ### Cleanup - [ ] Delete test projects: `jtk projects delete ZTEST --force` (etc.) - [ ] Delete test issues: search for `[Test]` prefix, delete with `--force` +- [ ] Trash test fields: `jtk fields delete $TEST_FIELD --force` - [ ] Disable + rename automation test copies to `[DELETEME]` - [ ] Manually purge `[DELETEME]` rules in Jira UI (Settings → System → Automation rules) - [ ] Verify: `jtk auto list -o json | jq '.[] | select(.name | startswith("[Test]") or startswith("[DELETEME]"))'` diff --git a/tools/jtk/internal/cmd/fields/contexts.go b/tools/jtk/internal/cmd/fields/contexts.go new file mode 100644 index 0000000..20729fe --- /dev/null +++ b/tools/jtk/internal/cmd/fields/contexts.go @@ -0,0 +1,185 @@ +package fields + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/atlassian-go/prompt" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +func newContextsCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "contexts", + Aliases: []string{"context", "ctx"}, + Short: "Manage field contexts", + Long: "Commands for listing, creating, and deleting custom field contexts.", + } + + cmd.AddCommand(newContextsListCmd(opts)) + cmd.AddCommand(newContextsCreateCmd(opts)) + cmd.AddCommand(newContextsDeleteCmd(opts)) + + return cmd +} + +func newContextsListCmd(opts *root.Options) *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List contexts for a field", + Example: ` # List contexts for a custom field + jtk fields contexts list customfield_10100`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runContextsList(opts, args[0]) + }, + } +} + +func runContextsList(opts *root.Options, fieldID string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + result, err := client.GetFieldContexts(fieldID) + if err != nil { + return err + } + + if len(result.Values) == 0 { + v.Info("No contexts found for field %s", fieldID) + return nil + } + + if opts.Output == "json" { + return v.JSON(result.Values) + } + + headers := []string{"ID", "NAME", "GLOBAL", "ANY_ISSUE_TYPE"} + var rows [][]string + + for _, ctx := range result.Values { + global := "no" + if ctx.IsGlobalContext { + global = "yes" + } + anyIssueType := "no" + if ctx.IsAnyIssueType { + anyIssueType = "yes" + } + rows = append(rows, []string{ctx.ID, ctx.Name, global, anyIssueType}) + } + + return v.Table(headers, rows) +} + +func newContextsCreateCmd(opts *root.Options) *cobra.Command { + var name, project string + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a field context", + Example: ` # Create a context for a field + jtk fields contexts create customfield_10100 --name "Bug Context" + + # Create a context scoped to a project + jtk fields contexts create customfield_10100 --name "Project Context" --project 10001`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runContextsCreate(opts, args[0], name, project) + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "Context name (required)") + cmd.Flags().StringVarP(&project, "project", "p", "", "Project ID to scope the context to") + + _ = cmd.MarkFlagRequired("name") + + return cmd +} + +func runContextsCreate(opts *root.Options, fieldID, name, project string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + req := &api.CreateFieldContextRequest{ + Name: name, + } + if project != "" { + req.ProjectIDs = []string{project} + } + + ctx, err := client.CreateFieldContext(fieldID, req) + if err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(ctx) + } + + v.Success("Created context %s (%s)", ctx.ID, ctx.Name) + return nil +} + +func newContextsDeleteCmd(opts *root.Options) *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a field context", + Example: ` # Delete a context (will prompt for confirmation) + jtk fields contexts delete customfield_10100 10003 + + # Delete without confirmation + jtk fields contexts delete customfield_10100 10003 --force`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runContextsDelete(opts, args[0], args[1], force) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + + return cmd +} + +func runContextsDelete(opts *root.Options, fieldID, contextID string, force bool) error { + v := opts.View() + + if !force { + fmt.Printf("This will delete context %s from field %s.\n", contextID, fieldID) + fmt.Print("Are you sure? [y/N]: ") + + confirmed, err := prompt.Confirm(opts.Stdin) + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if !confirmed { + v.Info("Deletion cancelled.") + return nil + } + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.DeleteFieldContext(fieldID, contextID); err != nil { + return err + } + + v.Success("Deleted context %s from field %s", contextID, fieldID) + return nil +} diff --git a/tools/jtk/internal/cmd/fields/fields.go b/tools/jtk/internal/cmd/fields/fields.go new file mode 100644 index 0000000..dc3550f --- /dev/null +++ b/tools/jtk/internal/cmd/fields/fields.go @@ -0,0 +1,242 @@ +package fields + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/atlassian-go/prompt" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +// Register registers the fields commands +func Register(parent *cobra.Command, opts *root.Options) { + cmd := &cobra.Command{ + Use: "fields", + Aliases: []string{"field", "f"}, + Short: "Manage Jira custom fields", + Long: "Commands for managing custom field definitions, contexts, and options.", + } + + cmd.AddCommand(newListCmd(opts)) + cmd.AddCommand(newCreateCmd(opts)) + cmd.AddCommand(newDeleteCmd(opts)) + cmd.AddCommand(newRestoreCmd(opts)) + cmd.AddCommand(newContextsCmd(opts)) + cmd.AddCommand(newOptionsCmd(opts)) + + parent.AddCommand(cmd) +} + +func newListCmd(opts *root.Options) *cobra.Command { + var customOnly bool + + cmd := &cobra.Command{ + Use: "list", + Short: "List field definitions", + Long: "List all fields or only custom fields. Shows field ID, name, type, and whether it is custom.", + Example: ` # List all fields + jtk fields list + + # List only custom fields + jtk fields list --custom + + # List fields as JSON + jtk fields list -o json`, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(opts, customOnly) + }, + } + + cmd.Flags().BoolVar(&customOnly, "custom", false, "Show only custom fields") + + return cmd +} + +func runList(opts *root.Options, customOnly bool) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + var fields []api.Field + if customOnly { + fields, err = client.GetCustomFields() + } else { + fields, err = client.GetFields() + } + if err != nil { + return err + } + + if len(fields) == 0 { + v.Info("No fields found") + return nil + } + + if opts.Output == "json" { + return v.JSON(fields) + } + + headers := []string{"ID", "NAME", "TYPE", "CUSTOM"} + var rows [][]string + + for _, f := range fields { + custom := "no" + if f.Custom { + custom = "yes" + } + rows = append(rows, []string{f.ID, f.Name, f.Schema.Type, custom}) + } + + return v.Table(headers, rows) +} + +func newCreateCmd(opts *root.Options) *cobra.Command { + var name, fieldType, description string + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a custom field", + Long: `Create a new custom field in Jira. + +Common field types: + com.atlassian.jira.plugin.system.customfieldtypes:textfield (single-line text) + com.atlassian.jira.plugin.system.customfieldtypes:textarea (multi-line text) + com.atlassian.jira.plugin.system.customfieldtypes:select (single select) + com.atlassian.jira.plugin.system.customfieldtypes:multiselect (multi select) + com.atlassian.jira.plugin.system.customfieldtypes:float (number)`, + Example: ` # Create a single-select field + jtk fields create --name "Environment" --type com.atlassian.jira.plugin.system.customfieldtypes:select + + # Create a text field with description + jtk fields create --name "Release Notes" --type com.atlassian.jira.plugin.system.customfieldtypes:textarea --description "Notes for the release"`, + RunE: func(cmd *cobra.Command, args []string) error { + return runCreate(opts, name, fieldType, description) + }, + } + + cmd.Flags().StringVarP(&name, "name", "n", "", "Field name (required)") + cmd.Flags().StringVarP(&fieldType, "type", "t", "", "Field type (required)") + cmd.Flags().StringVarP(&description, "description", "d", "", "Field description") + + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("type") + + return cmd +} + +func runCreate(opts *root.Options, name, fieldType, description string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + field, err := client.CreateField(&api.CreateFieldRequest{ + Name: name, + Type: fieldType, + Description: description, + }) + if err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(field) + } + + v.Success("Created field %s (%s)", field.ID, field.Name) + return nil +} + +func newDeleteCmd(opts *root.Options) *cobra.Command { + var force bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Trash a custom field", + Long: `Move a custom field to the trash (soft delete). + +The field can be restored using 'jtk fields restore'. +Trashed fields are permanently deleted after 60 days.`, + Example: ` # Trash a field (will prompt for confirmation) + jtk fields delete customfield_10100 + + # Trash without confirmation + jtk fields delete customfield_10100 --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(opts, args[0], force) + }, + } + + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + + return cmd +} + +func runDelete(opts *root.Options, fieldID string, force bool) error { + v := opts.View() + + if !force { + fmt.Printf("This will trash field %s. It can be restored later.\n", fieldID) + fmt.Print("Are you sure? [y/N]: ") + + confirmed, err := prompt.Confirm(opts.Stdin) + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if !confirmed { + v.Info("Deletion cancelled.") + return nil + } + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.TrashField(fieldID); err != nil { + return err + } + + v.Success("Trashed field %s", fieldID) + return nil +} + +func newRestoreCmd(opts *root.Options) *cobra.Command { + return &cobra.Command{ + Use: "restore ", + Short: "Restore a trashed field", + Long: "Restore a custom field from the trash.", + Example: ` # Restore a trashed field + jtk fields restore customfield_10100`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runRestore(opts, args[0]) + }, + } +} + +func runRestore(opts *root.Options, fieldID string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if err := client.RestoreField(fieldID); err != nil { + return err + } + + v.Success("Restored field %s", fieldID) + return nil +} diff --git a/tools/jtk/internal/cmd/fields/fields_test.go b/tools/jtk/internal/cmd/fields/fields_test.go new file mode 100644 index 0000000..b32bb20 --- /dev/null +++ b/tools/jtk/internal/cmd/fields/fields_test.go @@ -0,0 +1,576 @@ +package fields + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +func TestRegister(t *testing.T) { + rootCmd, opts := root.NewCmd() + Register(rootCmd, opts) + + cmd, _, err := rootCmd.Find([]string{"fields"}) + require.NoError(t, err) + assert.Equal(t, "fields", cmd.Name()) + assert.Equal(t, []string{"field", "f"}, cmd.Aliases) +} + +func TestNewListCmd(t *testing.T) { + opts := &root.Options{} + cmd := newListCmd(opts) + + assert.Equal(t, "list", cmd.Use) + assert.NotEmpty(t, cmd.Short) + + customFlag := cmd.Flags().Lookup("custom") + require.NotNil(t, customFlag) + assert.Equal(t, "false", customFlag.DefValue) +} + +func TestRunList_Table(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]api.Field{ + {ID: "summary", Name: "Summary", Schema: api.FieldSchema{Type: "string"}}, + {ID: "customfield_10100", Name: "Environment", Custom: true, Schema: api.FieldSchema{Type: "option"}}, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runList(opts, false) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "summary") + assert.Contains(t, stdout.String(), "customfield_10100") + assert.Contains(t, stdout.String(), "Environment") +} + +func TestRunList_JSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]api.Field{ + {ID: "customfield_10100", Name: "Environment", Custom: true}, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "json", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runList(opts, false) + require.NoError(t, err) + assert.Contains(t, stdout.String(), `"id"`) + assert.Contains(t, stdout.String(), "customfield_10100") +} + +func TestRunList_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]api.Field{}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runList(opts, false) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "No fields found") +} + +func TestNewCreateCmd(t *testing.T) { + opts := &root.Options{} + cmd := newCreateCmd(opts) + + assert.Equal(t, "create", cmd.Use) + + nameFlag := cmd.Flags().Lookup("name") + require.NotNil(t, nameFlag) + + typeFlag := cmd.Flags().Lookup("type") + require.NotNil(t, typeFlag) + + descFlag := cmd.Flags().Lookup("description") + require.NotNil(t, descFlag) +} + +func TestRunCreate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(api.Field{ + ID: "customfield_10100", + Name: "Environment", + Custom: true, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runCreate(opts, "Environment", "com.atlassian.jira.plugin.system.customfieldtypes:select", "") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created field customfield_10100") + assert.Contains(t, stdout.String(), "Environment") +} + +func TestRunCreate_JSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(api.Field{ + ID: "customfield_10100", + Name: "Environment", + Custom: true, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{Output: "json", Stdout: &stdout, Stderr: &bytes.Buffer{}} + opts.SetAPIClient(client) + + err = runCreate(opts, "Environment", "select", "") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "customfield_10100") +} + +func TestNewDeleteCmd(t *testing.T) { + opts := &root.Options{} + cmd := newDeleteCmd(opts) + + assert.Equal(t, "delete ", cmd.Use) + + forceFlag := cmd.Flags().Lookup("force") + require.NotNil(t, forceFlag) + assert.Equal(t, "false", forceFlag.DefValue) +} + +func TestRunDelete_Force(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/trash") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runDelete(opts, "customfield_10100", true) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Trashed field customfield_10100") +} + +func TestRunDelete_NoForce_Declined(t *testing.T) { + client, err := api.New(api.ClientConfig{URL: "https://test.atlassian.net", Email: "test@test.com", APIToken: "token"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{ + Output: "table", + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + Stdin: bytes.NewBufferString("n\n"), + } + opts.SetAPIClient(client) + + err = runDelete(opts, "customfield_10100", false) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Deletion cancelled") +} + +func TestRunDelete_NoForce_Accepted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{ + Output: "table", + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + Stdin: bytes.NewBufferString("y\n"), + } + opts.SetAPIClient(client) + + err = runDelete(opts, "customfield_10100", false) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Trashed field customfield_10100") +} + +func TestRunRestore(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Contains(t, r.URL.Path, "/restore") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runRestore(opts, "customfield_10100") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Restored field customfield_10100") +} + +// --- Contexts tests --- + +func TestNewContextsCmd(t *testing.T) { + rootCmd, opts := root.NewCmd() + Register(rootCmd, opts) + + cmd, _, err := rootCmd.Find([]string{"fields", "contexts"}) + require.NoError(t, err) + assert.Equal(t, "contexts", cmd.Name()) + assert.Equal(t, []string{"context", "ctx"}, cmd.Aliases) +} + +func TestRunContextsList_Table(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(api.FieldContextsResponse{ + Values: []api.FieldContext{ + {ID: "10001", Name: "Default", IsGlobalContext: true, IsAnyIssueType: true}, + {ID: "10002", Name: "Bug Context", IsGlobalContext: false, IsAnyIssueType: false}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runContextsList(opts, "customfield_10100") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Default") + assert.Contains(t, stdout.String(), "Bug Context") +} + +func TestRunContextsList_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(api.FieldContextsResponse{Values: []api.FieldContext{}}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runContextsList(opts, "customfield_10100") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "No contexts found") +} + +func TestRunContextsCreate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(api.FieldContext{ + ID: "10003", + Name: "Bug Context", + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runContextsCreate(opts, "customfield_10100", "Bug Context", "") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Created context 10003") + assert.Contains(t, stdout.String(), "Bug Context") +} + +func TestRunContextsDelete_Force(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runContextsDelete(opts, "customfield_10100", "10003", true) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Deleted context 10003") +} + +func TestRunContextsDelete_NoForce_Declined(t *testing.T) { + client, err := api.New(api.ClientConfig{URL: "https://test.atlassian.net", Email: "test@test.com", APIToken: "token"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{ + Output: "table", + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + Stdin: bytes.NewBufferString("n\n"), + } + opts.SetAPIClient(client) + + err = runContextsDelete(opts, "customfield_10100", "10003", false) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Deletion cancelled") +} + +// --- Options tests --- + +func TestNewOptionsCmd(t *testing.T) { + rootCmd, opts := root.NewCmd() + Register(rootCmd, opts) + + cmd, _, err := rootCmd.Find([]string{"fields", "options"}) + require.NoError(t, err) + assert.Equal(t, "options", cmd.Name()) + assert.Equal(t, []string{"option", "opt"}, cmd.Aliases) +} + +func TestResolveContextID_Explicit(t *testing.T) { + // When context flag is provided, it should be used directly + id, err := resolveContextID(nil, "customfield_10100", "10001") + require.NoError(t, err) + assert.Equal(t, "10001", id) +} + +func TestResolveContextID_AutoDetect(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(api.FieldContextsResponse{ + Values: []api.FieldContext{ + {ID: "10001", Name: "Default"}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.com", APIToken: "token"}) + require.NoError(t, err) + + id, err := resolveContextID(client, "customfield_10100", "") + require.NoError(t, err) + assert.Equal(t, "10001", id) +} + +func TestRunOptionsList_Table(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + // GetFieldContexts (auto-detect) + json.NewEncoder(w).Encode(api.FieldContextsResponse{ + Values: []api.FieldContext{{ID: "10001", Name: "Default"}}, + }) + return + } + // GetFieldContextOptions + json.NewEncoder(w).Encode(api.FieldContextOptionsResponse{ + Values: []api.FieldContextOption{ + {ID: "1", Value: "Production", Disabled: false}, + {ID: "2", Value: "Staging", Disabled: true}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runOptionsList(opts, "customfield_10100", "") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Production") + assert.Contains(t, stdout.String(), "Staging") +} + +func TestRunOptionsList_Empty(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + json.NewEncoder(w).Encode(api.FieldContextsResponse{ + Values: []api.FieldContext{{ID: "10001", Name: "Default"}}, + }) + return + } + json.NewEncoder(w).Encode(api.FieldContextOptionsResponse{Values: []api.FieldContextOption{}}) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runOptionsList(opts, "customfield_10100", "") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "No options found") +} + +func TestRunOptionsAdd(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + json.NewEncoder(w).Encode(api.FieldContextsResponse{ + Values: []api.FieldContext{{ID: "10001", Name: "Default"}}, + }) + return + } + assert.Equal(t, http.MethodPost, r.Method) + json.NewEncoder(w).Encode(api.FieldContextOptionsResponse{ + Values: []api.FieldContextOption{ + {ID: "3", Value: "Option A"}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runOptionsAdd(opts, "customfield_10100", "Option A", "") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Added option 3") + assert.Contains(t, stdout.String(), "Option A") +} + +func TestRunOptionsUpdate(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + json.NewEncoder(w).Encode(api.FieldContextsResponse{ + Values: []api.FieldContext{{ID: "10001", Name: "Default"}}, + }) + return + } + assert.Equal(t, http.MethodPut, r.Method) + json.NewEncoder(w).Encode(api.FieldContextOptionsResponse{ + Values: []api.FieldContextOption{ + {ID: "3", Value: "Option A (updated)"}, + }, + }) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runOptionsUpdate(opts, "customfield_10100", "3", "Option A (updated)", "") + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Updated option 3") +} + +func TestRunOptionsDelete_Force(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 1 { + json.NewEncoder(w).Encode(api.FieldContextsResponse{ + Values: []api.FieldContext{{ID: "10001", Name: "Default"}}, + }) + return + } + assert.Equal(t, http.MethodDelete, r.Method) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + client, err := api.New(api.ClientConfig{URL: server.URL, Email: "test@test.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 = runOptionsDelete(opts, "customfield_10100", "3", "", true) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Deleted option 3") +} + +func TestRunOptionsDelete_NoForce_Declined(t *testing.T) { + client, err := api.New(api.ClientConfig{URL: "https://test.atlassian.net", Email: "test@test.com", APIToken: "token"}) + require.NoError(t, err) + + var stdout bytes.Buffer + opts := &root.Options{ + Output: "table", + Stdout: &stdout, + Stderr: &bytes.Buffer{}, + Stdin: bytes.NewBufferString("n\n"), + } + opts.SetAPIClient(client) + + err = runOptionsDelete(opts, "customfield_10100", "3", "", false) + require.NoError(t, err) + assert.Contains(t, stdout.String(), "Deletion cancelled") +} diff --git a/tools/jtk/internal/cmd/fields/options.go b/tools/jtk/internal/cmd/fields/options.go new file mode 100644 index 0000000..9fb54d0 --- /dev/null +++ b/tools/jtk/internal/cmd/fields/options.go @@ -0,0 +1,284 @@ +package fields + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/atlassian-go/prompt" + + "github.com/open-cli-collective/jira-ticket-cli/api" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" +) + +func newOptionsCmd(opts *root.Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "options", + Aliases: []string{"option", "opt"}, + Short: "Manage field option values", + Long: `Commands for managing option values of select and multiselect custom fields. + +When --context is omitted, the default (first) context is used automatically.`, + } + + cmd.AddCommand(newOptionsListCmd(opts)) + cmd.AddCommand(newOptionsAddCmd(opts)) + cmd.AddCommand(newOptionsUpdateCmd(opts)) + cmd.AddCommand(newOptionsDeleteCmd(opts)) + + return cmd +} + +// resolveContextID returns the provided context ID, or auto-detects the default context. +func resolveContextID(client *api.Client, fieldID, contextFlag string) (string, error) { + if contextFlag != "" { + return contextFlag, nil + } + ctx, err := client.GetDefaultFieldContext(fieldID) + if err != nil { + return "", fmt.Errorf("could not auto-detect context (use --context to specify): %w", err) + } + return ctx.ID, nil +} + +func newOptionsListCmd(opts *root.Options) *cobra.Command { + var contextID string + + cmd := &cobra.Command{ + Use: "list ", + Short: "List options for a field", + Long: "List option values for a select or multiselect custom field. Auto-detects the default context if --context is not specified.", + Example: ` # List options (auto-detects context) + jtk fields options list customfield_10100 + + # List options for a specific context + jtk fields options list customfield_10100 --context 10001`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOptionsList(opts, args[0], contextID) + }, + } + + cmd.Flags().StringVarP(&contextID, "context", "c", "", "Context ID (auto-detected if omitted)") + + return cmd +} + +func runOptionsList(opts *root.Options, fieldID, contextFlag string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + ctxID, err := resolveContextID(client, fieldID, contextFlag) + if err != nil { + return err + } + + result, err := client.GetFieldContextOptions(fieldID, ctxID) + if err != nil { + return err + } + + if len(result.Values) == 0 { + v.Info("No options found for field %s", fieldID) + return nil + } + + if opts.Output == "json" { + return v.JSON(result.Values) + } + + headers := []string{"ID", "VALUE", "DISABLED"} + var rows [][]string + + for _, opt := range result.Values { + disabled := "no" + if opt.Disabled { + disabled = "yes" + } + rows = append(rows, []string{opt.ID, opt.Value, disabled}) + } + + return v.Table(headers, rows) +} + +func newOptionsAddCmd(opts *root.Options) *cobra.Command { + var value, contextID string + + cmd := &cobra.Command{ + Use: "add ", + Short: "Add an option to a field", + Long: "Add a new option value to a select or multiselect custom field. Auto-detects the default context if --context is not specified.", + Example: ` # Add an option (auto-detects context) + jtk fields options add customfield_10100 --value "Production" + + # Add to a specific context + jtk fields options add customfield_10100 --value "Staging" --context 10001`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOptionsAdd(opts, args[0], value, contextID) + }, + } + + cmd.Flags().StringVarP(&value, "value", "V", "", "Option value (required)") + cmd.Flags().StringVarP(&contextID, "context", "c", "", "Context ID (auto-detected if omitted)") + + _ = cmd.MarkFlagRequired("value") + + return cmd +} + +func runOptionsAdd(opts *root.Options, fieldID, value, contextFlag string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + ctxID, err := resolveContextID(client, fieldID, contextFlag) + if err != nil { + return err + } + + options, err := client.CreateFieldContextOptions(fieldID, ctxID, &api.CreateFieldContextOptionsRequest{ + Options: []api.CreateFieldContextOptionEntry{ + {Value: value}, + }, + }) + if err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(options) + } + + if len(options) > 0 { + v.Success("Added option %s (%s)", options[0].ID, options[0].Value) + } else { + v.Success("Added option %s", value) + } + return nil +} + +func newOptionsUpdateCmd(opts *root.Options) *cobra.Command { + var optionID, value, contextID string + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a field option", + Long: "Update an existing option value in a select or multiselect custom field. Auto-detects the default context if --context is not specified.", + Example: ` # Update an option value + jtk fields options update customfield_10100 --option 10001 --value "Production (updated)"`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOptionsUpdate(opts, args[0], optionID, value, contextID) + }, + } + + cmd.Flags().StringVar(&optionID, "option", "", "Option ID to update (required)") + cmd.Flags().StringVarP(&value, "value", "V", "", "New option value (required)") + cmd.Flags().StringVarP(&contextID, "context", "c", "", "Context ID (auto-detected if omitted)") + + _ = cmd.MarkFlagRequired("option") + _ = cmd.MarkFlagRequired("value") + + return cmd +} + +func runOptionsUpdate(opts *root.Options, fieldID, optionID, value, contextFlag string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + ctxID, err := resolveContextID(client, fieldID, contextFlag) + if err != nil { + return err + } + + options, err := client.UpdateFieldContextOptions(fieldID, ctxID, &api.UpdateFieldContextOptionsRequest{ + Options: []api.UpdateFieldContextOptionEntry{ + {ID: optionID, Value: value}, + }, + }) + if err != nil { + return err + } + + if opts.Output == "json" { + return v.JSON(options) + } + + v.Success("Updated option %s", optionID) + return nil +} + +func newOptionsDeleteCmd(opts *root.Options) *cobra.Command { + var optionID, contextID string + var force bool + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a field option", + Long: "Delete an option value from a select or multiselect custom field. Auto-detects the default context if --context is not specified.", + Example: ` # Delete an option (will prompt for confirmation) + jtk fields options delete customfield_10100 --option 10001 + + # Delete without confirmation + jtk fields options delete customfield_10100 --option 10001 --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runOptionsDelete(opts, args[0], optionID, contextID, force) + }, + } + + cmd.Flags().StringVar(&optionID, "option", "", "Option ID to delete (required)") + cmd.Flags().StringVarP(&contextID, "context", "c", "", "Context ID (auto-detected if omitted)") + cmd.Flags().BoolVar(&force, "force", false, "Skip confirmation prompt") + + _ = cmd.MarkFlagRequired("option") + + return cmd +} + +func runOptionsDelete(opts *root.Options, fieldID, optionID, contextFlag string, force bool) error { + v := opts.View() + + if !force { + fmt.Printf("This will delete option %s from field %s.\n", optionID, fieldID) + fmt.Print("Are you sure? [y/N]: ") + + confirmed, err := prompt.Confirm(opts.Stdin) + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if !confirmed { + v.Info("Deletion cancelled.") + return nil + } + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + ctxID, err := resolveContextID(client, fieldID, contextFlag) + if err != nil { + return err + } + + if err := client.DeleteFieldContextOption(fieldID, ctxID, optionID); err != nil { + return err + } + + v.Success("Deleted option %s from field %s", optionID, fieldID) + return nil +}