Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions internal/jira/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,10 +284,23 @@ 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)
}

// 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)
}

Expand Down
52 changes: 52 additions & 0 deletions internal/jira/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,58 @@ 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)
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)
Expand Down
13 changes: 13 additions & 0 deletions internal/output/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package output

import (
"fmt"
"slices"
"strings"
)

Expand Down Expand Up @@ -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 }

Expand Down
24 changes: 24 additions & 0 deletions internal/output/helpers_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
208 changes: 208 additions & 0 deletions pkg/cmd/audit/audit.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading
Loading