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
10 changes: 10 additions & 0 deletions internal/jira/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions internal/jira/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
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()
}
78 changes: 78 additions & 0 deletions pkg/cmd/audit/audit_test.go
Original file line number Diff line number Diff line change
@@ -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"])
}
23 changes: 21 additions & 2 deletions pkg/cmd/issue/edit.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package issue

import (
"encoding/json"
"fmt"
"strings"

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading