diff --git a/AGENTS.md b/AGENTS.md index 2a81852..b32129c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,8 +44,8 @@ Aliases also work with `--hostname` flags: `atl auth status --hostname prod` ### View Issues ```bash -atl issue view PROJ-1234 # View issue details -atl issue view PROJ-1234 --json # View as JSON (for parsing) +atl issue view PROJ-1234 # View issue details (includes custom fields) +atl issue view PROJ-1234 --json # View as JSON (includes custom_fields section) atl issue view PROJ-1234 --web # Open in browser ``` @@ -87,6 +87,7 @@ atl issue edit PROJ-1234 --field "Custom Field=Some **markdown** text" # Auto-c ```bash atl issue transition PROJ-1234 "In Progress" atl issue transition PROJ-1234 --list # List available transitions +atl issue transition PROJ-1234 "Done" --field "Resolution=Fixed" # With required fields ``` ### Comments @@ -132,6 +133,8 @@ atl issue types --project PROJ # List issue types atl issue priorities # List available priorities atl issue fields # List all fields atl issue fields --search "story points" # Search for field by name +atl issue field-options --project PROJ --type Bug # Show allowed values for fields +atl issue field-options --project PROJ --type Bug --field "Priority" # Specific field ``` ## Jira Boards diff --git a/internal/api/jira.go b/internal/api/jira.go index 45be623..6c8e336 100644 --- a/internal/api/jira.go +++ b/internal/api/jira.go @@ -13,7 +13,8 @@ import ( // JiraService handles Jira API operations. type JiraService struct { - client *Client + client *Client + fieldsCache []*Field } // NewJiraService creates a new Jira service. @@ -47,6 +48,103 @@ type IssueFields struct { Comment *Comments `json:"comment,omitempty"` Parent *Issue `json:"parent,omitempty"` Attachment []*Attachment `json:"attachment,omitempty"` + + // Extra holds custom field values not captured by the typed fields above. + // Keys are field IDs like "customfield_10413", values are raw JSON. + Extra map[string]json.RawMessage `json:"-"` +} + +// UnmarshalJSON implements custom unmarshaling to capture both standard +// and custom fields. Standard fields are decoded into typed struct fields, +// while custom fields (customfield_*) are preserved as raw JSON in Extra. +func (f *IssueFields) UnmarshalJSON(data []byte) error { + type Alias IssueFields + var alias Alias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + *f = IssueFields(alias) + + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + f.Extra = make(map[string]json.RawMessage) + for key, val := range raw { + if strings.HasPrefix(key, "customfield_") { + f.Extra[key] = val + } + } + + return nil +} + +// FormatCustomFieldValue extracts a human-readable string from a raw JSON +// custom field value. Handles common Jira field value shapes: +// select/radio, user, ADF, arrays, strings, numbers, null. +func FormatCustomFieldValue(raw json.RawMessage) string { + if len(raw) == 0 || string(raw) == "null" { + return "" + } + + // Try as object with "value" key (select/radio fields). + var selectField struct { + Value string `json:"value"` + } + if err := json.Unmarshal(raw, &selectField); err == nil && selectField.Value != "" { + return selectField.Value + } + + // Try as object with "displayName" key (user fields). + var userField struct { + DisplayName string `json:"displayName"` + } + if err := json.Unmarshal(raw, &userField); err == nil && userField.DisplayName != "" { + return userField.DisplayName + } + + // Try as ADF document. + var adfField struct { + Type string `json:"type"` + Version int `json:"version"` + } + if err := json.Unmarshal(raw, &adfField); err == nil && adfField.Type == "doc" { + var adfDoc ADF + if err := json.Unmarshal(raw, &adfDoc); err == nil { + return ADFToText(&adfDoc) + } + } + + // Try as array. + var arr []json.RawMessage + if err := json.Unmarshal(raw, &arr); err == nil { + var values []string + for _, item := range arr { + formatted := FormatCustomFieldValue(item) + if formatted != "" { + values = append(values, formatted) + } + } + return strings.Join(values, ", ") + } + + // Try as string. + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return s + } + + // Try as number. + var n float64 + if err := json.Unmarshal(raw, &n); err == nil { + if n == float64(int64(n)) { + return fmt.Sprintf("%d", int64(n)) + } + return fmt.Sprintf("%g", n) + } + + return string(raw) } // Attachment represents an attachment on an issue. @@ -434,6 +532,52 @@ func (s *JiraService) GetSubtaskType(ctx context.Context, projectKey string) (*P return nil, nil } +// FieldMeta represents metadata for a field from the createmeta endpoint. +type FieldMeta struct { + Required bool `json:"required"` + Schema *FieldSchema `json:"schema,omitempty"` + Name string `json:"name"` + Key string `json:"key"` + FieldID string `json:"fieldId"` + AllowedValues []json.RawMessage `json:"allowedValues,omitempty"` +} + +// FieldMetaResponse is the paginated response from the createmeta field endpoint. +type FieldMetaResponse struct { + MaxResults int `json:"maxResults"` + StartAt int `json:"startAt"` + Total int `json:"total"` + Values []*FieldMeta `json:"values"` +} + +// GetFieldOptions gets field metadata (including allowed values) for a project/issue type. +// Uses the createmeta endpoint: /issue/createmeta/{projectKey}/issuetypes/{issueTypeId} +func (s *JiraService) GetFieldOptions(ctx context.Context, projectKey, issueTypeID string) ([]*FieldMeta, error) { + path := fmt.Sprintf("%s/issue/createmeta/%s/issuetypes/%s", + s.client.JiraBaseURL(), url.PathEscape(projectKey), url.PathEscape(issueTypeID)) + + params := url.Values{} + params.Set("maxResults", "100") + + var allFields []*FieldMeta + startAt := 0 + + for { + params.Set("startAt", strconv.Itoa(startAt)) + var result FieldMetaResponse + if err := s.client.Get(ctx, path+"?"+params.Encode(), &result); err != nil { + return nil, err + } + allFields = append(allFields, result.Values...) + if startAt+result.MaxResults >= result.Total { + break + } + startAt += result.MaxResults + } + + return allFields, nil +} + // GetPriorities gets all available priorities in the Jira instance. func (s *JiraService) GetPriorities(ctx context.Context) ([]*Priority, error) { path := fmt.Sprintf("%s/priority", s.client.JiraBaseURL()) @@ -479,7 +623,8 @@ func (s *JiraService) GetTransitions(ctx context.Context, key string) ([]*Transi // TransitionRequest represents a request to transition an issue. type TransitionRequest struct { - Transition TransitionID `json:"transition"` + Transition TransitionID `json:"transition"` + Fields map[string]interface{} `json:"fields,omitempty"` } // TransitionID identifies a transition. @@ -488,10 +633,12 @@ type TransitionID struct { } // TransitionIssue transitions an issue to a new status. -func (s *JiraService) TransitionIssue(ctx context.Context, key string, transitionID string) error { +// Optional fields can be set during the transition. +func (s *JiraService) TransitionIssue(ctx context.Context, key string, transitionID string, fields map[string]interface{}) error { path := fmt.Sprintf("%s/issue/%s/transitions", s.client.JiraBaseURL(), key) req := &TransitionRequest{ Transition: TransitionID{ID: transitionID}, + Fields: fields, } return s.client.Post(ctx, path, req, nil) } @@ -799,6 +946,10 @@ type FieldSchema struct { // GetFields gets all field definitions. func (s *JiraService) GetFields(ctx context.Context) ([]*Field, error) { + if s.fieldsCache != nil { + return s.fieldsCache, nil + } + path := fmt.Sprintf("%s/field", s.client.JiraBaseURL()) var fields []*Field @@ -806,6 +957,7 @@ func (s *JiraService) GetFields(ctx context.Context) ([]*Field, error) { return nil, err } + s.fieldsCache = fields return fields, nil } diff --git a/internal/cmd/issue/create.go b/internal/cmd/issue/create.go index 4bd47fd..756ce77 100644 --- a/internal/cmd/issue/create.go +++ b/internal/cmd/issue/create.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "strconv" "strings" "github.com/spf13/cobra" @@ -218,30 +217,11 @@ func runCreate(opts *CreateOptions) error { req.Fields.CustomFields = make(map[string]interface{}) } for _, field := range opts.CustomFields { - parts := strings.SplitN(field, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid field format: %s (expected key=value)", field) - } - key, value := parts[0], parts[1] - - // If key doesn't look like a field ID, try to resolve it by name - if !strings.HasPrefix(key, "customfield_") && !isSystemField(key) { - resolvedField, err := jira.GetFieldByName(ctx, key) - if err != nil { - return fmt.Errorf("failed to look up field '%s': %w", key, err) - } - if resolvedField == nil { - return fmt.Errorf("field not found: %s\n\nUse 'atl issue fields --search \"%s\"' to find available fields", key, key) - } - key = resolvedField.ID - } - - // Try to parse value as number, otherwise use string - if numVal, err := strconv.ParseFloat(value, 64); err == nil { - req.Fields.CustomFields[key] = numVal - } else { - req.Fields.CustomFields[key] = value + key, fieldValue, err := ParseCustomField(ctx, jira, field) + if err != nil { + return err } + req.Fields.CustomFields[key] = fieldValue } } @@ -274,16 +254,3 @@ func runCreate(opts *CreateOptions) error { return nil } - -// isSystemField checks if a field name is a known Jira system field. -func isSystemField(name string) bool { - systemFields := map[string]bool{ - "summary": true, "description": true, "issuetype": true, - "project": true, "reporter": true, "assignee": true, - "priority": true, "labels": true, "components": true, - "fixVersions": true, "versions": true, "duedate": true, - "environment": true, "resolution": true, "status": true, - "created": true, "updated": true, "parent": true, - } - return systemFields[strings.ToLower(name)] -} diff --git a/internal/cmd/issue/edit.go b/internal/cmd/issue/edit.go index ef5112b..8248799 100644 --- a/internal/cmd/issue/edit.go +++ b/internal/cmd/issue/edit.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "os" - "strconv" "strings" "github.com/spf13/cobra" @@ -222,47 +221,9 @@ func runEdit(opts *EditOptions) error { // Parse and add custom fields from command line (override file values) for _, field := range opts.CustomFields { - parts := strings.SplitN(field, "=", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid field format: %s (expected key=value)", field) - } - key, value := parts[0], parts[1] - - var resolvedField *api.Field - var err error - - if strings.HasPrefix(key, "customfield_") { - // Look up field by ID to get type information - resolvedField, err = jira.GetFieldByID(ctx, key) - if err != nil { - return fmt.Errorf("failed to look up field '%s': %w", key, err) - } - // Note: resolvedField may be nil if field doesn't exist, we'll still try to set it - } else if !isSystemField(key) { - // Resolve field by name - resolvedField, err = jira.GetFieldByName(ctx, key) - if err != nil { - return fmt.Errorf("failed to look up field '%s': %w", key, err) - } - if resolvedField == nil { - return fmt.Errorf("field not found: %s\n\nUse 'atl issue fields --search \"%s\"' to find available fields", key, key) - } - key = resolvedField.ID - } - - // Determine field value based on field type - var fieldValue interface{} - - // Check if this is a textarea field that requires ADF format - if resolvedField != nil && resolvedField.Schema != nil && - strings.Contains(resolvedField.Schema.Custom, "textarea") { - // Convert Markdown to ADF for textarea fields - fieldValue = api.TextToADF(value) - } else if numVal, err := strconv.ParseFloat(value, 64); err == nil { - // Try to parse value as number - fieldValue = numVal - } else { - fieldValue = value + key, fieldValue, err := ParseCustomField(ctx, jira, field) + if err != nil { + return err } req.Fields[key] = fieldValue editOutput.FieldsUpdated = append(editOutput.FieldsUpdated, key) diff --git a/internal/cmd/issue/field_options.go b/internal/cmd/issue/field_options.go new file mode 100644 index 0000000..99d9fef --- /dev/null +++ b/internal/cmd/issue/field_options.go @@ -0,0 +1,212 @@ +package issue + +import ( + "context" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/enthus-appdev/atl-cli/internal/api" + "github.com/enthus-appdev/atl-cli/internal/iostreams" + "github.com/enthus-appdev/atl-cli/internal/output" +) + +// FieldOptionsOptions holds the options for the field-options command. +type FieldOptionsOptions struct { + IO *iostreams.IOStreams + Project string + IssueType string + Field string + JSON bool +} + +// NewCmdFieldOptions creates the field-options command. +func NewCmdFieldOptions(ios *iostreams.IOStreams) *cobra.Command { + opts := &FieldOptionsOptions{ + IO: ios, + } + + cmd := &cobra.Command{ + Use: "field-options", + Short: "Show allowed values for issue fields", + Long: `Display field metadata and allowed values for a project and issue type. Useful for discovering valid values for select, radio, and other constrained fields.`, + Example: ` # Show all fields with allowed values for bugs + atl issue field-options --project NX --type Bug + + # Show options for a specific field + atl issue field-options --project NX --type Bug --field "Fehlverhalten" + + # Output as JSON + atl issue field-options --project NX --type Bug --json`, + RunE: func(cmd *cobra.Command, args []string) error { + if opts.Project == "" { + return fmt.Errorf("--project flag is required\n\nUse 'atl issue types --project PROJ' to list available projects") + } + if opts.IssueType == "" { + return fmt.Errorf("--type flag is required\n\nUse 'atl issue types --project %s' to list available issue types", opts.Project) + } + return runFieldOptions(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Project, "project", "p", "", "Project key (required)") + cmd.Flags().StringVarP(&opts.IssueType, "type", "t", "", "Issue type name (required)") + cmd.Flags().StringVarP(&opts.Field, "field", "f", "", "Filter to a specific field by name") + cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") + + return cmd +} + +// FieldOptionOutput represents a field with its allowed values. +type FieldOptionOutput struct { + FieldID string `json:"field_id"` + Name string `json:"name"` + Required bool `json:"required"` + Type string `json:"type,omitempty"` + CustomType string `json:"custom_type,omitempty"` + AllowedValues []string `json:"allowed_values"` +} + +func runFieldOptions(opts *FieldOptionsOptions) error { + client, err := api.NewClientFromConfig() + if err != nil { + return err + } + + ctx := context.Background() + jira := api.NewJiraService(client) + + // Resolve issue type name to ID + issueTypes, err := jira.GetProjectIssueTypes(ctx, opts.Project) + if err != nil { + return fmt.Errorf("failed to get issue types: %w", err) + } + + var issueTypeID string + typeLower := strings.ToLower(opts.IssueType) + for _, it := range issueTypes { + if strings.ToLower(it.Name) == typeLower { + issueTypeID = it.ID + break + } + } + if issueTypeID == "" { + var available []string + for _, it := range issueTypes { + available = append(available, it.Name) + } + return fmt.Errorf("issue type %q not found in project %s\n\nAvailable types: %s", opts.IssueType, opts.Project, strings.Join(available, ", ")) + } + + // Get field metadata + fieldMetas, err := jira.GetFieldOptions(ctx, opts.Project, issueTypeID) + if err != nil { + return fmt.Errorf("failed to get field options: %w", err) + } + + // Filter and format + var results []*FieldOptionOutput + fieldLower := strings.ToLower(opts.Field) + + for _, fm := range fieldMetas { + // Skip fields without allowed values (unless filtering by name) + if len(fm.AllowedValues) == 0 && opts.Field == "" { + continue + } + + // Filter by field name if specified + if opts.Field != "" && !strings.Contains(strings.ToLower(fm.Name), fieldLower) { + continue + } + + result := &FieldOptionOutput{ + FieldID: fm.FieldID, + Name: fm.Name, + Required: fm.Required, + } + + if fm.Schema != nil { + result.Type = fm.Schema.Type + result.CustomType = fm.Schema.Custom + } + + // Extract allowed values + for _, rawVal := range fm.AllowedValues { + val := extractAllowedValue(rawVal) + if val != "" { + result.AllowedValues = append(result.AllowedValues, val) + } + } + + results = append(results, result) + } + + // Sort by name + sort.Slice(results, func(i, j int) bool { + return results[i].Name < results[j].Name + }) + + if opts.JSON { + return output.JSON(opts.IO.Out, results) + } + + if len(results) == 0 { + if opts.Field != "" { + fmt.Fprintf(opts.IO.Out, "No fields matching %q found with allowed values\n", opts.Field) + } else { + fmt.Fprintf(opts.IO.Out, "No fields with allowed values found for %s %s\n", opts.Project, opts.IssueType) + } + return nil + } + + for i, r := range results { + if i > 0 { + fmt.Fprintln(opts.IO.Out) + } + required := "" + if r.Required { + required = " (required)" + } + fmt.Fprintf(opts.IO.Out, "%s [%s]%s\n", r.Name, r.FieldID, required) + if r.CustomType != "" { + fmt.Fprintf(opts.IO.Out, " Type: %s\n", r.CustomType) + } else if r.Type != "" { + fmt.Fprintf(opts.IO.Out, " Type: %s\n", r.Type) + } + if len(r.AllowedValues) > 0 { + fmt.Fprintf(opts.IO.Out, " Values: %s\n", strings.Join(r.AllowedValues, ", ")) + } + } + + return nil +} + +// extractAllowedValue extracts a display value from a raw allowed value JSON. +func extractAllowedValue(raw json.RawMessage) string { + // Try {value: "..."} pattern (select/radio) + var selectVal struct { + Value string `json:"value"` + } + if err := json.Unmarshal(raw, &selectVal); err == nil && selectVal.Value != "" { + return selectVal.Value + } + + // Try {name: "..."} pattern (priority, status, etc.) + var nameVal struct { + Name string `json:"name"` + } + if err := json.Unmarshal(raw, &nameVal); err == nil && nameVal.Name != "" { + return nameVal.Name + } + + // Try plain string + var strVal string + if err := json.Unmarshal(raw, &strVal); err == nil { + return strVal + } + + return "" +} diff --git a/internal/cmd/issue/field_util.go b/internal/cmd/issue/field_util.go new file mode 100644 index 0000000..e3ba50c --- /dev/null +++ b/internal/cmd/issue/field_util.go @@ -0,0 +1,88 @@ +package issue + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/enthus-appdev/atl-cli/internal/api" +) + +// isSystemField checks if a field name is a known Jira system field. +func isSystemField(name string) bool { + systemFields := map[string]bool{ + "summary": true, "description": true, "issuetype": true, + "project": true, "reporter": true, "assignee": true, + "priority": true, "labels": true, "components": true, + "fixversions": true, "versions": true, "duedate": true, + "environment": true, "resolution": true, "status": true, + "created": true, "updated": true, "parent": true, + } + return systemFields[strings.ToLower(name)] +} + +// ParseCustomField resolves a key=value pair into a field ID and properly +// typed value for the Jira API. Handles name-to-ID resolution and +// type-aware value coercion (select -> {value:...}, textarea -> ADF, number). +func ParseCustomField(ctx context.Context, jira *api.JiraService, raw string) (string, interface{}, error) { + parts := strings.SplitN(raw, "=", 2) + if len(parts) != 2 { + return "", nil, fmt.Errorf("invalid field format: %s (expected key=value)", raw) + } + key, value := parts[0], parts[1] + + var resolvedField *api.Field + + if strings.HasPrefix(key, "customfield_") { + resolvedField, _ = jira.GetFieldByID(ctx, key) + } else if !isSystemField(key) { + var err error + resolvedField, err = jira.GetFieldByName(ctx, key) + if err != nil { + return "", nil, fmt.Errorf("failed to look up field '%s': %w", key, err) + } + if resolvedField == nil { + return "", nil, fmt.Errorf("field not found: %s\n\nUse 'atl issue fields --search \"%s\"' to find available fields", key, key) + } + key = resolvedField.ID + } + + fieldValue := coerceFieldValue(resolvedField, value) + return key, fieldValue, nil +} + +// coerceFieldValue converts a string value to the appropriate type +// based on the field's schema. +func coerceFieldValue(field *api.Field, value string) interface{} { + if field != nil && field.Schema != nil { + customType := field.Schema.Custom + if strings.Contains(customType, "select") || strings.Contains(customType, "radiobuttons") { + return map[string]string{"value": value} + } + if strings.Contains(customType, "multiselect") || strings.Contains(customType, "multicheckboxes") { + vals := strings.Split(value, ",") + options := make([]map[string]string, len(vals)) + for i, v := range vals { + options[i] = map[string]string{"value": strings.TrimSpace(v)} + } + return options + } + if strings.Contains(customType, "textarea") { + return api.TextToADF(value) + } + if field.Schema.Type == "array" && field.Schema.Custom == "" { + // Labels-type array of strings. + vals := strings.Split(value, ",") + for i := range vals { + vals[i] = strings.TrimSpace(vals[i]) + } + return vals + } + } + + if numVal, err := strconv.ParseFloat(value, 64); err == nil { + return numVal + } + return value +} diff --git a/internal/cmd/issue/issue.go b/internal/cmd/issue/issue.go index 32d7f1a..c5088b3 100644 --- a/internal/cmd/issue/issue.go +++ b/internal/cmd/issue/issue.go @@ -25,6 +25,7 @@ func NewCmdIssue(ios *iostreams.IOStreams) *cobra.Command { cmd.AddCommand(NewCmdAssign(ios)) cmd.AddCommand(NewCmdLink(ios)) cmd.AddCommand(NewCmdFields(ios)) + cmd.AddCommand(NewCmdFieldOptions(ios)) cmd.AddCommand(NewCmdSprint(ios)) cmd.AddCommand(NewCmdFlag(ios)) cmd.AddCommand(NewCmdWebLink(ios)) diff --git a/internal/cmd/issue/transition.go b/internal/cmd/issue/transition.go index 8504568..f4db749 100644 --- a/internal/cmd/issue/transition.go +++ b/internal/cmd/issue/transition.go @@ -14,12 +14,13 @@ import ( // TransitionOptions holds the options for the transition command. type TransitionOptions struct { - IO *iostreams.IOStreams - IssueKey string - Status string - Comment string - List bool - JSON bool + IO *iostreams.IOStreams + IssueKey string + Status string + Comment string + CustomFields []string + List bool + JSON bool } // NewCmdTransition creates the transition command. @@ -42,6 +43,9 @@ func NewCmdTransition(ios *iostreams.IOStreams) *cobra.Command { # Move issue to Done with a comment atl issue transition PROJ-1234 Done --comment "Completed the implementation" + # Transition with required fields + atl issue transition PROJ-1234 "Done" --field "Resolution=Fixed" + # Output result as JSON atl issue transition PROJ-1234 Done --json`, Args: cobra.RangeArgs(1, 2), @@ -55,6 +59,7 @@ func NewCmdTransition(ios *iostreams.IOStreams) *cobra.Command { } cmd.Flags().StringVarP(&opts.Comment, "comment", "c", "", "Add a comment with the transition") + cmd.Flags().StringSliceVarP(&opts.CustomFields, "field", "f", nil, "Custom field in key=value format (for transitions that require fields)") cmd.Flags().BoolVarP(&opts.List, "list", "l", false, "List available transitions") cmd.Flags().BoolVarP(&opts.JSON, "json", "j", false, "Output as JSON") @@ -166,8 +171,21 @@ func runTransition(opts *TransitionOptions) error { fromStatus = issue.Fields.Status.Name } + // Parse custom fields if provided + var fields map[string]interface{} + if len(opts.CustomFields) > 0 { + fields = make(map[string]interface{}) + for _, field := range opts.CustomFields { + key, fieldValue, err := ParseCustomField(ctx, jira, field) + if err != nil { + return err + } + fields[key] = fieldValue + } + } + // Perform transition - if err := jira.TransitionIssue(ctx, opts.IssueKey, matchedTransition.ID); err != nil { + if err := jira.TransitionIssue(ctx, opts.IssueKey, matchedTransition.ID, fields); err != nil { return fmt.Errorf("failed to transition issue: %w", err) } diff --git a/internal/cmd/issue/view.go b/internal/cmd/issue/view.go index d9c65fd..86df5ed 100644 --- a/internal/cmd/issue/view.go +++ b/internal/cmd/issue/view.go @@ -2,7 +2,9 @@ package issue import ( "context" + "encoding/json" "fmt" + "sort" "strings" "time" @@ -55,21 +57,29 @@ func NewCmdView(ios *iostreams.IOStreams) *cobra.Command { // IssueOutput represents the output format for an issue (LLM-friendly). type IssueOutput struct { - Key string `json:"key"` - ID string `json:"id"` - Summary string `json:"summary"` - Description string `json:"description,omitempty"` - Status string `json:"status"` - StatusCategory string `json:"status_category,omitempty"` - Priority string `json:"priority,omitempty"` - Type string `json:"type"` - Assignee *UserOutput `json:"assignee,omitempty"` - Reporter *UserOutput `json:"reporter,omitempty"` - Project *ProjectOutput `json:"project"` - Labels []string `json:"labels,omitempty"` - Created string `json:"created"` - Updated string `json:"updated"` - URL string `json:"url"` + Key string `json:"key"` + ID string `json:"id"` + Summary string `json:"summary"` + Description string `json:"description,omitempty"` + Status string `json:"status"` + StatusCategory string `json:"status_category,omitempty"` + Priority string `json:"priority,omitempty"` + Type string `json:"type"` + Assignee *UserOutput `json:"assignee,omitempty"` + Reporter *UserOutput `json:"reporter,omitempty"` + Project *ProjectOutput `json:"project"` + Labels []string `json:"labels,omitempty"` + Created string `json:"created"` + Updated string `json:"updated"` + URL string `json:"url"` + CustomFields map[string]*CustomFieldOutput `json:"custom_fields,omitempty"` +} + +// CustomFieldOutput represents a custom field in the output. +type CustomFieldOutput struct { + ID string `json:"id"` + Value string `json:"value"` + Raw json.RawMessage `json:"raw,omitempty"` } // UserOutput represents user information. @@ -104,7 +114,18 @@ func runView(opts *ViewOptions) error { return fmt.Errorf("failed to get issue: %w", err) } - issueOutput := formatIssueOutput(issue, client.Hostname()) + // Resolve field ID -> name mapping for custom fields. + fieldNames := make(map[string]string) + if len(issue.Fields.Extra) > 0 { + fields, err := jira.GetFields(ctx) + if err == nil { + for _, f := range fields { + fieldNames[f.ID] = f.Name + } + } + } + + issueOutput := formatIssueOutput(issue, client.Hostname(), fieldNames) if opts.JSON { return output.JSON(opts.IO.Out, issueOutput) @@ -116,7 +137,7 @@ func runView(opts *ViewOptions) error { return nil } -func formatIssueOutput(issue *api.Issue, hostname string) *IssueOutput { +func formatIssueOutput(issue *api.Issue, hostname string, fieldNames map[string]string) *IssueOutput { out := &IssueOutput{ Key: issue.Key, ID: issue.ID, @@ -170,6 +191,26 @@ func formatIssueOutput(issue *api.Issue, hostname string) *IssueOutput { out.Created = formatTime(issue.Fields.Created) out.Updated = formatTime(issue.Fields.Updated) + // Add custom fields. + if len(issue.Fields.Extra) > 0 { + out.CustomFields = make(map[string]*CustomFieldOutput, len(issue.Fields.Extra)) + for id, raw := range issue.Fields.Extra { + value := api.FormatCustomFieldValue(raw) + if value == "" { + continue + } + name := id + if n, ok := fieldNames[id]; ok { + name = n + } + out.CustomFields[name] = &CustomFieldOutput{ + ID: id, + Value: value, + Raw: raw, + } + } + } + return out } @@ -204,6 +245,24 @@ func printIssueDetails(ios *iostreams.IOStreams, issue *IssueOutput) { fmt.Fprintf(ios.Out, "Updated: %s\n", issue.Updated) fmt.Fprintf(ios.Out, "URL: %s\n", issue.URL) + if len(issue.CustomFields) > 0 { + fmt.Fprintln(ios.Out, "") + fmt.Fprintln(ios.Out, "## Custom Fields") + fmt.Fprintln(ios.Out, "") + + // Sort keys for deterministic output. + names := make([]string, 0, len(issue.CustomFields)) + for name := range issue.CustomFields { + names = append(names, name) + } + sort.Strings(names) + + for _, name := range names { + cf := issue.CustomFields[name] + fmt.Fprintf(ios.Out, "%s: %s\n", name, cf.Value) + } + } + if issue.Description != "" { fmt.Fprintln(ios.Out, "") fmt.Fprintln(ios.Out, "## Description") diff --git a/internal/cmd/issue/view_test.go b/internal/cmd/issue/view_test.go index dfbd46b..dc324ee 100644 --- a/internal/cmd/issue/view_test.go +++ b/internal/cmd/issue/view_test.go @@ -91,7 +91,7 @@ func TestFormatIssueOutput(t *testing.T) { } hostname := "example.atlassian.net" - output := formatIssueOutput(issue, hostname) + output := formatIssueOutput(issue, hostname, nil) // Verify basic fields if output.Key != "TEST-123" { @@ -165,7 +165,7 @@ func TestFormatIssueOutputMinimal(t *testing.T) { }, } - output := formatIssueOutput(issue, "example.atlassian.net") + output := formatIssueOutput(issue, "example.atlassian.net", nil) if output.Key != "TEST-1" { t.Errorf("Key = %q, want %q", output.Key, "TEST-1")