From f4fd40777f5d10da10f8e6b395bbccd18468ad69 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 09:37:32 +0200 Subject: [PATCH 1/2] fix: custom fields for issue edit --- internal/jira/client.go | 10 ++ internal/jira/client_test.go | 37 +++++++ pkg/cmd/audit/audit.go | 208 +++++++++++++++++++++++++++++++++++ pkg/cmd/audit/audit_test.go | 78 +++++++++++++ pkg/cmd/issue/edit.go | 23 +++- pkg/cmd/issue/edit_test.go | 42 +++++++ pkg/cmd/me/me.go | 3 + pkg/cmd/mine/mine.go | 3 + 8 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 pkg/cmd/audit/audit.go create mode 100644 pkg/cmd/audit/audit_test.go create mode 100644 pkg/cmd/issue/edit_test.go diff --git a/internal/jira/client.go b/internal/jira/client.go index 1a38487..84c9098 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -291,6 +291,16 @@ func (c *Client) Search(jql string, startAt, maxResults int) (map[string]any, er return c.getJSON(path) } +// SearchWithChangelog runs a JQL search and asks Jira to expand each issue's +// changelog, so callers can inspect change history. Used by `me audit` to +// reconstruct a user's activity from issue history. +func (c *Client) SearchWithChangelog(jql string, maxResults int) (map[string]any, error) { + path := fmt.Sprintf("/rest/api/3/search/jql?jql=%s&maxResults=%d&fields=%s&expand=changelog", + urlEncode(jql), maxResults, + "key,summary,status,issuetype,created,updated") + return c.getJSON(path) +} + // --- Boards --- // ListBoards lists boards, optionally filtered by name and project. diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go index 4e766cc..52080ed 100644 --- a/internal/jira/client_test.go +++ b/internal/jira/client_test.go @@ -124,6 +124,43 @@ func TestSearch(t *testing.T) { assert.Len(t, issues, 1) } +func TestSearchWithChangelog(t *testing.T) { + server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/search/jql", r.URL.Path) + assert.Equal(t, "GET", r.Method) + assert.Equal(t, `issuekey IN updatedBy("abc123")`, r.URL.Query().Get("jql")) + assert.Equal(t, "changelog", r.URL.Query().Get("expand")) + assert.Equal(t, "50", r.URL.Query().Get("maxResults")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "issues": []any{ + map[string]any{ + "key": "TEST-1", + "changelog": map[string]any{ + "histories": []any{ + map[string]any{ + "created": "2026-06-08T10:00:00.000+0000", + "author": map[string]any{"accountId": "abc123"}, + }, + }, + }, + }, + }, + }) + }) + defer server.Close() + + data, err := client.SearchWithChangelog(`issuekey IN updatedBy("abc123")`, 50) + require.NoError(t, err) + + issues := data["issues"].([]any) + require.Len(t, issues, 1) + iss := issues[0].(map[string]any) + cl := iss["changelog"].(map[string]any) + assert.Len(t, cl["histories"], 1) +} + func TestCreateIssue(t *testing.T) { server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/rest/api/3/issue", r.URL.Path) diff --git a/pkg/cmd/audit/audit.go b/pkg/cmd/audit/audit.go new file mode 100644 index 0000000..58cda81 --- /dev/null +++ b/pkg/cmd/audit/audit.go @@ -0,0 +1,208 @@ +// Package audit implements the `me audit` / `mine audit` command, which lists +// the current user's Jira activity for a given day by reconstructing it from +// issue changelogs (Search API with expand=changelog). +package audit + +import ( + "fmt" + "strings" + "time" + + "AndersSpringborg/jira-cli/internal/cmdutil" + "AndersSpringborg/jira-cli/internal/output" + + "github.com/spf13/cobra" +) + +// jiraTimeLayout is the timestamp format Jira uses in changelog entries. +const jiraTimeLayout = "2006-01-02T15:04:05.000-0700" + +// NewCmd creates the `audit` subcommand. It is mounted under both `me` and +// `mine` so `jira me audit` and `jira mine audit` are equivalent. +func NewCmd(f *cmdutil.Factory) *cobra.Command { + var ( + date string + maxResults int + columns string + raw bool + ) + + cmd := &cobra.Command{ + Use: "audit", + Short: "Show your Jira activity for a given day", + Long: `List your own changes to Jira issues on a given day. + +Reconstructs your activity from issue changelogs: finds issues you updated in +the day window, then reports each field/status change you authored. + +Examples: + jira me audit + jira me audit --date 2026-06-07 + jira mine audit --date 2026-06-07 --format markdown`, + RunE: func(cmd *cobra.Command, args []string) error { + day, err := resolveDay(date) + if err != nil { + return err + } + + client, err := f.LoadClient() + if err != nil { + return err + } + + me, err := client.GetMyself() + if err != nil { + return err + } + userID := currentUserID(me) + if userID == "" { + return fmt.Errorf("could not determine current user id from /myself") + } + + from := day.Format("2006-01-02") + to := day.AddDate(0, 0, 1).Format("2006-01-02") + jql := fmt.Sprintf(`issuekey IN updatedBy(%q, %q, %q)`, userID, from, to) + + data, err := client.SearchWithChangelog(jql, maxResults) + if err != nil { + return err + } + + driver := f.DisplayDriver(cmd) + if raw { + return driver.Raw(data) + } + + issues, ok := data["issues"].([]any) + if !ok || len(issues) == 0 { + return driver.Message("No activity found for %s.", from) + } + + rows := collectMyChanges(issues, userID, day) + if len(rows) == 0 { + return driver.Message("No activity found for %s.", from) + } + + cols := output.NormalizeFields(columns, []string{"time", "key", "field", "from", "to"}) + return driver.List("Audit", cols, rows) + }, + } + + cmd.Flags().StringVar(&date, "date", "", "Day to audit (YYYY-MM-DD, default today)") + cmd.Flags().IntVar(&maxResults, "max", 50, "Max issues to scan") + cmd.Flags().StringVar(&columns, "columns", "", "Comma-separated columns to display") + cmd.Flags().BoolVar(&raw, "raw", false, "Print raw JSON response") + + return cmd +} + +// resolveDay parses the --date flag (YYYY-MM-DD) or defaults to today (local). +func resolveDay(date string) (time.Time, error) { + if date == "" { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), nil + } + day, err := time.ParseInLocation("2006-01-02", date, time.Local) + if err != nil { + return time.Time{}, fmt.Errorf("invalid --date %q, expected YYYY-MM-DD", date) + } + return day, nil +} + +// currentUserID extracts the identifier used by the updatedBy() JQL function: +// accountId on Cloud, falling back to name/key on Server/DC. +func currentUserID(me map[string]any) string { + for _, k := range []string{"accountId", "name", "key"} { + if v, ok := me[k].(string); ok && v != "" { + return v + } + } + return "" +} + +// collectMyChanges walks each issue's changelog and returns one row per change +// item authored by userID on the target day. Pure (no I/O) for testability. +func collectMyChanges(issues []any, userID string, day time.Time) []map[string]any { + var rows []map[string]any + + for _, raw := range issues { + iss, ok := raw.(map[string]any) + if !ok { + continue + } + key, _ := iss["key"].(string) + var summary any + if flds, ok := iss["fields"].(map[string]any); ok { + summary = flds["summary"] + } + + changelog, ok := iss["changelog"].(map[string]any) + if !ok { + continue + } + histories, ok := changelog["histories"].([]any) + if !ok { + continue + } + + for _, h := range histories { + hist, ok := h.(map[string]any) + if !ok { + continue + } + if !authorMatches(hist["author"], userID) { + continue + } + created, ok := hist["created"].(string) + if !ok || !sameDay(created, day) { + continue + } + items, ok := hist["items"].([]any) + if !ok { + continue + } + for _, it := range items { + item, ok := it.(map[string]any) + if !ok { + continue + } + rows = append(rows, map[string]any{ + "time": created, + "key": key, + "summary": summary, + "field": item["field"], + "from": item["fromString"], + "to": item["toString"], + }) + } + } + } + + return rows +} + +// authorMatches reports whether the changelog author is the current user, +// matching on accountId (Cloud) or name/key (Server/DC). +func authorMatches(author any, userID string) bool { + a, ok := author.(map[string]any) + if !ok { + return false + } + for _, k := range []string{"accountId", "name", "key"} { + if v, ok := a[k].(string); ok && v == userID { + return true + } + } + return false +} + +// sameDay reports whether a Jira timestamp falls on the target day, compared in +// the target day's location. +func sameDay(created string, day time.Time) bool { + t, err := time.Parse(jiraTimeLayout, strings.TrimSpace(created)) + if err != nil { + return false + } + t = t.In(day.Location()) + return t.Year() == day.Year() && t.Month() == day.Month() && t.Day() == day.Day() +} diff --git a/pkg/cmd/audit/audit_test.go b/pkg/cmd/audit/audit_test.go new file mode 100644 index 0000000..4327e4a --- /dev/null +++ b/pkg/cmd/audit/audit_test.go @@ -0,0 +1,78 @@ +package audit + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCollectMyChanges(t *testing.T) { + day := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC) + + issues := []any{ + map[string]any{ + "key": "TEST-1", + "fields": map[string]any{"summary": "Login bug"}, + "changelog": map[string]any{ + "histories": []any{ + // Mine, on the target day -> kept (two items). + map[string]any{ + "created": "2026-06-08T10:30:00.000+0000", + "author": map[string]any{"accountId": "me-123"}, + "items": []any{ + map[string]any{"field": "status", "fromString": "To Do", "toString": "In Progress"}, + map[string]any{"field": "assignee", "fromString": "", "toString": "Me"}, + }, + }, + // Mine, but a different day -> dropped. + map[string]any{ + "created": "2026-06-07T09:00:00.000+0000", + "author": map[string]any{"accountId": "me-123"}, + "items": []any{map[string]any{"field": "priority", "fromString": "Low", "toString": "High"}}, + }, + // Someone else, target day -> dropped. + map[string]any{ + "created": "2026-06-08T12:00:00.000+0000", + "author": map[string]any{"accountId": "other-999"}, + "items": []any{map[string]any{"field": "status", "fromString": "In Progress", "toString": "Done"}}, + }, + }, + }, + }, + } + + rows := collectMyChanges(issues, "me-123", day) + + require.Len(t, rows, 2) + assert.Equal(t, "TEST-1", rows[0]["key"]) + assert.Equal(t, "Login bug", rows[0]["summary"]) + assert.Equal(t, "status", rows[0]["field"]) + assert.Equal(t, "To Do", rows[0]["from"]) + assert.Equal(t, "In Progress", rows[0]["to"]) + assert.Equal(t, "assignee", rows[1]["field"]) +} + +func TestCollectMyChanges_MatchesByName(t *testing.T) { + // Server/DC identifies the author by "name" rather than accountId. + day := time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC) + issues := []any{ + map[string]any{ + "key": "DC-1", + "changelog": map[string]any{ + "histories": []any{ + map[string]any{ + "created": "2026-06-08T08:00:00.000+0000", + "author": map[string]any{"name": "jsmith"}, + "items": []any{map[string]any{"field": "summary", "fromString": "a", "toString": "b"}}, + }, + }, + }, + }, + } + + rows := collectMyChanges(issues, "jsmith", day) + require.Len(t, rows, 1) + assert.Equal(t, "summary", rows[0]["field"]) +} diff --git a/pkg/cmd/issue/edit.go b/pkg/cmd/issue/edit.go index 1b6fa5e..5e51b0c 100644 --- a/pkg/cmd/issue/edit.go +++ b/pkg/cmd/issue/edit.go @@ -1,6 +1,7 @@ package issue import ( + "encoding/json" "fmt" "strings" @@ -31,6 +32,10 @@ Custom fields use --field with the raw field ID: jira issue edit PROJ-123 --field customfield_10001=5 jira issue edit PROJ-123 --field customfield_10002="Option A" +A value that looks like a JSON object or array is parsed, which is +required for user-picker and multi-value fields: + jira issue edit PROJ-123 --field 'customfield_10145={"accountId":"712020:..."}' + Array fields (labels, components, fix-versions) support add/remove with a - prefix: --label bugfix --label -wontfix`, Args: cobra.ExactArgs(1), @@ -114,13 +119,27 @@ with a - prefix: --label bugfix --label -wontfix`, } // parseCustomFields splits "key=value" entries from --field flags. -func parseCustomFields(raw []string) (map[string]string, error) { - result := make(map[string]string, len(raw)) +func parseCustomFields(raw []string) (map[string]any, error) { + result := make(map[string]any, len(raw)) for _, entry := range raw { k, v, ok := strings.Cut(entry, "=") if !ok || k == "" { return nil, fmt.Errorf("invalid --field format %q: expected key=value", entry) } + // Values that look like a JSON object or array are parsed so that + // complex fields can be set, e.g. a user-picker field: + // --field 'customfield_10145={"accountId":"712020:..."}' + // Plain values are kept as strings (Jira accepts strings for text + // and single-select option fields). + trimmed := strings.TrimSpace(v) + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") { + var parsed any + if err := json.Unmarshal([]byte(trimmed), &parsed); err != nil { + return nil, fmt.Errorf("invalid JSON for --field %s: %w", k, err) + } + result[k] = parsed + continue + } result[k] = v } return result, nil diff --git a/pkg/cmd/issue/edit_test.go b/pkg/cmd/issue/edit_test.go new file mode 100644 index 0000000..e1722e7 --- /dev/null +++ b/pkg/cmd/issue/edit_test.go @@ -0,0 +1,42 @@ +package issue + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCustomFields(t *testing.T) { + t.Run("plain values stay strings", func(t *testing.T) { + got, err := parseCustomFields([]string{"customfield_10001=5", "customfield_10002=Option A"}) + require.NoError(t, err) + assert.Equal(t, "5", got["customfield_10001"]) + assert.Equal(t, "Option A", got["customfield_10002"]) + }) + + t.Run("JSON object value is parsed for user-picker fields", func(t *testing.T) { + got, err := parseCustomFields([]string{`customfield_10145={"accountId":"712020:abc"}`}) + require.NoError(t, err) + assert.Equal(t, map[string]any{"accountId": "712020:abc"}, got["customfield_10145"]) + }) + + t.Run("JSON array value is parsed for multi-value fields", func(t *testing.T) { + got, err := parseCustomFields([]string{`customfield_10010=[{"value":"A"},{"value":"B"}]`}) + require.NoError(t, err) + assert.Equal(t, []any{ + map[string]any{"value": "A"}, + map[string]any{"value": "B"}, + }, got["customfield_10010"]) + }) + + t.Run("malformed JSON object returns an error", func(t *testing.T) { + _, err := parseCustomFields([]string{`customfield_10145={"accountId":}`}) + require.Error(t, err) + }) + + t.Run("missing = returns an error", func(t *testing.T) { + _, err := parseCustomFields([]string{"customfield_10001"}) + require.Error(t, err) + }) +} diff --git a/pkg/cmd/me/me.go b/pkg/cmd/me/me.go index d60b2d8..82f5c18 100644 --- a/pkg/cmd/me/me.go +++ b/pkg/cmd/me/me.go @@ -2,6 +2,7 @@ package me import ( "AndersSpringborg/jira-cli/internal/cmdutil" + "AndersSpringborg/jira-cli/pkg/cmd/audit" "github.com/spf13/cobra" ) @@ -36,5 +37,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().BoolVar(&raw, "raw", false, "Print raw JSON") + cmd.AddCommand(audit.NewCmd(f)) + return cmd } diff --git a/pkg/cmd/mine/mine.go b/pkg/cmd/mine/mine.go index 36680b5..6b5749d 100644 --- a/pkg/cmd/mine/mine.go +++ b/pkg/cmd/mine/mine.go @@ -6,6 +6,7 @@ import ( "AndersSpringborg/jira-cli/internal/cmdutil" "AndersSpringborg/jira-cli/internal/output" + "AndersSpringborg/jira-cli/pkg/cmd/audit" "github.com/spf13/cobra" ) @@ -151,5 +152,7 @@ Examples: cmd.Flags().BoolVar(&raw, "raw", false, "Print raw JSON response") cmd.Flags().BoolVar(&all, "all", false, "Include completed issues") + cmd.AddCommand(audit.NewCmd(f)) + return cmd } From e82cd07e24eec9023e3a2a805636178194bb08ee Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 09:47:47 +0200 Subject: [PATCH 2/2] fix: add custom fields to search --- internal/jira/client.go | 9 ++++++--- internal/jira/client_test.go | 15 +++++++++++++++ internal/output/helpers.go | 13 +++++++++++++ internal/output/helpers_test.go | 24 ++++++++++++++++++++++++ pkg/cmd/issue/list.go | 8 +++++++- pkg/cmd/mine/mine.go | 8 +++++++- pkg/cmd/search/jql.go | 8 +++++++- pkg/cmd/search/text.go | 8 +++++++- 8 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 internal/output/helpers_test.go diff --git a/internal/jira/client.go b/internal/jira/client.go index 84c9098..f1fb489 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -284,10 +284,13 @@ func (c *Client) DeleteIssueLink(linkID string) error { // Search executes a JQL search query using the /rest/api/3/search/jql endpoint. // Requests key and common fields by default so results are useful. -func (c *Client) Search(jql string, startAt, maxResults int) (map[string]any, error) { +func (c *Client) Search(jql string, startAt, maxResults int, extraFields ...string) (map[string]any, error) { + fields := "key,summary,status,assignee,priority,issuetype,reporter,resolution,created,updated,labels,description,comment" + if len(extraFields) > 0 { + fields += "," + strings.Join(extraFields, ",") + } path := fmt.Sprintf("/rest/api/3/search/jql?jql=%s&startAt=%d&maxResults=%d&fields=%s", - urlEncode(jql), startAt, maxResults, - "key,summary,status,assignee,priority,issuetype,reporter,resolution,created,updated,labels,description,comment") + urlEncode(jql), startAt, maxResults, fields) return c.getJSON(path) } diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go index 52080ed..fa7ef90 100644 --- a/internal/jira/client_test.go +++ b/internal/jira/client_test.go @@ -124,6 +124,21 @@ func TestSearch(t *testing.T) { assert.Len(t, issues, 1) } +func TestSearchIncludesCustomFields(t *testing.T) { + server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + fields := r.URL.Query().Get("fields") + assert.Contains(t, fields, "summary") + assert.Contains(t, fields, "customfield_10145") + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"total": 0, "issues": []any{}}) + }) + defer server.Close() + + _, err := client.Search("project = TEST", 0, 25, "customfield_10145") + require.NoError(t, err) +} + func TestSearchWithChangelog(t *testing.T) { server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/rest/api/3/search/jql", r.URL.Path) diff --git a/internal/output/helpers.go b/internal/output/helpers.go index 3e52a75..98ae95e 100644 --- a/internal/output/helpers.go +++ b/internal/output/helpers.go @@ -2,6 +2,7 @@ package output import ( "fmt" + "slices" "strings" ) @@ -78,6 +79,18 @@ func NormalizeFields(userColumns string, defaults []string) []string { return result } +// AppendColumns appends extra column names to cols, skipping any that are +// already present. Used to surface requested custom fields as table columns +// without duplicating user-specified ones. +func AppendColumns(cols, extra []string) []string { + for _, e := range extra { + if !slices.Contains(cols, e) { + cols = append(cols, e) + } + } + return cols +} + // Green returns the string as-is. No ANSI colors in AI-first output. func Green(s string) string { return s } diff --git a/internal/output/helpers_test.go b/internal/output/helpers_test.go new file mode 100644 index 0000000..0c4f704 --- /dev/null +++ b/internal/output/helpers_test.go @@ -0,0 +1,24 @@ +package output + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAppendColumns(t *testing.T) { + t.Run("appends new columns", func(t *testing.T) { + got := AppendColumns([]string{"key", "summary"}, []string{"customfield_10145"}) + assert.Equal(t, []string{"key", "summary", "customfield_10145"}, got) + }) + + t.Run("skips columns already present", func(t *testing.T) { + got := AppendColumns([]string{"key", "customfield_10145"}, []string{"customfield_10145"}) + assert.Equal(t, []string{"key", "customfield_10145"}, got) + }) + + t.Run("no extra columns leaves cols unchanged", func(t *testing.T) { + got := AppendColumns([]string{"key"}, nil) + assert.Equal(t, []string{"key"}, got) + }) +} diff --git a/pkg/cmd/issue/list.go b/pkg/cmd/issue/list.go index 47101c2..42d60fb 100644 --- a/pkg/cmd/issue/list.go +++ b/pkg/cmd/issue/list.go @@ -20,6 +20,7 @@ func newListCmd(f *cmdutil.Factory) *cobra.Command { labels []string maxResults int columns string + fields []string raw bool ) @@ -98,7 +99,7 @@ Examples: return err } - data, err := client.Search(jql, 0, maxResults) + data, err := client.Search(jql, 0, maxResults, fields...) if err != nil { return err } @@ -117,6 +118,7 @@ Examples: } cols := output.NormalizeFields(columns, []string{"key", "summary", "status", "assignee", "priority"}) + cols = output.AppendColumns(cols, fields) rows := make([]map[string]any, 0, len(issues)) for _, item := range issues { iss, ok := item.(map[string]any) @@ -136,6 +138,9 @@ Examples: row["reporter"] = flds["reporter"] row["created"] = flds["created"] row["updated"] = flds["updated"] + for _, fid := range fields { + row[fid] = flds[fid] + } } rows = append(rows, row) } @@ -152,6 +157,7 @@ Examples: cmd.Flags().StringSliceVar(&labels, "label", nil, "Filter by label (repeatable)") cmd.Flags().IntVar(&maxResults, "max", 20, "Max results") cmd.Flags().StringVar(&columns, "columns", "", "Comma-separated columns to display") + cmd.Flags().StringArrayVarP(&fields, "field", "F", nil, "Custom field ID to fetch and display (e.g. customfield_10145), repeatable") cmd.Flags().BoolVar(&raw, "raw", false, "Print raw JSON response") return cmd diff --git a/pkg/cmd/mine/mine.go b/pkg/cmd/mine/mine.go index 6b5749d..368d306 100644 --- a/pkg/cmd/mine/mine.go +++ b/pkg/cmd/mine/mine.go @@ -20,6 +20,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command { labels []string maxResults int columns string + fields []string raw bool all bool ) @@ -96,7 +97,7 @@ Examples: return err } - data, err := client.Search(jql, 0, maxResults) + data, err := client.Search(jql, 0, maxResults, fields...) if err != nil { return err } @@ -115,6 +116,7 @@ Examples: } cols := output.NormalizeFields(columns, []string{"key", "summary", "status", "priority", "updated"}) + cols = output.AppendColumns(cols, fields) rows := make([]map[string]any, 0, len(issues)) for _, item := range issues { iss, ok := item.(map[string]any) @@ -134,6 +136,9 @@ Examples: row["reporter"] = flds["reporter"] row["created"] = flds["created"] row["updated"] = flds["updated"] + for _, fid := range fields { + row[fid] = flds[fid] + } } rows = append(rows, row) } @@ -149,6 +154,7 @@ Examples: cmd.Flags().StringSliceVar(&labels, "label", nil, "Filter by label (repeatable)") cmd.Flags().IntVar(&maxResults, "max", 20, "Max results") cmd.Flags().StringVar(&columns, "columns", "", "Comma-separated columns to display") + cmd.Flags().StringArrayVarP(&fields, "field", "F", nil, "Custom field ID to fetch and display (e.g. customfield_10145), repeatable") cmd.Flags().BoolVar(&raw, "raw", false, "Print raw JSON response") cmd.Flags().BoolVar(&all, "all", false, "Include completed issues") diff --git a/pkg/cmd/search/jql.go b/pkg/cmd/search/jql.go index 13e5f99..9e2f39c 100644 --- a/pkg/cmd/search/jql.go +++ b/pkg/cmd/search/jql.go @@ -11,6 +11,7 @@ func newJQLCmd(f *cmdutil.Factory) *cobra.Command { var ( maxResults int columns string + fields []string raw bool ) @@ -26,7 +27,7 @@ func newJQLCmd(f *cmdutil.Factory) *cobra.Command { return err } - data, err := client.Search(jql, 0, maxResults) + data, err := client.Search(jql, 0, maxResults, fields...) if err != nil { return err } @@ -48,6 +49,7 @@ func newJQLCmd(f *cmdutil.Factory) *cobra.Command { } cols := output.NormalizeFields(columns, []string{"key", "summary", "status", "assignee", "priority"}) + cols = output.AppendColumns(cols, fields) rows := make([]map[string]any, 0, len(issues)) for _, item := range issues { issue, ok := item.(map[string]any) @@ -68,6 +70,9 @@ func newJQLCmd(f *cmdutil.Factory) *cobra.Command { row["resolution"] = flds["resolution"] row["created"] = flds["created"] row["updated"] = flds["updated"] + for _, fid := range fields { + row[fid] = flds[fid] + } } rows = append(rows, row) } @@ -78,6 +83,7 @@ func newJQLCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().IntVar(&maxResults, "max", 50, "Max results") cmd.Flags().StringVar(&columns, "columns", "", "Comma-separated columns to display") + cmd.Flags().StringArrayVarP(&fields, "field", "F", nil, "Custom field ID to fetch and display (e.g. customfield_10145), repeatable") cmd.Flags().BoolVar(&raw, "raw", false, "Print raw JSON response") return cmd diff --git a/pkg/cmd/search/text.go b/pkg/cmd/search/text.go index c708d4e..8d7abb4 100644 --- a/pkg/cmd/search/text.go +++ b/pkg/cmd/search/text.go @@ -15,6 +15,7 @@ func newTextCmd(f *cmdutil.Factory) *cobra.Command { project string maxResults int columns string + fields []string raw bool ) @@ -45,7 +46,7 @@ func newTextCmd(f *cmdutil.Factory) *cobra.Command { return err } - data, err := client.Search(jql, 0, maxResults) + data, err := client.Search(jql, 0, maxResults, fields...) if err != nil { return err } @@ -67,6 +68,7 @@ func newTextCmd(f *cmdutil.Factory) *cobra.Command { } cols := output.NormalizeFields(columns, []string{"key", "summary", "status", "assignee", "priority"}) + cols = output.AppendColumns(cols, fields) rows := make([]map[string]any, 0, len(issues)) for _, item := range issues { issue, ok := item.(map[string]any) @@ -87,6 +89,9 @@ func newTextCmd(f *cmdutil.Factory) *cobra.Command { row["resolution"] = flds["resolution"] row["created"] = flds["created"] row["updated"] = flds["updated"] + for _, fid := range fields { + row[fid] = flds[fid] + } } rows = append(rows, row) } @@ -98,6 +103,7 @@ func newTextCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVarP(&project, "project", "p", "", "Filter by project key") cmd.Flags().IntVar(&maxResults, "max", 50, "Max results") cmd.Flags().StringVar(&columns, "columns", "", "Comma-separated columns to display") + cmd.Flags().StringArrayVarP(&fields, "field", "F", nil, "Custom field ID to fetch and display (e.g. customfield_10145), repeatable") cmd.Flags().BoolVar(&raw, "raw", false, "Print raw JSON response") return cmd