From 915fb3421c4af4b5cab1231f8963580e5ae91b41 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 21:56:41 +0000 Subject: [PATCH 01/18] bd init: initialize beads issue tracking --- .beads/hooks/post-checkout | 21 +++++------------ .beads/hooks/post-merge | 21 +++++------------ .beads/hooks/pre-commit | 21 +++++------------ .beads/hooks/pre-push | 21 +++++------------ .beads/hooks/prepare-commit-msg | 21 +++++------------ .claude/settings.json | 20 +++++++++++++++++ AGENTS.md | 39 +++++++++++++------------------- CLAUDE.md | 40 ++++++++++++++------------------- 8 files changed, 83 insertions(+), 121 deletions(-) diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout index 2ec8c38..7d35c68 100755 --- a/.beads/hooks/post-checkout +++ b/.beads/hooks/post-checkout @@ -1,33 +1,24 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v1.0.5 --- +# --- BEGIN BEADS INTEGRATION v1.0.4 --- # This section is managed by beads. Do not remove these markers. if command -v bd >/dev/null 2>&1; then export BD_GIT_HOOK=1 _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} - _bd_used_perl=0 if command -v timeout >/dev/null 2>&1; then timeout "$_bd_timeout" bd hooks run post-checkout "$@" _bd_exit=$? - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$_bd_timeout" bd hooks run post-checkout "$@" - _bd_exit=$? - elif command -v perl >/dev/null 2>&1; then - _bd_used_perl=1 - perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run post-checkout "$@" - _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi else - echo >&2 "beads: hook 'post-checkout' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT" bd hooks run post-checkout "$@" _bd_exit=$? fi - if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then - echo >&2 "beads: hook 'post-checkout' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi if [ $_bd_exit -eq 3 ]; then echo >&2 "beads: database not initialized — skipping hook 'post-checkout'" _bd_exit=0 fi if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi fi -# --- END BEADS INTEGRATION v1.0.5 --- +# --- END BEADS INTEGRATION v1.0.4 --- diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge index 7ade514..1f458ba 100755 --- a/.beads/hooks/post-merge +++ b/.beads/hooks/post-merge @@ -1,33 +1,24 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v1.0.5 --- +# --- BEGIN BEADS INTEGRATION v1.0.4 --- # This section is managed by beads. Do not remove these markers. if command -v bd >/dev/null 2>&1; then export BD_GIT_HOOK=1 _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} - _bd_used_perl=0 if command -v timeout >/dev/null 2>&1; then timeout "$_bd_timeout" bd hooks run post-merge "$@" _bd_exit=$? - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$_bd_timeout" bd hooks run post-merge "$@" - _bd_exit=$? - elif command -v perl >/dev/null 2>&1; then - _bd_used_perl=1 - perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run post-merge "$@" - _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi else - echo >&2 "beads: hook 'post-merge' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT" bd hooks run post-merge "$@" _bd_exit=$? fi - if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then - echo >&2 "beads: hook 'post-merge' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi if [ $_bd_exit -eq 3 ]; then echo >&2 "beads: database not initialized — skipping hook 'post-merge'" _bd_exit=0 fi if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi fi -# --- END BEADS INTEGRATION v1.0.5 --- +# --- END BEADS INTEGRATION v1.0.4 --- diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit index 749dc91..ad1fb16 100755 --- a/.beads/hooks/pre-commit +++ b/.beads/hooks/pre-commit @@ -1,33 +1,24 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v1.0.5 --- +# --- BEGIN BEADS INTEGRATION v1.0.4 --- # This section is managed by beads. Do not remove these markers. if command -v bd >/dev/null 2>&1; then export BD_GIT_HOOK=1 _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} - _bd_used_perl=0 if command -v timeout >/dev/null 2>&1; then timeout "$_bd_timeout" bd hooks run pre-commit "$@" _bd_exit=$? - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$_bd_timeout" bd hooks run pre-commit "$@" - _bd_exit=$? - elif command -v perl >/dev/null 2>&1; then - _bd_used_perl=1 - perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run pre-commit "$@" - _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi else - echo >&2 "beads: hook 'pre-commit' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT" bd hooks run pre-commit "$@" _bd_exit=$? fi - if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then - echo >&2 "beads: hook 'pre-commit' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi if [ $_bd_exit -eq 3 ]; then echo >&2 "beads: database not initialized — skipping hook 'pre-commit'" _bd_exit=0 fi if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi fi -# --- END BEADS INTEGRATION v1.0.5 --- +# --- END BEADS INTEGRATION v1.0.4 --- diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push index fb85f89..35c2a69 100755 --- a/.beads/hooks/pre-push +++ b/.beads/hooks/pre-push @@ -1,33 +1,24 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v1.0.5 --- +# --- BEGIN BEADS INTEGRATION v1.0.4 --- # This section is managed by beads. Do not remove these markers. if command -v bd >/dev/null 2>&1; then export BD_GIT_HOOK=1 _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} - _bd_used_perl=0 if command -v timeout >/dev/null 2>&1; then timeout "$_bd_timeout" bd hooks run pre-push "$@" _bd_exit=$? - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$_bd_timeout" bd hooks run pre-push "$@" - _bd_exit=$? - elif command -v perl >/dev/null 2>&1; then - _bd_used_perl=1 - perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run pre-push "$@" - _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi else - echo >&2 "beads: hook 'pre-push' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT" bd hooks run pre-push "$@" _bd_exit=$? fi - if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then - echo >&2 "beads: hook 'pre-push' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi if [ $_bd_exit -eq 3 ]; then echo >&2 "beads: database not initialized — skipping hook 'pre-push'" _bd_exit=0 fi if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi fi -# --- END BEADS INTEGRATION v1.0.5 --- +# --- END BEADS INTEGRATION v1.0.4 --- diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg index 8f5b58d..a72277d 100755 --- a/.beads/hooks/prepare-commit-msg +++ b/.beads/hooks/prepare-commit-msg @@ -1,33 +1,24 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v1.0.5 --- +# --- BEGIN BEADS INTEGRATION v1.0.4 --- # This section is managed by beads. Do not remove these markers. if command -v bd >/dev/null 2>&1; then export BD_GIT_HOOK=1 _bd_timeout=${BEADS_HOOK_TIMEOUT:-300} - _bd_used_perl=0 if command -v timeout >/dev/null 2>&1; then timeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@" _bd_exit=$? - elif command -v gtimeout >/dev/null 2>&1; then - gtimeout "$_bd_timeout" bd hooks run prepare-commit-msg "$@" - _bd_exit=$? - elif command -v perl >/dev/null 2>&1; then - _bd_used_perl=1 - perl -e 'alarm shift; exec @ARGV' "$_bd_timeout" bd hooks run prepare-commit-msg "$@" - _bd_exit=$? + if [ $_bd_exit -eq 124 ]; then + echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads" + _bd_exit=0 + fi else - echo >&2 "beads: hook 'prepare-commit-msg' running without timeout; install coreutils or perl to enable BEADS_HOOK_TIMEOUT" bd hooks run prepare-commit-msg "$@" _bd_exit=$? fi - if [ $_bd_exit -eq 124 ] || { [ $_bd_used_perl -eq 1 ] && [ $_bd_exit -eq 142 ]; }; then - echo >&2 "beads: hook 'prepare-commit-msg' timed out after ${_bd_timeout}s — continuing without beads" - _bd_exit=0 - fi if [ $_bd_exit -eq 3 ]; then echo >&2 "beads: database not initialized — skipping hook 'prepare-commit-msg'" _bd_exit=0 fi if [ $_bd_exit -ne 0 ]; then exit $_bd_exit; fi fi -# --- END BEADS INTEGRATION v1.0.5 --- +# --- END BEADS INTEGRATION v1.0.4 --- diff --git a/.claude/settings.json b/.claude/settings.json index c6907bf..bd4fdc7 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,16 @@ { "hooks": { + "PreCompact": [ + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" + } + ], "SessionStart": [ { "hooks": [ @@ -9,6 +20,15 @@ } ], "matcher": "" + }, + { + "hooks": [ + { + "command": "bd prime", + "type": "command" + } + ], + "matcher": "" } ] } diff --git a/AGENTS.md b/AGENTS.md index 3809312..9cec6d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,7 @@ cp -rf source dest # NOT: cp -r source dest - `apt-get` - use `-y` flag - `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var - + ## Beads Issue Tracker This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. @@ -58,38 +58,31 @@ bd close # Complete work **Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns. -## Agent Context Profiles - -The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions. - -- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands. -- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise. -- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins. - ## Session Completion -This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions. +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. -1. **File issues for remaining work** - Create beads for anything that needs follow-up +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up 2. **Run quality gates** (if code changed) - Tests, linters, builds 3. **Update issue status** - Close finished work, update in-progress items -4. **Handle git/sync by active profile**: +4. **PUSH TO REMOTE** - This is MANDATORY: ```bash - # Conservative/minimal/default: report status and proposed commands; wait for approval. - git status - - # Team-maintainer opt-in only, unless current instructions forbid it: git pull --rebase bd dolt push git push - git status + git status # MUST show "up to date with origin" ``` -5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step - -**Critical rules:** -- Explicit user or orchestrator instructions override this Beads block. -- Do not commit or push without clear authority from the active profile or the current user request. -- If a required sync or push is blocked, stop and report the exact command and error. +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds diff --git a/CLAUDE.md b/CLAUDE.md index fce9c4c..2e64607 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -109,7 +109,7 @@ Reference docs (read before implementing any feature): - `docs/research.md` — prior art, language/approach decisions - + ## Beads Issue Tracker This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. @@ -131,35 +131,29 @@ bd close # Complete work **Architecture in one line:** issues live in a local Dolt DB; sync uses `refs/dolt/data` on your git remote; `.beads/issues.jsonl` is a passive export. See https://github.com/gastownhall/beads/blob/main/docs/SYNC_CONCEPTS.md for details and anti-patterns. -## Agent Context Profiles - -The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions. - -- **Conservative (default)**: Use `bd` for task tracking. Do not run git commits, git pushes, or Dolt remote sync unless explicitly asked. At handoff, report changed files, validation, and suggested next commands. -- **Minimal**: Keep tool instruction files as pointers to `bd prime`; use the same conservative git policy unless active instructions say otherwise. -- **Team-maintainer**: Only when the repository explicitly opts in, agents may close beads, run quality gates, commit, and push as part of session close. A current "do not commit" or "do not push" instruction still wins. - ## Session Completion -This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions. +**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. -1. **File issues for remaining work** - Create beads for anything that needs follow-up +**MANDATORY WORKFLOW:** + +1. **File issues for remaining work** - Create issues for anything that needs follow-up 2. **Run quality gates** (if code changed) - Tests, linters, builds 3. **Update issue status** - Close finished work, update in-progress items -4. **Handle git/sync by active profile**: +4. **PUSH TO REMOTE** - This is MANDATORY: ```bash - # Conservative/minimal/default: report status and proposed commands; wait for approval. - git status - - # Team-maintainer opt-in only, unless current instructions forbid it: git pull --rebase + bd dolt push git push - git status + git status # MUST show "up to date with origin" ``` -5. **Hand off** - Summarize changes, validation, issue status, and any blocked sync/commit/push step - -**Critical rules:** -- Explicit user or orchestrator instructions override this Beads block. -- Do not commit or push without clear authority from the active profile or the current user request. -- If a required sync or push is blocked, stop and report the exact command and error. +5. **Clean up** - Clear stashes, prune remote branches +6. **Verify** - All changes committed AND pushed +7. **Hand off** - Provide context for next session + +**CRITICAL RULES:** +- Work is NOT complete until `git push` succeeds +- NEVER stop before pushing - that leaves work stranded locally +- NEVER say "ready to push when you are" - YOU must push +- If push fails, resolve and retry until it succeeds From 6512506c2fe6786554762d19dd9b320c4871257c Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:02:30 +0000 Subject: [PATCH 02/18] feat: add built-in and parameterized template variables Extend resolveTemplates() with new built-in time variables (yesterday, month_ago, quarter_ago, this_week, this_month, this_quarter), parameterized time variables (days_ago:N, weeks_ago:N, months_ago:N), and a projects variable that expands to comma-separated quoted project keys from config. --- internal/query/engine.go | 83 ++++++++++++- internal/query/engine_test.go | 224 ++++++++++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 5 deletions(-) diff --git a/internal/query/engine.go b/internal/query/engine.go index 72b63eb..add4225 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -2,6 +2,8 @@ package query import ( "fmt" + "regexp" + "strconv" "strings" "time" @@ -9,6 +11,9 @@ import ( "github.com/sthadka/jai/internal/db" ) +// paramVarRe matches parameterized template variables like {{days_ago:14}}. +var paramVarRe = regexp.MustCompile(`\{\{(days_ago|weeks_ago|months_ago):(\d+)\}\}`) + // Results holds query output. type Results struct { Columns []string @@ -83,15 +88,83 @@ func requireReadOnly(sql string) error { // resolveTemplates replaces {{variable}} placeholders with their values. func (e *Engine) resolveTemplates(sql string) string { - now := time.Now() + return resolveTemplatesAt(sql, time.Now(), e.cfg) +} + +// quarterStart returns the first day of the quarter containing the given time. +func quarterStart(t time.Time) time.Time { + q := (t.Month()-1)/3*3 + 1 // Jan=1, Apr=4, Jul=7, Oct=10 + return time.Date(t.Year(), q, 1, 0, 0, 0, 0, t.Location()) +} + +// mondayOfWeek returns the Monday of the week containing the given time. +func mondayOfWeek(t time.Time) time.Time { + weekday := t.Weekday() + if weekday == time.Sunday { + weekday = 7 + } + offset := int(weekday) - int(time.Monday) + return t.AddDate(0, 0, -offset) +} + +// projectKeys returns a comma-separated list of single-quoted project keys +// from all configured sync sources (e.g., 'PROJ1','PROJ2'). +func projectKeys(cfg *config.Config) string { + seen := map[string]bool{} + var keys []string + for _, src := range cfg.SyncSources { + for _, p := range src.Projects { + if !seen[p] { + seen[p] = true + keys = append(keys, "'"+p+"'") + } + } + } + return strings.Join(keys, ",") +} + +// resolveTemplatesAt is the pure-function core of template resolution. +// It accepts the current time and config explicitly to enable deterministic testing. +func resolveTemplatesAt(sql string, now time.Time, cfg *config.Config) string { + dateFmt := "2006-01-02" + replacements := map[string]string{ - "{{me}}": e.cfg.Me, - "{{team}}": e.cfg.Team, - "{{today}}": now.Format("2006-01-02"), - "{{week_ago}}": now.AddDate(0, 0, -7).Format("2006-01-02"), + "{{me}}": cfg.Me, + "{{team}}": cfg.Team, + "{{today}}": now.Format(dateFmt), + "{{yesterday}}": now.AddDate(0, 0, -1).Format(dateFmt), + "{{week_ago}}": now.AddDate(0, 0, -7).Format(dateFmt), + "{{month_ago}}": now.AddDate(0, 0, -30).Format(dateFmt), + "{{quarter_ago}}": now.AddDate(0, 0, -90).Format(dateFmt), + "{{this_week}}": mondayOfWeek(now).Format(dateFmt), + "{{this_month}}": time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).Format(dateFmt), + "{{this_quarter}}": quarterStart(now).Format(dateFmt), + "{{projects}}": projectKeys(cfg), } for k, v := range replacements { sql = strings.ReplaceAll(sql, k, v) } + + // Handle parameterized variables: {{days_ago:N}}, {{weeks_ago:N}}, {{months_ago:N}}. + sql = paramVarRe.ReplaceAllStringFunc(sql, func(match string) string { + parts := paramVarRe.FindStringSubmatch(match) + if len(parts) != 3 { + return match // leave as-is + } + n, err := strconv.Atoi(parts[2]) + if err != nil { + return match // non-numeric → leave as-is + } + switch parts[1] { + case "days_ago": + return now.AddDate(0, 0, -n).Format(dateFmt) + case "weeks_ago": + return now.AddDate(0, 0, -n*7).Format(dateFmt) + case "months_ago": + return now.AddDate(0, -n, 0).Format(dateFmt) + } + return match + }) + return sql } diff --git a/internal/query/engine_test.go b/internal/query/engine_test.go index 77c390c..500feae 100644 --- a/internal/query/engine_test.go +++ b/internal/query/engine_test.go @@ -3,6 +3,7 @@ package query import ( "path/filepath" "testing" + "time" "github.com/sthadka/jai/internal/config" "github.com/sthadka/jai/internal/db" @@ -54,6 +55,229 @@ func TestExecute_TemplateVars(t *testing.T) { } } +func TestResolveTemplates_BuiltinTimeVars(t *testing.T) { + // Use a known Wednesday: 2024-07-17 (Wednesday) + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{ + Me: "user@example.com", + Team: "my-team", + } + + tests := []struct { + name string + input string + expected string + }{ + {"today", "{{today}}", "2024-07-17"}, + {"yesterday", "{{yesterday}}", "2024-07-16"}, + {"week_ago", "{{week_ago}}", "2024-07-10"}, + {"month_ago", "{{month_ago}}", "2024-06-17"}, + {"quarter_ago", "{{quarter_ago}}", "2024-04-18"}, + {"this_week is Monday", "{{this_week}}", "2024-07-15"}, + {"this_month", "{{this_month}}", "2024-07-01"}, + {"this_quarter Jul->Jul1", "{{this_quarter}}", "2024-07-01"}, + {"me", "{{me}}", "user@example.com"}, + {"team", "{{team}}", "my-team"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveTemplatesAt(tc.input, now, cfg) + if got != tc.expected { + t.Errorf("resolveTemplatesAt(%q) = %q, want %q", tc.input, got, tc.expected) + } + }) + } +} + +func TestResolveTemplates_ThisWeekAlwaysMonday(t *testing.T) { + cfg := &config.Config{} + // Check every day of a week (Mon 2024-07-15 .. Sun 2024-07-21) + for d := 15; d <= 21; d++ { + now := time.Date(2024, 7, d, 12, 0, 0, 0, time.UTC) + got := resolveTemplatesAt("{{this_week}}", now, cfg) + if got != "2024-07-15" { + t.Errorf("day=%d (%s): this_week = %q, want 2024-07-15", d, now.Weekday(), got) + } + } +} + +func TestResolveTemplates_ThisQuarterBoundaries(t *testing.T) { + cfg := &config.Config{} + tests := []struct { + date time.Time + expected string + }{ + {time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), "2024-01-01"}, + {time.Date(2024, 2, 15, 0, 0, 0, 0, time.UTC), "2024-01-01"}, + {time.Date(2024, 3, 31, 0, 0, 0, 0, time.UTC), "2024-01-01"}, + {time.Date(2024, 4, 1, 0, 0, 0, 0, time.UTC), "2024-04-01"}, + {time.Date(2024, 6, 30, 0, 0, 0, 0, time.UTC), "2024-04-01"}, + {time.Date(2024, 7, 1, 0, 0, 0, 0, time.UTC), "2024-07-01"}, + {time.Date(2024, 9, 30, 0, 0, 0, 0, time.UTC), "2024-07-01"}, + {time.Date(2024, 10, 1, 0, 0, 0, 0, time.UTC), "2024-10-01"}, + {time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC), "2024-10-01"}, + } + for _, tc := range tests { + t.Run(tc.date.Format("2006-01-02"), func(t *testing.T) { + got := resolveTemplatesAt("{{this_quarter}}", tc.date, cfg) + if got != tc.expected { + t.Errorf("this_quarter on %s = %q, want %q", tc.date.Format("2006-01-02"), got, tc.expected) + } + }) + } +} + +func TestResolveTemplates_ParameterizedVars(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{} + + tests := []struct { + name string + input string + expected string + }{ + {"days_ago:7 == week_ago", "{{days_ago:7}}", "2024-07-10"}, + {"days_ago:14", "{{days_ago:14}}", "2024-07-03"}, + {"days_ago:1 == yesterday", "{{days_ago:1}}", "2024-07-16"}, + {"weeks_ago:1", "{{weeks_ago:1}}", "2024-07-10"}, + {"weeks_ago:4", "{{weeks_ago:4}}", "2024-06-19"}, + {"months_ago:1", "{{months_ago:1}}", "2024-06-17"}, + {"months_ago:3", "{{months_ago:3}}", "2024-04-17"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveTemplatesAt(tc.input, now, cfg) + if got != tc.expected { + t.Errorf("resolveTemplatesAt(%q) = %q, want %q", tc.input, got, tc.expected) + } + }) + } +} + +func TestResolveTemplates_DaysAgo7EqualsWeekAgo(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{} + + daysAgo := resolveTemplatesAt("{{days_ago:7}}", now, cfg) + weekAgo := resolveTemplatesAt("{{week_ago}}", now, cfg) + if daysAgo != weekAgo { + t.Errorf("days_ago:7 (%s) != week_ago (%s)", daysAgo, weekAgo) + } +} + +func TestResolveTemplates_MonthsAgo1EqualsMonthAgo(t *testing.T) { + // months_ago:1 uses AddDate(0,-1,0), month_ago uses AddDate(0,0,-30). + // They match when the month has exactly 30 days. Use June 30 (30 days). + // Actually per spec: month_ago is 30 days ago, months_ago:1 is calendar month. + // Let's test that both resolve without error and are plausible dates. + now := time.Date(2024, 7, 30, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{} + + monthsAgo := resolveTemplatesAt("{{months_ago:1}}", now, cfg) + monthAgo := resolveTemplatesAt("{{month_ago}}", now, cfg) + + // months_ago:1 on July 30 → June 30 + if monthsAgo != "2024-06-30" { + t.Errorf("months_ago:1 = %q, want 2024-06-30", monthsAgo) + } + // month_ago on July 30 → 30 days ago → June 30 + if monthAgo != "2024-06-30" { + t.Errorf("month_ago = %q, want 2024-06-30", monthAgo) + } +} + +func TestResolveTemplates_InvalidParameterizedLeftAsIs(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{} + + tests := []struct { + name string + input string + }{ + {"unknown_var", "{{unknown_ago:5}}"}, + {"no_number", "{{days_ago:abc}}"}, + {"wrong_format", "{{days_ago}}"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveTemplatesAt(tc.input, now, cfg) + if got != tc.input { + t.Errorf("expected %q to be left as-is, got %q", tc.input, got) + } + }) + } +} + +func TestResolveTemplates_Projects(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + cfg *config.Config + expected string + }{ + { + "single project", + &config.Config{ + SyncSources: []config.SyncSource{ + {Projects: []string{"PROJ1"}}, + }, + }, + "'PROJ1'", + }, + { + "multiple projects across sources", + &config.Config{ + SyncSources: []config.SyncSource{ + {Projects: []string{"PROJ1", "PROJ2"}}, + {Projects: []string{"PROJ3"}}, + }, + }, + "'PROJ1','PROJ2','PROJ3'", + }, + { + "deduplicates projects", + &config.Config{ + SyncSources: []config.SyncSource{ + {Projects: []string{"PROJ1", "PROJ2"}}, + {Projects: []string{"PROJ2", "PROJ3"}}, + }, + }, + "'PROJ1','PROJ2','PROJ3'", + }, + { + "no sync sources", + &config.Config{}, + "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := resolveTemplatesAt("{{projects}}", now, tc.cfg) + if got != tc.expected { + t.Errorf("projects = %q, want %q", got, tc.expected) + } + }) + } +} + +func TestResolveTemplates_MultipleVarsInQuery(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{ + Me: "user@example.com", + SyncSources: []config.SyncSource{ + {Projects: []string{"PROJ1"}}, + }, + } + + input := "SELECT * FROM issues WHERE assignee = '{{me}}' AND updated >= '{{days_ago:14}}' AND project IN ({{projects}})" + expected := "SELECT * FROM issues WHERE assignee = 'user@example.com' AND updated >= '2024-07-03' AND project IN ('PROJ1')" + got := resolveTemplatesAt(input, now, cfg) + if got != expected { + t.Errorf("multi-var query:\ngot: %s\nwant: %s", got, expected) + } +} + func TestTable_Empty(t *testing.T) { r := &Results{Columns: []string{"key"}, Rows: nil, Count: 0} out := r.Table() From 042669811b447c752ff56501186acaa704cef514 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:02:40 +0000 Subject: [PATCH 03/18] feat: add watch/unwatch commands and remote link support in jira client Add jai watch and jai unwatch commands for managing issue watchers directly via the Jira API. Also add AddWatcher, RemoveWatcher, CreateRemoteLink methods and a delete HTTP helper to the jira client. --- internal/cli/watch.go | 109 +++++++++++++++++++++++++++++++++++++ internal/cli/watch_test.go | 90 ++++++++++++++++++++++++++++++ internal/jira/write.go | 35 ++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 internal/cli/watch.go create mode 100644 internal/cli/watch_test.go diff --git a/internal/cli/watch.go b/internal/cli/watch.go new file mode 100644 index 0000000..438eb1a --- /dev/null +++ b/internal/cli/watch.go @@ -0,0 +1,109 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/sthadka/jai/internal/output" +) + +var watchCmd = &cobra.Command{ + Use: "watch [user]", + Short: "Add a watcher to a Jira issue (pushed immediately)", + Long: `Add a watcher to a Jira issue directly via the Jira API. + +If no user is specified, adds yourself (from config "me") as a watcher. + +Examples: + jai watch PROJ-123 # watch as yourself + jai watch PROJ-123 user@example.com # add specific user as watcher`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + issueKey := strings.ToUpper(args[0]) + + user := g.cfg.Me + if len(args) > 1 { + user = args[1] + } + + if user == "" { + msg := "no user specified and 'me' not set in config" + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + if err := g.jira.AddWatcher(cmd.Context(), issueKey, user); err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("adding watcher: %w", err) + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]string{ + "message": fmt.Sprintf("added %s as watcher on %s", user, issueKey), + "issue_key": issueKey, + "user": user, + }))) + return nil + } + + fmt.Printf("%s: added %s as watcher\n", issueKey, user) + return nil + }, +} + +var unwatchCmd = &cobra.Command{ + Use: "unwatch ", + Short: "Remove yourself as a watcher from a Jira issue (pushed immediately)", + Long: `Remove yourself as a watcher from a Jira issue directly via the Jira API. + +Always removes the configured "me" user. + +Examples: + jai unwatch PROJ-123`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + issueKey := strings.ToUpper(args[0]) + + user := g.cfg.Me + if user == "" { + msg := "'me' not set in config — cannot determine which user to remove" + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + if err := g.jira.RemoveWatcher(cmd.Context(), issueKey, user); err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("removing watcher: %w", err) + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]string{ + "message": fmt.Sprintf("removed %s as watcher from %s", user, issueKey), + "issue_key": issueKey, + "user": user, + }))) + return nil + } + + fmt.Printf("%s: removed %s as watcher\n", issueKey, user) + return nil + }, +} + +func init() { + rootCmd.AddCommand(watchCmd) + rootCmd.AddCommand(unwatchCmd) +} diff --git a/internal/cli/watch_test.go b/internal/cli/watch_test.go new file mode 100644 index 0000000..0a76782 --- /dev/null +++ b/internal/cli/watch_test.go @@ -0,0 +1,90 @@ +package cli + +import ( + "testing" +) + +func TestWatchCmd_ArgsValidation(t *testing.T) { + cmd := watchCmd + + tests := []struct { + name string + args []string + wantErr bool + }{ + {name: "zero args", args: []string{}, wantErr: true}, + {name: "one arg (issue key)", args: []string{"PROJ-123"}, wantErr: false}, + {name: "two args (issue key + user)", args: []string{"PROJ-123", "user@example.com"}, wantErr: false}, + {name: "three args", args: []string{"PROJ-123", "user@example.com", "extra"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := cmd.Args(cmd, tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("Args(%v) error = %v, wantErr %v", tt.args, err, tt.wantErr) + } + }) + } +} + +func TestUnwatchCmd_ArgsValidation(t *testing.T) { + cmd := unwatchCmd + + tests := []struct { + name string + args []string + wantErr bool + }{ + {name: "zero args", args: []string{}, wantErr: true}, + {name: "one arg (issue key)", args: []string{"PROJ-123"}, wantErr: false}, + {name: "two args", args: []string{"PROJ-123", "extra"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := cmd.Args(cmd, tt.args) + if (err != nil) != tt.wantErr { + t.Errorf("Args(%v) error = %v, wantErr %v", tt.args, err, tt.wantErr) + } + }) + } +} + +func TestWatchCmd_Registration(t *testing.T) { + found := false + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "watch" { + found = true + break + } + } + if !found { + t.Error("expected 'watch' command to be registered on rootCmd") + } +} + +func TestUnwatchCmd_Registration(t *testing.T) { + found := false + for _, cmd := range rootCmd.Commands() { + if cmd.Name() == "unwatch" { + found = true + break + } + } + if !found { + t.Error("expected 'unwatch' command to be registered on rootCmd") + } +} + +func TestWatchCmd_UseString(t *testing.T) { + if watchCmd.Use != "watch [user]" { + t.Errorf("unexpected Use string: %s", watchCmd.Use) + } +} + +func TestUnwatchCmd_UseString(t *testing.T) { + if unwatchCmd.Use != "unwatch " { + t.Errorf("unexpected Use string: %s", unwatchCmd.Use) + } +} diff --git a/internal/jira/write.go b/internal/jira/write.go index 20dc85b..e599439 100644 --- a/internal/jira/write.go +++ b/internal/jira/write.go @@ -117,6 +117,41 @@ func (c *Client) CreateLink(ctx context.Context, linkType, inwardKey, outwardKey return c.post(ctx, "/rest/api/3/issueLink", payload) } +// AddWatcher adds a watcher to a Jira issue. +// The Jira Cloud watchers API expects a quoted accountId string as the request body. +func (c *Client) AddWatcher(ctx context.Context, issueKey, accountID string) error { + return c.post(ctx, fmt.Sprintf("/rest/api/3/issue/%s/watchers", issueKey), accountID) +} + +// RemoveWatcher removes a watcher from a Jira issue. +func (c *Client) RemoveWatcher(ctx context.Context, issueKey, accountID string) error { + return c.del(ctx, fmt.Sprintf("/rest/api/3/issue/%s/watchers?accountId=%s", issueKey, accountID)) +} + +// CreateRemoteLink creates a remote (web URL) link on a Jira issue. +func (c *Client) CreateRemoteLink(ctx context.Context, issueKey, url, title string) error { + payload := map[string]interface{}{ + "object": map[string]string{ + "url": url, + "title": title, + }, + } + return c.post(ctx, fmt.Sprintf("/rest/api/3/issue/%s/remotelink", issueKey), payload) +} + +func (c *Client) del(ctx context.Context, path string) error { + resp, err := c.doRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("jira DELETE %s: %d %s", path, resp.StatusCode, string(b)) + } + return nil +} + func (c *Client) put(ctx context.Context, path string, body interface{}) error { data, err := json.Marshal(body) if err != nil { From 3580d2270978906393bdd186b86149887286f43a Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:03:42 +0000 Subject: [PATCH 04/18] feat: add shell completions command for bash, zsh, fish, and powershell --- internal/cli/completion.go | 60 ++++++++++++++++++++ internal/cli/completion_test.go | 99 +++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 internal/cli/completion.go create mode 100644 internal/cli/completion_test.go diff --git a/internal/cli/completion.go b/internal/cli/completion.go new file mode 100644 index 0000000..3051d2b --- /dev/null +++ b/internal/cli/completion.go @@ -0,0 +1,60 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +var completionCmd = &cobra.Command{ + Use: "completion [bash|zsh|fish|powershell]", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for jai. + +To load completions: + +Bash: + $ source <(jai completion bash) + # To load completions for each session, execute once: + # Linux: + $ jai completion bash > /etc/bash_completion.d/jai + # macOS: + $ jai completion bash > $(brew --prefix)/etc/bash_completion.d/jai + +Zsh: + # If shell completion is not already enabled in your environment, + # you will need to enable it. You can execute the following once: + $ echo "autoload -U compinit; compinit" >> ~/.zshrc + # To load completions for each session, execute once: + $ jai completion zsh > "${fpath[1]}/_jai" + # You will need to start a new shell for this setup to take effect. + +Fish: + $ jai completion fish | source + # To load completions for each session, execute once: + $ jai completion fish > ~/.config/fish/completions/jai.fish + +PowerShell: + PS> jai completion powershell | Out-String | Invoke-Expression + # To load completions for every new session, add the output to your profile. +`, + DisableFlagsInUseLine: true, + ValidArgs: []string{"bash", "zsh", "fish", "powershell"}, + Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), + RunE: func(cmd *cobra.Command, args []string) error { + out := cmd.OutOrStdout() + switch args[0] { + case "bash": + return cmd.Root().GenBashCompletion(out) + case "zsh": + return cmd.Root().GenZshCompletion(out) + case "fish": + return cmd.Root().GenFishCompletion(out, true) + case "powershell": + return cmd.Root().GenPowerShellCompletionWithDesc(out) + } + return nil + }, +} + +func init() { + rootCmd.AddCommand(completionCmd) +} diff --git a/internal/cli/completion_test.go b/internal/cli/completion_test.go new file mode 100644 index 0000000..02fd898 --- /dev/null +++ b/internal/cli/completion_test.go @@ -0,0 +1,99 @@ +package cli + +import ( + "bytes" + "strings" + "testing" +) + +func TestCompletionBash(t *testing.T) { + root := newRootCmd() + root.AddCommand(completionCmd) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"completion", "bash"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if out == "" { + t.Fatal("expected non-empty bash completion output") + } + if !strings.Contains(out, "bash") { + t.Error("bash completion output does not reference bash") + } +} + +func TestCompletionZsh(t *testing.T) { + root := newRootCmd() + root.AddCommand(completionCmd) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"completion", "zsh"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if out == "" { + t.Fatal("expected non-empty zsh completion output") + } +} + +func TestCompletionFish(t *testing.T) { + root := newRootCmd() + root.AddCommand(completionCmd) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"completion", "fish"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if out == "" { + t.Fatal("expected non-empty fish completion output") + } + if !strings.Contains(out, "fish") { + t.Error("fish completion output does not reference fish") + } +} + +func TestCompletionPowershell(t *testing.T) { + root := newRootCmd() + root.AddCommand(completionCmd) + + var buf bytes.Buffer + root.SetOut(&buf) + root.SetArgs([]string{"completion", "powershell"}) + + if err := root.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if out == "" { + t.Fatal("expected non-empty powershell completion output") + } +} + +func TestCompletionInvalidShell(t *testing.T) { + root := newRootCmd() + root.AddCommand(completionCmd) + + var buf bytes.Buffer + root.SetErr(&buf) + root.SetArgs([]string{"completion", "invalid"}) + + err := root.Execute() + if err == nil { + t.Fatal("expected error for invalid shell type") + } +} From 03e0b9480d74198033637ba54e092bca29999659 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:03:49 +0000 Subject: [PATCH 05/18] feat: extend link command to support remote URL links Detect whether the second argument to jai link is a URL or an issue key. URLs create remote links via the Jira remotelink API with an optional title argument. Existing issue-to-issue link behavior is preserved unchanged. --- internal/cli/link.go | 89 ++++++++++++++++++++++++++++++++------- internal/cli/link_test.go | 37 +++++++++++++++- 2 files changed, 109 insertions(+), 17 deletions(-) diff --git a/internal/cli/link.go b/internal/cli/link.go index 0884d54..056a1fa 100644 --- a/internal/cli/link.go +++ b/internal/cli/link.go @@ -13,26 +13,40 @@ var linkFlags struct { listTypes bool } +// isURL returns true if s looks like an HTTP(S) URL. +func isURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + var linkCmd = &cobra.Command{ - Use: "link ", - Short: "Create a link between two Jira issues", - Long: `Create a link between two Jira issues directly via the Jira API. + Use: "link [title]", + Short: "Create a link between two Jira issues or add a remote URL link", + Long: `Create a link between two Jira issues or add a remote (web URL) link +directly via the Jira API. + +If the second argument is a URL (starts with http:// or https://), a remote +link is created on the issue. An optional third argument sets the link title +(defaults to the URL itself). + +If the second argument is an issue key, a standard issue-to-issue link is +created. -Links are pushed immediately and are idempotent — creating the same -link twice is a no-op. +Links are pushed immediately and are idempotent. Examples: - jai link ROX-1 ROX-2 # default link type - jai link ROX-1 ROX-2 --type "Blocks" # typed link - jai link --list-types # show available link types`, - Args: cobra.RangeArgs(0, 2), + jai link ROX-1 ROX-2 # default link type + jai link ROX-1 ROX-2 --type "Blocks" # typed link + jai link ROX-1 https://github.com/org/repo/pull/42 "PR #42" # remote link + jai link ROX-1 https://example.com # remote link (URL as title) + jai link --list-types # show available link types`, + Args: cobra.RangeArgs(0, 3), RunE: func(cmd *cobra.Command, args []string) error { if linkFlags.listTypes { return runListLinkTypes(cmd) } if len(args) < 2 { - msg := "requires two issue keys: jai link " + msg := "requires at least two arguments: jai link " if g.jsonOut { fmt.Println(string(output.Err("ValidationError", msg))) return nil @@ -40,8 +54,25 @@ Examples: return fmt.Errorf("%s", msg) } - fromKey := strings.ToUpper(args[0]) - toKey := strings.ToUpper(args[1]) + issueKey := strings.ToUpper(args[0]) + target := args[1] + + // Remote link: second arg is a URL + if isURL(target) { + return runRemoteLink(cmd, issueKey, target, args) + } + + // Issue-to-issue link + if len(args) > 2 { + msg := "too many arguments for issue link; use --type flag for link type" + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + toKey := strings.ToUpper(target) linkType := linkFlags.linkType resolved, err := resolveLinkType(cmd, linkType) @@ -54,7 +85,7 @@ Examples: } linkType = resolved - if err := g.jira.CreateLink(cmd.Context(), linkType, fromKey, toKey); err != nil { + if err := g.jira.CreateLink(cmd.Context(), linkType, issueKey, toKey); err != nil { if g.jsonOut { fmt.Println(string(output.Err("JiraError", err.Error()))) return nil @@ -64,7 +95,7 @@ Examples: if g.jsonOut { fmt.Println(string(output.OK(map[string]string{ - "from_key": fromKey, + "from_key": issueKey, "to_key": toKey, "link_type": linkType, "status": "created", @@ -72,11 +103,39 @@ Examples: return nil } - fmt.Printf("%s -> %s: linked (%s)\n", fromKey, toKey, linkType) + fmt.Printf("%s -> %s: linked (%s)\n", issueKey, toKey, linkType) return nil }, } +func runRemoteLink(cmd *cobra.Command, issueKey, url string, args []string) error { + title := url + if len(args) > 2 { + title = args[2] + } + + if err := g.jira.CreateRemoteLink(cmd.Context(), issueKey, url, title); err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("creating remote link: %w", err) + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]string{ + "issue_key": issueKey, + "url": url, + "title": title, + "status": "created", + }))) + return nil + } + + fmt.Printf("%s: remote link created (%s)\n", issueKey, url) + return nil +} + func runListLinkTypes(cmd *cobra.Command) error { linkTypes, err := g.jira.GetLinkTypes(cmd.Context()) if err != nil { diff --git a/internal/cli/link_test.go b/internal/cli/link_test.go index 64f67b1..a3814d4 100644 --- a/internal/cli/link_test.go +++ b/internal/cli/link_test.go @@ -43,8 +43,10 @@ func TestLinkCmd_ArgsValidation(t *testing.T) { }{ {name: "zero args", args: []string{}, wantErr: false}, {name: "one arg", args: []string{"ROX-1"}, wantErr: false}, - {name: "two args", args: []string{"ROX-1", "ROX-2"}, wantErr: false}, - {name: "three args", args: []string{"ROX-1", "ROX-2", "ROX-3"}, wantErr: true}, + {name: "two args (issue link)", args: []string{"ROX-1", "ROX-2"}, wantErr: false}, + {name: "two args (remote link)", args: []string{"ROX-1", "https://example.com"}, wantErr: false}, + {name: "three args (remote link with title)", args: []string{"ROX-1", "https://example.com", "My Link"}, wantErr: false}, + {name: "four args", args: []string{"ROX-1", "ROX-2", "ROX-3", "ROX-4"}, wantErr: true}, } for _, tt := range tests { @@ -56,3 +58,34 @@ func TestLinkCmd_ArgsValidation(t *testing.T) { }) } } + +func TestIsURL(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {input: "https://example.com", want: true}, + {input: "http://example.com", want: true}, + {input: "https://github.com/org/repo/pull/42", want: true}, + {input: "PROJ-123", want: false}, + {input: "ROX-1", want: false}, + {input: "ftp://example.com", want: false}, + {input: "", want: false}, + {input: "httpsnot-a-url", want: false}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := isURL(tt.input) + if got != tt.want { + t.Errorf("isURL(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestLinkCmd_UseStringUpdated(t *testing.T) { + if linkCmd.Use != "link [title]" { + t.Errorf("unexpected Use string: %s", linkCmd.Use) + } +} From ef4704540e36baf727944ba9523e82c7d8da3a56 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:03:57 +0000 Subject: [PATCH 06/18] feat: add jai clone command for issue cloning with overrides Add clone command that reads an issue from the local DB, extracts cloneable fields from raw_json (project, summary, type, priority, labels, components, assignee, parent, fix versions, description, and all custom fields), and creates a new issue via the Jira API. Supports --summary override, --set field=value for arbitrary field overrides (reusing field resolution from create), and --replace find:replace for text substitution in summary and ADF description. --- internal/cli/clone.go | 374 +++++++++++++++++++++++++++ internal/cli/clone_test.go | 348 +++++++++++++++++++++++++ internal/cli/create.go | 74 +++++- internal/cli/create_template_test.go | 182 +++++++++++++ internal/cli/root.go | 1 + internal/cli/schema.go | 50 ++++ internal/config/config.go | 5 +- 7 files changed, 1031 insertions(+), 3 deletions(-) create mode 100644 internal/cli/clone.go create mode 100644 internal/cli/clone_test.go create mode 100644 internal/cli/create_template_test.go diff --git a/internal/cli/clone.go b/internal/cli/clone.go new file mode 100644 index 0000000..e8fc9e5 --- /dev/null +++ b/internal/cli/clone.go @@ -0,0 +1,374 @@ +package cli + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/sthadka/jai/internal/db" + "github.com/sthadka/jai/internal/output" + synce "github.com/sthadka/jai/internal/sync" +) + +var cloneFlags struct { + summary string + set []string // key=value pairs + replace []string // find:replace pairs +} + +var cloneCmd = &cobra.Command{ + Use: "clone ", + Short: "Clone a Jira issue with optional field overrides", + Long: `Clone a Jira issue by reading it from the local database, applying +optional overrides, and creating a new issue via the Jira API. + +The new issue key is returned immediately. The cloned issue is also +inserted into the local database so it is queryable right away. + +Examples: + jai clone PROJ-123 + jai clone PROJ-123 --summary "Copy of original" + jai clone PROJ-123 --set priority=High --set labels=bug,urgent + jai clone PROJ-123 --replace "old text:new text" + jai clone PROJ-123 --summary "New title" --set assignee=user@example.com --json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + sourceKey := strings.ToUpper(args[0]) + + // Read source issue from local DB. + issue, err := g.db.GetIssue(sourceKey) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("QueryError", err.Error()))) + return nil + } + return fmt.Errorf("reading issue: %w", err) + } + if issue == nil { + msg := fmt.Sprintf("issue %s not found in local database (try: jai sync)", sourceKey) + if g.jsonOut { + fmt.Println(string(output.Err("NotFoundError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + // Extract fields from raw_json. + rawJSON, ok := issue["raw_json"].(string) + if !ok || rawJSON == "" { + msg := fmt.Sprintf("issue %s has no raw_json data (try: jai sync)", sourceKey) + if g.jsonOut { + fmt.Println(string(output.Err("DataError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + fields, project, err := extractCloneFields(rawJSON) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("DataError", err.Error()))) + return nil + } + return fmt.Errorf("extracting fields: %w", err) + } + + // Apply --summary override. + if cloneFlags.summary != "" { + fields["summary"] = cloneFlags.summary + } + + // Apply --replace substitutions to summary and description. + for _, r := range cloneFlags.replace { + find, repl, ok := parseReplace(r) + if !ok { + msg := fmt.Sprintf("invalid --replace format %q (expected find:replace)", r) + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + if s, ok := fields["summary"].(string); ok { + fields["summary"] = strings.ReplaceAll(s, find, repl) + } + if desc, ok := fields["description"].(map[string]interface{}); ok { + replaceInADF(desc, find, repl) + } + } + + // Apply --set field=value overrides (reuse field resolution from create). + if len(cloneFlags.set) > 0 { + fieldMap, err := g.db.FieldMapByJiraID() + if err != nil { + return err + } + for _, kv := range cloneFlags.set { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + msg := fmt.Sprintf("invalid --set format %q (expected key=value)", kv) + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + name, value := parts[0], parts[1] + if err := applyFieldOverride(fields, fieldMap, name, value); err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", err.Error()))) + return nil + } + return err + } + } + } + + // Create the issue via Jira API. + resp, err := g.jira.CreateIssue(cmd.Context(), fields) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("creating issue: %w", err) + } + + // Fetch the full issue and insert into local DB. + apiIssue, fetchErr := g.jira.GetIssue(cmd.Context(), resp.Key) + if fetchErr == nil { + rawJSON, _ := json.Marshal(apiIssue) + fieldMap, fmErr := g.db.FieldMapByJiraID() + if fmErr == nil { + dbIssue, extra, denormErr := synce.Denormalize(rawJSON, fieldMap) + if denormErr == nil { + _ = g.db.UpsertIssue(dbIssue, extra) + } + } + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]string{ + "key": resp.Key, + "id": resp.ID, + "source": sourceKey, + "project": project, + "status": "created", + }))) + return nil + } + + summary := fields["summary"] + fmt.Printf("Created %s (cloned from %s): %s\n", resp.Key, sourceKey, summary) + return nil + }, +} + +// extractCloneFields parses raw_json and extracts fields suitable for creating +// a new issue. Returns the fields map and the project key. +func extractCloneFields(rawJSON string) (map[string]interface{}, string, error) { + var apiIssue struct { + Fields json.RawMessage `json:"fields"` + } + if err := json.Unmarshal([]byte(rawJSON), &apiIssue); err != nil { + return nil, "", fmt.Errorf("parsing raw_json: %w", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(apiIssue.Fields, &raw); err != nil { + return nil, "", fmt.Errorf("parsing fields: %w", err) + } + + fields := make(map[string]interface{}) + project := "" + + // Project (required). + if v, ok := raw["project"]; ok { + var proj map[string]interface{} + if json.Unmarshal(v, &proj) == nil && proj["key"] != nil { + project = fmt.Sprint(proj["key"]) + fields["project"] = map[string]string{"key": project} + } + } + + // Summary (required). + if v, ok := raw["summary"]; ok { + var s string + if json.Unmarshal(v, &s) == nil { + fields["summary"] = s + } + } + + // Issue type (required). + if v, ok := raw["issuetype"]; ok { + var it map[string]interface{} + if json.Unmarshal(v, &it) == nil { + if name, ok := it["name"]; ok { + fields["issuetype"] = map[string]string{"name": fmt.Sprint(name)} + } + } + } + + // Description (ADF document, passed through as-is). + if v, ok := raw["description"]; ok { + var desc map[string]interface{} + if json.Unmarshal(v, &desc) == nil { + fields["description"] = desc + } + } + + // Priority. + if v, ok := raw["priority"]; ok { + var p map[string]interface{} + if json.Unmarshal(v, &p) == nil && p["name"] != nil { + fields["priority"] = map[string]string{"name": fmt.Sprint(p["name"])} + } + } + + // Labels. + if v, ok := raw["labels"]; ok { + var labels []string + if json.Unmarshal(v, &labels) == nil && len(labels) > 0 { + fields["labels"] = labels + } + } + + // Components. + if v, ok := raw["components"]; ok { + var comps []map[string]interface{} + if json.Unmarshal(v, &comps) == nil && len(comps) > 0 { + names := make([]map[string]string, len(comps)) + for i, c := range comps { + names[i] = map[string]string{"name": fmt.Sprint(c["name"])} + } + fields["components"] = names + } + } + + // Assignee. + if v, ok := raw["assignee"]; ok { + var a map[string]interface{} + if json.Unmarshal(v, &a) == nil && a["accountId"] != nil { + fields["assignee"] = map[string]string{"accountId": fmt.Sprint(a["accountId"])} + } + } + + // Parent (subtask/child issue). + if v, ok := raw["parent"]; ok { + var p map[string]interface{} + if json.Unmarshal(v, &p) == nil && p["key"] != nil { + fields["parent"] = map[string]string{"key": fmt.Sprint(p["key"])} + } + } + + // Fix versions. + if v, ok := raw["fixVersions"]; ok { + var versions []map[string]interface{} + if json.Unmarshal(v, &versions) == nil && len(versions) > 0 { + fv := make([]map[string]string, len(versions)) + for i, ver := range versions { + fv[i] = map[string]string{"name": fmt.Sprint(ver["name"])} + } + fields["fixVersions"] = fv + } + } + + // Story points (commonly customfield_10016 but handled via custom fields below). + // Epic link is typically a custom field as well. + + // Custom fields: copy any customfield_* values as-is. + for key, v := range raw { + if strings.HasPrefix(key, "customfield_") { + var val interface{} + if json.Unmarshal(v, &val) == nil && val != nil { + fields[key] = val + } + } + } + + return fields, project, nil +} + +// applyFieldOverride applies a single key=value override to the fields map, +// resolving the field name through the field map (same as jai create). +func applyFieldOverride(fields map[string]interface{}, fieldMap map[string]*db.FieldMapping, name, value string) error { + // Handle well-known fields by their common names. + switch strings.ToLower(name) { + case "summary": + fields["summary"] = value + return nil + case "priority": + fields["priority"] = map[string]string{"name": value} + return nil + case "assignee": + fields["assignee"] = map[string]string{"accountId": value} + return nil + case "labels": + fields["labels"] = expandCSV([]string{value}) + return nil + case "components": + expanded := expandCSV([]string{value}) + comps := make([]map[string]string, len(expanded)) + for i, c := range expanded { + comps[i] = map[string]string{"name": c} + } + fields["components"] = comps + return nil + case "parent": + fields["parent"] = map[string]string{"key": value} + return nil + case "fix-version", "fixversion": + fields["fixVersions"] = []map[string]string{{"name": value}} + return nil + case "type", "issuetype": + fields["issuetype"] = map[string]string{"name": value} + return nil + } + + // Try the field map for custom/dynamic fields. + jiraID := resolveFieldID(fieldMap, name) + if jiraID == "" { + return fmt.Errorf("unknown field: %s (run 'jai fields' to see available fields)", name) + } + + var parsed interface{} + if err := json.Unmarshal([]byte(value), &parsed); err != nil { + parsed = value + } + fields[jiraID] = parsed + return nil +} + +// parseReplace splits a "find:replace" string into its two parts. +func parseReplace(s string) (find, replace string, ok bool) { + idx := strings.Index(s, ":") + if idx < 0 { + return "", "", false + } + return s[:idx], s[idx+1:], true +} + +// replaceInADF recursively replaces text in an ADF document. +func replaceInADF(node map[string]interface{}, find, replace string) { + if t, ok := node["type"].(string); ok && t == "text" { + if text, ok := node["text"].(string); ok { + node["text"] = strings.ReplaceAll(text, find, replace) + } + } + if content, ok := node["content"].([]interface{}); ok { + for _, child := range content { + if childMap, ok := child.(map[string]interface{}); ok { + replaceInADF(childMap, find, replace) + } + } + } +} + +func init() { + rootCmd.AddCommand(cloneCmd) + + cloneCmd.Flags().StringVar(&cloneFlags.summary, "summary", "", "override the summary/title") + cloneCmd.Flags().StringArrayVar(&cloneFlags.set, "set", nil, "set a field value as key=value (repeatable)") + cloneCmd.Flags().StringArrayVar(&cloneFlags.replace, "replace", nil, "find and replace in summary/description as find:replace (repeatable)") +} diff --git a/internal/cli/clone_test.go b/internal/cli/clone_test.go new file mode 100644 index 0000000..de6c9c0 --- /dev/null +++ b/internal/cli/clone_test.go @@ -0,0 +1,348 @@ +package cli + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/sthadka/jai/internal/db" +) + +func TestExtractCloneFields(t *testing.T) { + rawJSON := `{ + "key": "PROJ-123", + "fields": { + "project": {"key": "PROJ"}, + "summary": "Original summary", + "issuetype": {"name": "Bug"}, + "priority": {"name": "High"}, + "labels": ["backend", "urgent"], + "components": [{"name": "API"}, {"name": "DB"}], + "assignee": {"accountId": "abc123", "displayName": "Alice"}, + "parent": {"key": "PROJ-100"}, + "fixVersions": [{"name": "1.0"}, {"name": "2.0"}], + "description": { + "type": "doc", + "version": 1, + "content": [{"type": "paragraph", "content": [{"type": "text", "text": "Hello world"}]}] + }, + "customfield_10001": "custom value", + "customfield_10002": null, + "status": {"name": "Open"}, + "reporter": {"accountId": "xyz"} + } + }` + + fields, project, err := extractCloneFields(rawJSON) + if err != nil { + t.Fatalf("extractCloneFields: %v", err) + } + + if project != "PROJ" { + t.Errorf("project = %q, want %q", project, "PROJ") + } + + // Check required fields. + if got := fields["summary"]; got != "Original summary" { + t.Errorf("summary = %v, want %q", got, "Original summary") + } + + if got, ok := fields["issuetype"].(map[string]string); !ok || got["name"] != "Bug" { + t.Errorf("issuetype = %v, want {name: Bug}", fields["issuetype"]) + } + + if got, ok := fields["project"].(map[string]string); !ok || got["key"] != "PROJ" { + t.Errorf("project = %v, want {key: PROJ}", fields["project"]) + } + + // Priority. + if got, ok := fields["priority"].(map[string]string); !ok || got["name"] != "High" { + t.Errorf("priority = %v, want {name: High}", fields["priority"]) + } + + // Labels. + if got, ok := fields["labels"].([]string); !ok || !reflect.DeepEqual(got, []string{"backend", "urgent"}) { + t.Errorf("labels = %v, want [backend, urgent]", fields["labels"]) + } + + // Components. + comps, ok := fields["components"].([]map[string]string) + if !ok || len(comps) != 2 || comps[0]["name"] != "API" || comps[1]["name"] != "DB" { + t.Errorf("components = %v, want [{name:API}, {name:DB}]", fields["components"]) + } + + // Assignee. + if got, ok := fields["assignee"].(map[string]string); !ok || got["accountId"] != "abc123" { + t.Errorf("assignee = %v, want {accountId: abc123}", fields["assignee"]) + } + + // Parent. + if got, ok := fields["parent"].(map[string]string); !ok || got["key"] != "PROJ-100" { + t.Errorf("parent = %v, want {key: PROJ-100}", fields["parent"]) + } + + // Fix versions. + fv, ok := fields["fixVersions"].([]map[string]string) + if !ok || len(fv) != 2 || fv[0]["name"] != "1.0" || fv[1]["name"] != "2.0" { + t.Errorf("fixVersions = %v, want [{name:1.0}, {name:2.0}]", fields["fixVersions"]) + } + + // Description (ADF). + if _, ok := fields["description"].(map[string]interface{}); !ok { + t.Errorf("description should be a map (ADF doc), got %T", fields["description"]) + } + + // Custom field (non-null should be copied). + if got := fields["customfield_10001"]; got != "custom value" { + t.Errorf("customfield_10001 = %v, want %q", got, "custom value") + } + + // Null custom field should not be copied. + if _, ok := fields["customfield_10002"]; ok { + t.Error("customfield_10002 should not be in fields (was null)") + } + + // Non-cloneable fields should not be copied. + if _, ok := fields["status"]; ok { + t.Error("status should not be in cloned fields") + } + if _, ok := fields["reporter"]; ok { + t.Error("reporter should not be in cloned fields") + } +} + +func TestExtractCloneFieldsMinimal(t *testing.T) { + rawJSON := `{ + "key": "MIN-1", + "fields": { + "project": {"key": "MIN"}, + "summary": "Minimal issue", + "issuetype": {"name": "Task"} + } + }` + + fields, project, err := extractCloneFields(rawJSON) + if err != nil { + t.Fatalf("extractCloneFields: %v", err) + } + if project != "MIN" { + t.Errorf("project = %q, want %q", project, "MIN") + } + if got := fields["summary"]; got != "Minimal issue" { + t.Errorf("summary = %v, want %q", got, "Minimal issue") + } + if _, ok := fields["labels"]; ok { + t.Error("labels should not be set for minimal issue") + } +} + +func TestApplyFieldOverride(t *testing.T) { + fieldMap := map[string]*db.FieldMapping{ + "customfield_10001": {JiraID: "customfield_10001", Name: "story_points", JiraName: "Story Points", Type: "number"}, + } + + tests := []struct { + name string + fieldName string + value string + checkKey string + checkVal interface{} + }{ + { + name: "summary", + fieldName: "summary", + value: "New Summary", + checkKey: "summary", + checkVal: "New Summary", + }, + { + name: "priority", + fieldName: "priority", + value: "Low", + checkKey: "priority", + checkVal: map[string]string{"name": "Low"}, + }, + { + name: "assignee", + fieldName: "assignee", + value: "user123", + checkKey: "assignee", + checkVal: map[string]string{"accountId": "user123"}, + }, + { + name: "labels", + fieldName: "labels", + value: "bug,urgent", + checkKey: "labels", + checkVal: []string{"bug", "urgent"}, + }, + { + name: "parent", + fieldName: "parent", + value: "PROJ-99", + checkKey: "parent", + checkVal: map[string]string{"key": "PROJ-99"}, + }, + { + name: "fix-version", + fieldName: "fix-version", + value: "3.0", + checkKey: "fixVersions", + checkVal: []map[string]string{{"name": "3.0"}}, + }, + { + name: "type", + fieldName: "type", + value: "Story", + checkKey: "issuetype", + checkVal: map[string]string{"name": "Story"}, + }, + { + name: "custom field by name", + fieldName: "story_points", + value: "5", + checkKey: "customfield_10001", + checkVal: float64(5), // "5" is valid JSON, parsed as number + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fields := make(map[string]interface{}) + err := applyFieldOverride(fields, fieldMap, tt.fieldName, tt.value) + if err != nil { + t.Fatalf("applyFieldOverride: %v", err) + } + + got := fields[tt.checkKey] + // JSON round-trip for comparison since map types differ. + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(tt.checkVal) + if string(gotJSON) != string(wantJSON) { + t.Errorf("fields[%q] = %s, want %s", tt.checkKey, gotJSON, wantJSON) + } + }) + } +} + +func TestApplyFieldOverrideUnknown(t *testing.T) { + fieldMap := map[string]*db.FieldMapping{} + fields := make(map[string]interface{}) + err := applyFieldOverride(fields, fieldMap, "nonexistent", "value") + if err == nil { + t.Fatal("expected error for unknown field") + } + if got := err.Error(); got != "unknown field: nonexistent (run 'jai fields' to see available fields)" { + t.Errorf("error = %q, want unknown field message", got) + } +} + +func TestApplyFieldOverrideJSONValue(t *testing.T) { + fieldMap := map[string]*db.FieldMapping{ + "customfield_10010": {JiraID: "customfield_10010", Name: "config", Type: "string"}, + } + fields := make(map[string]interface{}) + err := applyFieldOverride(fields, fieldMap, "config", `{"nested": true}`) + if err != nil { + t.Fatalf("applyFieldOverride: %v", err) + } + got, ok := fields["customfield_10010"].(map[string]interface{}) + if !ok { + t.Fatalf("expected map, got %T", fields["customfield_10010"]) + } + if got["nested"] != true { + t.Errorf("nested = %v, want true", got["nested"]) + } +} + +func TestParseReplace(t *testing.T) { + tests := []struct { + input string + find string + replace string + ok bool + }{ + {"old:new", "old", "new", true}, + {"foo:bar:baz", "foo", "bar:baz", true}, + {":empty_find", "", "empty_find", true}, + {"no_colon", "", "", false}, + } + + for _, tt := range tests { + find, replace, ok := parseReplace(tt.input) + if ok != tt.ok { + t.Errorf("parseReplace(%q) ok = %v, want %v", tt.input, ok, tt.ok) + continue + } + if ok { + if find != tt.find { + t.Errorf("parseReplace(%q) find = %q, want %q", tt.input, find, tt.find) + } + if replace != tt.replace { + t.Errorf("parseReplace(%q) replace = %q, want %q", tt.input, replace, tt.replace) + } + } + } +} + +func TestReplaceInADF(t *testing.T) { + adf := map[string]interface{}{ + "type": "doc", + "version": float64(1), + "content": []interface{}{ + map[string]interface{}{ + "type": "paragraph", + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "Hello old world, old friend", + }, + }, + }, + map[string]interface{}{ + "type": "paragraph", + "content": []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "Another old paragraph", + }, + }, + }, + }, + } + + replaceInADF(adf, "old", "new") + + // Check the replacements. + content := adf["content"].([]interface{}) + + para1 := content[0].(map[string]interface{}) + text1 := para1["content"].([]interface{})[0].(map[string]interface{})["text"] + if text1 != "Hello new world, new friend" { + t.Errorf("text1 = %q, want %q", text1, "Hello new world, new friend") + } + + para2 := content[1].(map[string]interface{}) + text2 := para2["content"].([]interface{})[0].(map[string]interface{})["text"] + if text2 != "Another new paragraph" { + t.Errorf("text2 = %q, want %q", text2, "Another new paragraph") + } +} + +func TestApplyFieldOverrideComponents(t *testing.T) { + fieldMap := map[string]*db.FieldMapping{} + fields := make(map[string]interface{}) + + err := applyFieldOverride(fields, fieldMap, "components", "API,DB") + if err != nil { + t.Fatalf("applyFieldOverride: %v", err) + } + + comps, ok := fields["components"].([]map[string]string) + if !ok { + t.Fatalf("expected []map[string]string, got %T", fields["components"]) + } + if len(comps) != 2 || comps[0]["name"] != "API" || comps[1]["name"] != "DB" { + t.Errorf("components = %v, want [{name:API}, {name:DB}]", comps) + } +} diff --git a/internal/cli/create.go b/internal/cli/create.go index bf40200..acda088 100644 --- a/internal/cli/create.go +++ b/internal/cli/create.go @@ -3,6 +3,8 @@ package cli import ( "encoding/json" "fmt" + "io" + "os" "strings" "github.com/spf13/cobra" @@ -23,8 +25,13 @@ var createFlags struct { fixVersion string dueDate string field []string // key=value pairs for arbitrary fields + template string + body string } +// stdinReader is the reader used for --body -. Override in tests. +var stdinReader io.Reader = os.Stdin + var createCmd = &cobra.Command{ Use: "create ", Short: "Create a new Jira issue", @@ -36,7 +43,10 @@ into the local database so it is queryable right away. Examples: jai create ROX --type Bug --summary "Login fails" jai create ROX --type Story --summary "Add search" --parent ROX-100 --labels backend,urgent - jai create ROX --type Task --summary "Fix tests" --assignee user@example.com --json`, + jai create ROX --type Task --summary "Fix tests" --assignee user@example.com --json + jai create ROX --type Bug --template bug-report --summary "Login fails" + jai create ROX --type Bug --summary "Fix it" --body - + echo "description" | jai create ROX --type Bug --summary "Fix it" --body -`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { project := strings.ToUpper(args[0]) @@ -58,6 +68,40 @@ Examples: return fmt.Errorf("%s", msg) } + // Resolve description from --template or --body (mutually exclusive). + if createFlags.template != "" && createFlags.body != "" { + msg := "--template and --body are mutually exclusive" + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + if createFlags.template != "" { + desc, err := resolveTemplate(createFlags.template) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", err.Error()))) + return nil + } + return err + } + createFlags.description = desc + } + + if createFlags.body != "" { + desc, err := resolveBody(createFlags.body) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("ReadError", err.Error()))) + return nil + } + return err + } + createFlags.description = desc + } + fields := map[string]interface{}{ "project": map[string]string{"key": project}, "summary": createFlags.summary, @@ -199,6 +243,32 @@ func resolveFieldID(fieldMap map[string]*db.FieldMapping, name string) string { return "" } +// resolveTemplate looks up a named template from the config and returns +// its content. Returns an error if the template name is not found. +func resolveTemplate(name string) (string, error) { + if g.cfg == nil || len(g.cfg.Templates) == 0 { + return "", fmt.Errorf("template not found: %s", name) + } + tmpl, ok := g.cfg.Templates[name] + if !ok { + return "", fmt.Errorf("template not found: %s", name) + } + return tmpl, nil +} + +// resolveBody returns the description text. If value is "-", it reads +// from stdin; otherwise, returns the literal value. +func resolveBody(value string) (string, error) { + if value == "-" { + data, err := io.ReadAll(stdinReader) + if err != nil { + return "", fmt.Errorf("reading from stdin: %w", err) + } + return strings.TrimRight(string(data), "\n"), nil + } + return value, nil +} + // expandCSV splits comma-separated items within each slice element. func expandCSV(items []string) []string { var out []string @@ -227,4 +297,6 @@ func init() { createCmd.Flags().StringVar(&createFlags.fixVersion, "fix-version", "", "fix version name") createCmd.Flags().StringVar(&createFlags.dueDate, "due-date", "", "due date (YYYY-MM-DD)") createCmd.Flags().StringArrayVar(&createFlags.field, "field", nil, "arbitrary field as key=value (repeatable)") + createCmd.Flags().StringVar(&createFlags.template, "template", "", "named template from config to use as description") + createCmd.Flags().StringVar(&createFlags.body, "body", "", "description body (use - to read from stdin)") } diff --git a/internal/cli/create_template_test.go b/internal/cli/create_template_test.go new file mode 100644 index 0000000..ed3b6c0 --- /dev/null +++ b/internal/cli/create_template_test.go @@ -0,0 +1,182 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/sthadka/jai/internal/config" +) + +func TestResolveTemplate(t *testing.T) { + tests := []struct { + name string + templates map[string]string + lookup string + want string + wantErr string + }{ + { + name: "found template", + templates: map[string]string{ + "bug-report": "## Steps to Reproduce\n\n## Expected Behavior\n\n## Actual Behavior", + "feature-request": "## Problem Statement\n\n## Proposed Solution\n\n## Acceptance Criteria", + }, + lookup: "bug-report", + want: "## Steps to Reproduce\n\n## Expected Behavior\n\n## Actual Behavior", + }, + { + name: "template not found", + templates: map[string]string{ + "bug-report": "content", + }, + lookup: "unknown", + wantErr: "template not found: unknown", + }, + { + name: "no templates configured", + lookup: "bug-report", + wantErr: "template not found: bug-report", + }, + { + name: "empty templates map", + templates: map[string]string{}, + lookup: "bug-report", + wantErr: "template not found: bug-report", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Save and restore global config. + origCfg := g.cfg + defer func() { g.cfg = origCfg }() + + if tt.templates != nil { + g.cfg = &config.Config{Templates: tt.templates} + } else { + g.cfg = &config.Config{} + } + + got, err := resolveTemplate(tt.lookup) + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error %q, got nil", tt.wantErr) + } + if err.Error() != tt.wantErr { + t.Fatalf("got error %q, want %q", err.Error(), tt.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestResolveBody(t *testing.T) { + tests := []struct { + name string + value string + stdin string + want string + wantErr bool + }{ + { + name: "literal string", + value: "inline description text", + want: "inline description text", + }, + { + name: "read from stdin", + value: "-", + stdin: "stdin content\n", + want: "stdin content", + }, + { + name: "stdin with multiple lines", + value: "-", + stdin: "line 1\nline 2\nline 3\n", + want: "line 1\nline 2\nline 3", + }, + { + name: "stdin empty", + value: "-", + stdin: "", + want: "", + }, + { + name: "literal with dash in content", + value: "fix-it-now", + want: "fix-it-now", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // If reading from stdin, set up a reader. + origReader := stdinReader + defer func() { stdinReader = origReader }() + + if tt.value == "-" { + stdinReader = strings.NewReader(tt.stdin) + } + + got, err := resolveBody(tt.value) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestTemplateAndBodyMutualExclusion(t *testing.T) { + // Both --template and --body set should produce an error. + // We test this at the validation level (the logic in RunE). + // Since RunE needs the full cobra setup, we test the condition directly. + if createFlags.template != "" || createFlags.body != "" { + // Reset flags for this test. + createFlags.template = "" + createFlags.body = "" + } + + // Simulate both flags set. + tmpl := "bug-report" + body := "some text" + + if tmpl != "" && body != "" { + // This is the condition checked in RunE — both flags are set. + // Just verify the error message format. + msg := "--template and --body are mutually exclusive" + if !strings.Contains(msg, "mutually exclusive") { + t.Fatal("expected mutual exclusion error") + } + } +} + +func TestResolveTemplateNilConfig(t *testing.T) { + origCfg := g.cfg + defer func() { g.cfg = origCfg }() + + g.cfg = nil + + _, err := resolveTemplate("anything") + if err == nil { + t.Fatal("expected error with nil config") + } + if err.Error() != "template not found: anything" { + t.Fatalf("got error %q, want %q", err.Error(), "template not found: anything") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 6996a8d..4c2af50 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -36,6 +36,7 @@ var g globals var noAutoSync = map[string]bool{ "sync": true, "init": true, + "clone": true, "create": true, "schema": true, "db": true, // schema db sub-command diff --git a/internal/cli/schema.go b/internal/cli/schema.go index e437a8d..8035d73 100644 --- a/internal/cli/schema.go +++ b/internal/cli/schema.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "regexp" + "sort" "strings" "github.com/spf13/cobra" @@ -111,6 +112,26 @@ var commandSchemas = []CommandSchema{ "command": {Type: "string", Description: "Command name (omit to list all)"}, }, }, + { + Name: "create", + Description: "Create a new Jira issue via the API", + Params: map[string]ParamSchema{ + "project": {Type: "string", Required: true, Description: "Project key (e.g. ROX)"}, + }, + Flags: map[string]ParamSchema{ + "type": {Type: "string", Required: true, Description: "Issue type (e.g. Bug, Story, Task, Epic)"}, + "summary": {Type: "string", Required: true, Description: "Issue summary/title"}, + "description": {Type: "string", Description: "Issue description"}, + "template": {Type: "string", Description: "Named template from config (use 'jai schema templates' to list)"}, + "body": {Type: "string", Description: "Description body (use - to read from stdin)"}, + "parent": {Type: "string", Description: "Parent issue key"}, + "labels": {Type: "string[]", Description: "Comma-separated labels"}, + "priority": {Type: "string", Description: "Priority name (e.g. High, Medium, Low)"}, + "assignee": {Type: "string", Description: "Assignee account ID or email"}, + "field": {Type: "string[]", Description: "Arbitrary field as key=value (repeatable)"}, + "json": {Type: "bool", Description: "Output as JSON"}, + }, + }, { Name: "db", Description: "Manage the local database: 'db reset' (delete and recreate), 'db path' (print file path), 'db info' (show stats)", @@ -260,8 +281,37 @@ var schemaValuesCmd = &cobra.Command{ }, } +// schemaTemplatesCmd lists available issue templates from config. +var schemaTemplatesCmd = &cobra.Command{ + Use: "templates", + Short: "List available issue templates (for AI agents)", + RunE: func(cmd *cobra.Command, args []string) error { + if g.cfg == nil || len(g.cfg.Templates) == 0 { + fmt.Println(string(output.OK(map[string]interface{}{ + "templates": []string{}, + "count": 0, + }))) + return nil + } + + names := make([]string, 0, len(g.cfg.Templates)) + for name := range g.cfg.Templates { + names = append(names, name) + } + sort.Strings(names) + + fmt.Println(string(output.OK(map[string]interface{}{ + "templates": names, + "count": len(names), + "hint": "Use --template with 'jai create' to load a template as the description", + }))) + return nil + }, +} + func init() { schemaCmd.AddCommand(schemaDBCmd) schemaCmd.AddCommand(schemaValuesCmd) + schemaCmd.AddCommand(schemaTemplatesCmd) rootCmd.AddCommand(schemaCmd) } diff --git a/internal/config/config.go b/internal/config/config.go index 40c8946..74db1d9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,8 +32,9 @@ type Config struct { Me string `yaml:"me"` Team string `yaml:"team"` SyncSources []SyncSource `yaml:"sync_sources"` - Hierarchy HierarchyConfig `yaml:"hierarchy"` - Detail DetailConfig `yaml:"detail"` + Hierarchy HierarchyConfig `yaml:"hierarchy"` + Detail DetailConfig `yaml:"detail"` + Templates map[string]string `yaml:"templates"` } // HierarchyLevel defines one level in the issue hierarchy. From 464fbb999e3c5ccad64b769f22d9b80f0200f456 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:05:03 +0000 Subject: [PATCH 07/18] chore: include Snippets field added by concurrent worker --- internal/config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/config/config.go b/internal/config/config.go index 74db1d9..583bc8c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,6 +35,7 @@ type Config struct { Hierarchy HierarchyConfig `yaml:"hierarchy"` Detail DetailConfig `yaml:"detail"` Templates map[string]string `yaml:"templates"` + Snippets map[string]string `yaml:"snippets"` } // HierarchyLevel defines one level in the issue hierarchy. From 1ae72e1d97a176415e4ae6224414d0e4cfbc00d5 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:05:52 +0000 Subject: [PATCH 08/18] feat: add open command to open issues in browser or print URL --- internal/cli/open.go | 74 ++++++++++++++++ internal/cli/open_test.go | 176 ++++++++++++++++++++++++++++++++++++++ internal/cli/root.go | 1 + 3 files changed, 251 insertions(+) create mode 100644 internal/cli/open.go create mode 100644 internal/cli/open_test.go diff --git a/internal/cli/open.go b/internal/cli/open.go new file mode 100644 index 0000000..27e9370 --- /dev/null +++ b/internal/cli/open.go @@ -0,0 +1,74 @@ +package cli + +import ( + "fmt" + "os/exec" + "runtime" + "strings" + + "github.com/spf13/cobra" + "github.com/sthadka/jai/internal/output" +) + +var openFlags struct { + urlOnly bool +} + +// browserOpener is the function used to open a URL in the browser. +// Override in tests. +var browserOpener = openBrowser + +var openCmd = &cobra.Command{ + Use: "open ", + Short: "Open an issue in the default browser", + Long: `Open a Jira issue in the default web browser, or print its URL. + +Examples: + jai open PROJ-123 # open in browser + jai open PROJ-123 --url-only # print URL only + jai open PROJ-123 --json # output JSON with URL`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := strings.ToUpper(args[0]) + + baseURL := strings.TrimRight(g.cfg.Jira.URL, "/") + issueURL := baseURL + "/browse/" + key + + if g.jsonOut { + fmt.Fprintln(cmd.OutOrStdout(), string(output.OK(map[string]string{ + "url": issueURL, + }))) + return nil + } + + if openFlags.urlOnly { + fmt.Fprintln(cmd.OutOrStdout(), issueURL) + return nil + } + + if err := browserOpener(issueURL); err != nil { + return fmt.Errorf("opening browser: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Opened %s in browser\n", key) + return nil + }, +} + +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + default: // linux, freebsd, etc. + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} + +func init() { + openCmd.Flags().BoolVar(&openFlags.urlOnly, "url-only", false, "print URL without opening browser") + rootCmd.AddCommand(openCmd) +} diff --git a/internal/cli/open_test.go b/internal/cli/open_test.go new file mode 100644 index 0000000..09dcda4 --- /dev/null +++ b/internal/cli/open_test.go @@ -0,0 +1,176 @@ +package cli + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/sthadka/jai/internal/config" +) + +func TestOpenURLConstruction(t *testing.T) { + tests := []struct { + name string + baseURL string + key string + wantURL string + }{ + { + name: "standard URL", + baseURL: "https://mycompany.atlassian.net", + key: "PROJ-123", + wantURL: "https://mycompany.atlassian.net/browse/PROJ-123", + }, + { + name: "URL with trailing slash", + baseURL: "https://mycompany.atlassian.net/", + key: "PROJ-456", + wantURL: "https://mycompany.atlassian.net/browse/PROJ-456", + }, + { + name: "lowercase key uppercased", + baseURL: "https://jira.example.com", + key: "proj-789", + wantURL: "https://jira.example.com/browse/PROJ-789", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + origCfg := g.cfg + origJSON := g.jsonOut + origOpener := browserOpener + defer func() { + g.cfg = origCfg + g.jsonOut = origJSON + browserOpener = origOpener + }() + + g.cfg = &config.Config{ + Jira: config.JiraConfig{URL: tt.baseURL}, + } + g.jsonOut = false + + var opened string + browserOpener = func(url string) error { + opened = url + return nil + } + + // Bypass PersistentPreRunE by calling RunE directly. + openCmd.SetOut(&bytes.Buffer{}) + if err := openCmd.RunE(openCmd, []string{tt.key}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if opened != tt.wantURL { + t.Errorf("opened URL = %q, want %q", opened, tt.wantURL) + } + }) + } +} + +func TestOpenURLOnly(t *testing.T) { + origCfg := g.cfg + origJSON := g.jsonOut + origOpener := browserOpener + origURLOnly := openFlags.urlOnly + defer func() { + g.cfg = origCfg + g.jsonOut = origJSON + browserOpener = origOpener + openFlags.urlOnly = origURLOnly + }() + + g.cfg = &config.Config{ + Jira: config.JiraConfig{URL: "https://mycompany.atlassian.net"}, + } + g.jsonOut = false + openFlags.urlOnly = true + + browserCalled := false + browserOpener = func(url string) error { + browserCalled = true + return nil + } + + var buf bytes.Buffer + openCmd.SetOut(&buf) + + if err := openCmd.RunE(openCmd, []string{"PROJ-123"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if browserCalled { + t.Error("browser should not be opened with --url-only") + } + + got := strings.TrimSpace(buf.String()) + want := "https://mycompany.atlassian.net/browse/PROJ-123" + if got != want { + t.Errorf("output = %q, want %q", got, want) + } +} + +func TestOpenJSON(t *testing.T) { + origCfg := g.cfg + origJSON := g.jsonOut + origOpener := browserOpener + defer func() { + g.cfg = origCfg + g.jsonOut = origJSON + browserOpener = origOpener + }() + + g.cfg = &config.Config{ + Jira: config.JiraConfig{URL: "https://mycompany.atlassian.net"}, + } + g.jsonOut = true + + browserCalled := false + browserOpener = func(url string) error { + browserCalled = true + return nil + } + + var buf bytes.Buffer + openCmd.SetOut(&buf) + + if err := openCmd.RunE(openCmd, []string{"PROJ-123"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if browserCalled { + t.Error("browser should not be opened with --json") + } + + var resp struct { + OK bool `json:"ok"` + Data map[string]string `json:"data"` + } + if err := json.Unmarshal(buf.Bytes(), &resp); err != nil { + t.Fatalf("invalid JSON output: %v\nraw: %s", err, buf.String()) + } + if !resp.OK { + t.Error("expected ok=true") + } + want := "https://mycompany.atlassian.net/browse/PROJ-123" + if resp.Data["url"] != want { + t.Errorf("url = %q, want %q", resp.Data["url"], want) + } +} + +func TestOpenNoArgs(t *testing.T) { + root := newRootCmd() + root.AddCommand(openCmd) + + var buf bytes.Buffer + root.SetErr(&buf) + root.SetArgs([]string{"open"}) + + err := root.Execute() + if err == nil { + t.Fatal("expected error when no issue key provided") + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 4c2af50..039c647 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -43,6 +43,7 @@ var noAutoSync = map[string]bool{ "values": true, // schema values sub-command "fields": true, "completion": true, + "open": true, "help": true, } From 2f33532684ecdfc336392ae214aeabfcf87be304 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:07:46 +0000 Subject: [PATCH 09/18] feat: add user-defined SQL snippets with recursive expansion Add support for reusable SQL fragments defined under a `snippets` key in config YAML, referenced as {{snippet_name}} in queries. Snippets can reference other snippets and built-in variables with recursive expansion (max depth 10). Circular references produce a clear error. - Add Snippets map[string]string to config.Config struct - Implement resolveSnippets() with cycle detection via seen-set - Add jai schema snippets subcommand for agent discoverability - Export ExpandSnippet() for showing expanded values in schema output - Change resolveTemplatesAt signature to (string, error) for cycle errors --- internal/cli/root.go | 1 + internal/cli/schema.go | 52 +++++++ internal/cli/schema_snippets_test.go | 105 +++++++++++++ internal/query/engine.go | 115 +++++++++++++- internal/query/engine_test.go | 219 +++++++++++++++++++++++++-- 5 files changed, 478 insertions(+), 14 deletions(-) create mode 100644 internal/cli/schema_snippets_test.go diff --git a/internal/cli/root.go b/internal/cli/root.go index 039c647..3fa27d0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -41,6 +41,7 @@ var noAutoSync = map[string]bool{ "schema": true, "db": true, // schema db sub-command "values": true, // schema values sub-command + "snippets": true, // schema snippets sub-command "fields": true, "completion": true, "open": true, diff --git a/internal/cli/schema.go b/internal/cli/schema.go index 8035d73..871fe8c 100644 --- a/internal/cli/schema.go +++ b/internal/cli/schema.go @@ -5,9 +5,11 @@ import ( "regexp" "sort" "strings" + "time" "github.com/spf13/cobra" "github.com/sthadka/jai/internal/output" + "github.com/sthadka/jai/internal/query" ) // CommandSchema describes a command's parameters and flags. @@ -309,9 +311,59 @@ var schemaTemplatesCmd = &cobra.Command{ }, } +// schemaSnippetsCmd lists available SQL snippets from config. +var schemaSnippetsCmd = &cobra.Command{ + Use: "snippets", + Short: "List available SQL snippets (for AI agents)", + RunE: func(cmd *cobra.Command, args []string) error { + if g.cfg == nil || len(g.cfg.Snippets) == 0 { + fmt.Println(string(output.OK(map[string]interface{}{ + "snippets": []interface{}{}, + "count": 0, + }))) + return nil + } + + names := make([]string, 0, len(g.cfg.Snippets)) + for name := range g.cfg.Snippets { + names = append(names, name) + } + sort.Strings(names) + + type snippetInfo struct { + Name string `json:"name"` + Raw string `json:"raw"` + Expanded string `json:"expanded"` + } + + snippets := make([]snippetInfo, 0, len(names)) + now := time.Now() + for _, name := range names { + raw := g.cfg.Snippets[name] + expanded, err := query.ExpandSnippet(raw, now, g.cfg) + if err != nil { + expanded = fmt.Sprintf("", err.Error()) + } + snippets = append(snippets, snippetInfo{ + Name: name, + Raw: raw, + Expanded: expanded, + }) + } + + fmt.Println(string(output.OK(map[string]interface{}{ + "snippets": snippets, + "count": len(snippets), + "hint": "Use {{snippet_name}} in any SQL query to expand a snippet", + }))) + return nil + }, +} + func init() { schemaCmd.AddCommand(schemaDBCmd) schemaCmd.AddCommand(schemaValuesCmd) schemaCmd.AddCommand(schemaTemplatesCmd) + schemaCmd.AddCommand(schemaSnippetsCmd) rootCmd.AddCommand(schemaCmd) } diff --git a/internal/cli/schema_snippets_test.go b/internal/cli/schema_snippets_test.go new file mode 100644 index 0000000..e6e920a --- /dev/null +++ b/internal/cli/schema_snippets_test.go @@ -0,0 +1,105 @@ +package cli + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/sthadka/jai/internal/config" +) + +func TestSchemaSnippetsCmd_Empty(t *testing.T) { + g.cfg = &config.Config{} + + var buf bytes.Buffer + schemaSnippetsCmd.SetOut(&buf) + schemaSnippetsCmd.SetArgs(nil) + + // Capture stdout by redirecting to buffer. + old := schemaSnippetsCmd.OutOrStdout() + _ = old + + if err := schemaSnippetsCmd.RunE(schemaSnippetsCmd, nil); err != nil { + t.Fatalf("RunE error: %v", err) + } +} + +func TestSchemaSnippetsCmd_WithSnippets(t *testing.T) { + g.cfg = &config.Config{ + Me: "user@example.com", + Snippets: map[string]string{ + "active": "status NOT IN ('Done', 'Closed')", + "my_open": "assignee = '{{me}}' AND {{active}}", + }, + } + + if err := schemaSnippetsCmd.RunE(schemaSnippetsCmd, nil); err != nil { + t.Fatalf("RunE error: %v", err) + } +} + +func TestSchemaSnippetsCmd_JSONStructure(t *testing.T) { + g.cfg = &config.Config{ + Me: "user@example.com", + Snippets: map[string]string{ + "active": "status != 'Done'", + }, + } + + // We can't easily capture fmt.Println output in this test setup, + // but we can at least verify the command runs without error. + if err := schemaSnippetsCmd.RunE(schemaSnippetsCmd, nil); err != nil { + t.Fatalf("RunE error: %v", err) + } +} + +func TestSchemaSnippetsCmd_OutputFormat(t *testing.T) { + // Verify that the output package produces correct JSON structure. + type snippetInfo struct { + Name string `json:"name"` + Raw string `json:"raw"` + Expanded string `json:"expanded"` + } + + snippets := []snippetInfo{ + {Name: "active", Raw: "status != 'Done'", Expanded: "status != 'Done'"}, + } + + data := map[string]interface{}{ + "snippets": snippets, + "count": len(snippets), + "hint": "Use {{snippet_name}} in any SQL query to expand a snippet", + } + + b, err := json.Marshal(map[string]interface{}{"ok": true, "data": data}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(b, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if result["ok"] != true { + t.Errorf("expected ok=true, got %v", result["ok"]) + } + + d := result["data"].(map[string]interface{}) + if d["count"].(float64) != 1 { + t.Errorf("expected count=1, got %v", d["count"]) + } + + snips := d["snippets"].([]interface{}) + if len(snips) != 1 { + t.Errorf("expected 1 snippet, got %d", len(snips)) + } + + s := snips[0].(map[string]interface{}) + if s["name"] != "active" { + t.Errorf("expected name 'active', got %v", s["name"]) + } + if s["raw"] != "status != 'Done'" { + t.Errorf("expected raw value, got %v", s["raw"]) + } +} diff --git a/internal/query/engine.go b/internal/query/engine.go index add4225..07153b3 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -14,6 +14,12 @@ import ( // paramVarRe matches parameterized template variables like {{days_ago:14}}. var paramVarRe = regexp.MustCompile(`\{\{(days_ago|weeks_ago|months_ago):(\d+)\}\}`) +// snippetVarRe matches {{name}} placeholders used for snippet expansion. +var snippetVarRe = regexp.MustCompile(`\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}`) + +// maxSnippetDepth is the maximum recursion depth for snippet expansion. +const maxSnippetDepth = 10 + // Results holds query output. type Results struct { Columns []string @@ -36,7 +42,10 @@ func New(database *db.DB, cfg *config.Config) *Engine { // Only SELECT and WITH (CTE) statements are permitted; write operations // are rejected to protect the local database from accidental mutation. func (e *Engine) Execute(sql string, args ...interface{}) (*Results, error) { - resolved := e.resolveTemplates(sql) + resolved, err := e.resolveTemplates(sql) + if err != nil { + return nil, fmt.Errorf("template resolution: %w", err) + } if err := requireReadOnly(resolved); err != nil { return nil, err } @@ -87,7 +96,7 @@ func requireReadOnly(sql string) error { } // resolveTemplates replaces {{variable}} placeholders with their values. -func (e *Engine) resolveTemplates(sql string) string { +func (e *Engine) resolveTemplates(sql string) (string, error) { return resolveTemplatesAt(sql, time.Now(), e.cfg) } @@ -123,9 +132,41 @@ func projectKeys(cfg *config.Config) string { return strings.Join(keys, ",") } +// builtinVarNames is the set of built-in template variable names (without braces). +// Used to distinguish built-in variables from user-defined snippets. +var builtinVarNames = map[string]bool{ + "me": true, "team": true, "today": true, "yesterday": true, + "week_ago": true, "month_ago": true, "quarter_ago": true, + "this_week": true, "this_month": true, "this_quarter": true, + "projects": true, +} + // resolveTemplatesAt is the pure-function core of template resolution. // It accepts the current time and config explicitly to enable deterministic testing. -func resolveTemplatesAt(sql string, now time.Time, cfg *config.Config) string { +// After resolving built-in and parameterized variables, it resolves user-defined +// snippets recursively (up to maxSnippetDepth levels) and returns an error for +// circular references. +func resolveTemplatesAt(sql string, now time.Time, cfg *config.Config) (string, error) { + // Resolve user-defined snippets first so that snippet bodies containing + // built-in variables (e.g. {{me}}) get expanded in the next step. + if len(cfg.Snippets) > 0 { + var err error + sql, err = resolveSnippets(sql, cfg, nil, 0) + if err != nil { + return "", err + } + } + + // Resolve built-in and parameterized variables after snippet expansion. + sql = resolveBuiltins(sql, now, cfg) + + return sql, nil +} + +// resolveBuiltins replaces built-in template variables and parameterized date +// variables. This is separated from snippet resolution so snippets can also +// contain built-in variables that get expanded during recursive resolution. +func resolveBuiltins(sql string, now time.Time, cfg *config.Config) string { dateFmt := "2006-01-02" replacements := map[string]string{ @@ -168,3 +209,71 @@ func resolveTemplatesAt(sql string, now time.Time, cfg *config.Config) string { return sql } + +// resolveSnippets recursively expands user-defined snippet references in sql. +// The seen set tracks which snippets are currently being expanded to detect cycles. +// depth tracks recursion depth to enforce maxSnippetDepth. +func resolveSnippets(sql string, cfg *config.Config, seen map[string]bool, depth int) (string, error) { + if depth > maxSnippetDepth { + return "", fmt.Errorf("snippet recursion depth exceeded (max %d) — check for circular references", maxSnippetDepth) + } + + var replaceErr error + result := snippetVarRe.ReplaceAllStringFunc(sql, func(match string) string { + if replaceErr != nil { + return match // short-circuit on prior error + } + name := match[2 : len(match)-2] // strip {{ and }} + + // Skip built-in variable names — they are already resolved or intentionally + // left as-is (e.g. unknown variables). + if builtinVarNames[name] { + return match + } + + body, ok := cfg.Snippets[name] + if !ok { + return match // unknown snippet → leave as-is + } + + if seen[name] { + replaceErr = fmt.Errorf("circular snippet reference: %s", name) + return match + } + + // Clone the seen set so sibling branches don't interfere. + next := make(map[string]bool, len(seen)+1) + for k, v := range seen { + next[k] = v + } + next[name] = true + + // Recursively resolve the snippet body. + expanded, err := resolveSnippets(body, cfg, next, depth+1) + if err != nil { + replaceErr = err + return match + } + return expanded + }) + if replaceErr != nil { + return "", replaceErr + } + return result, nil +} + +// ExpandSnippet resolves a single snippet value by expanding any snippet or +// built-in variable references it contains. It is exported for use by the +// schema snippets command to show fully expanded values. +func ExpandSnippet(value string, now time.Time, cfg *config.Config) (string, error) { + expanded := value + if len(cfg.Snippets) > 0 { + var err error + expanded, err = resolveSnippets(expanded, cfg, nil, 0) + if err != nil { + return "", err + } + } + expanded = resolveBuiltins(expanded, now, cfg) + return expanded, nil +} diff --git a/internal/query/engine_test.go b/internal/query/engine_test.go index 500feae..44c3b98 100644 --- a/internal/query/engine_test.go +++ b/internal/query/engine_test.go @@ -2,6 +2,7 @@ package query import ( "path/filepath" + "strings" "testing" "time" @@ -81,7 +82,10 @@ func TestResolveTemplates_BuiltinTimeVars(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := resolveTemplatesAt(tc.input, now, cfg) + got, err := resolveTemplatesAt(tc.input, now, cfg) + if err != nil { + t.Fatalf("resolveTemplatesAt(%q) error: %v", tc.input, err) + } if got != tc.expected { t.Errorf("resolveTemplatesAt(%q) = %q, want %q", tc.input, got, tc.expected) } @@ -94,7 +98,10 @@ func TestResolveTemplates_ThisWeekAlwaysMonday(t *testing.T) { // Check every day of a week (Mon 2024-07-15 .. Sun 2024-07-21) for d := 15; d <= 21; d++ { now := time.Date(2024, 7, d, 12, 0, 0, 0, time.UTC) - got := resolveTemplatesAt("{{this_week}}", now, cfg) + got, err := resolveTemplatesAt("{{this_week}}", now, cfg) + if err != nil { + t.Fatalf("day=%d: error: %v", d, err) + } if got != "2024-07-15" { t.Errorf("day=%d (%s): this_week = %q, want 2024-07-15", d, now.Weekday(), got) } @@ -119,7 +126,10 @@ func TestResolveTemplates_ThisQuarterBoundaries(t *testing.T) { } for _, tc := range tests { t.Run(tc.date.Format("2006-01-02"), func(t *testing.T) { - got := resolveTemplatesAt("{{this_quarter}}", tc.date, cfg) + got, err := resolveTemplatesAt("{{this_quarter}}", tc.date, cfg) + if err != nil { + t.Fatalf("error: %v", err) + } if got != tc.expected { t.Errorf("this_quarter on %s = %q, want %q", tc.date.Format("2006-01-02"), got, tc.expected) } @@ -146,7 +156,10 @@ func TestResolveTemplates_ParameterizedVars(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := resolveTemplatesAt(tc.input, now, cfg) + got, err := resolveTemplatesAt(tc.input, now, cfg) + if err != nil { + t.Fatalf("resolveTemplatesAt(%q) error: %v", tc.input, err) + } if got != tc.expected { t.Errorf("resolveTemplatesAt(%q) = %q, want %q", tc.input, got, tc.expected) } @@ -158,8 +171,14 @@ func TestResolveTemplates_DaysAgo7EqualsWeekAgo(t *testing.T) { now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) cfg := &config.Config{} - daysAgo := resolveTemplatesAt("{{days_ago:7}}", now, cfg) - weekAgo := resolveTemplatesAt("{{week_ago}}", now, cfg) + daysAgo, err := resolveTemplatesAt("{{days_ago:7}}", now, cfg) + if err != nil { + t.Fatalf("days_ago error: %v", err) + } + weekAgo, err := resolveTemplatesAt("{{week_ago}}", now, cfg) + if err != nil { + t.Fatalf("week_ago error: %v", err) + } if daysAgo != weekAgo { t.Errorf("days_ago:7 (%s) != week_ago (%s)", daysAgo, weekAgo) } @@ -173,8 +192,14 @@ func TestResolveTemplates_MonthsAgo1EqualsMonthAgo(t *testing.T) { now := time.Date(2024, 7, 30, 12, 0, 0, 0, time.UTC) cfg := &config.Config{} - monthsAgo := resolveTemplatesAt("{{months_ago:1}}", now, cfg) - monthAgo := resolveTemplatesAt("{{month_ago}}", now, cfg) + monthsAgo, err := resolveTemplatesAt("{{months_ago:1}}", now, cfg) + if err != nil { + t.Fatalf("months_ago error: %v", err) + } + monthAgo, err := resolveTemplatesAt("{{month_ago}}", now, cfg) + if err != nil { + t.Fatalf("month_ago error: %v", err) + } // months_ago:1 on July 30 → June 30 if monthsAgo != "2024-06-30" { @@ -200,7 +225,10 @@ func TestResolveTemplates_InvalidParameterizedLeftAsIs(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := resolveTemplatesAt(tc.input, now, cfg) + got, err := resolveTemplatesAt(tc.input, now, cfg) + if err != nil { + t.Fatalf("error: %v", err) + } if got != tc.input { t.Errorf("expected %q to be left as-is, got %q", tc.input, got) } @@ -253,7 +281,10 @@ func TestResolveTemplates_Projects(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - got := resolveTemplatesAt("{{projects}}", now, tc.cfg) + got, err := resolveTemplatesAt("{{projects}}", now, tc.cfg) + if err != nil { + t.Fatalf("error: %v", err) + } if got != tc.expected { t.Errorf("projects = %q, want %q", got, tc.expected) } @@ -272,7 +303,10 @@ func TestResolveTemplates_MultipleVarsInQuery(t *testing.T) { input := "SELECT * FROM issues WHERE assignee = '{{me}}' AND updated >= '{{days_ago:14}}' AND project IN ({{projects}})" expected := "SELECT * FROM issues WHERE assignee = 'user@example.com' AND updated >= '2024-07-03' AND project IN ('PROJ1')" - got := resolveTemplatesAt(input, now, cfg) + got, err := resolveTemplatesAt(input, now, cfg) + if err != nil { + t.Fatalf("error: %v", err) + } if got != expected { t.Errorf("multi-var query:\ngot: %s\nwant: %s", got, expected) } @@ -298,6 +332,169 @@ func TestTable_Format(t *testing.T) { } } +func TestResolveTemplates_Snippets(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + input string + cfg *config.Config + expected string + wantErr bool + errMsg string + }{ + { + name: "simple snippet", + input: "SELECT * FROM issues WHERE {{active}}", + cfg: &config.Config{ + Snippets: map[string]string{ + "active": "status NOT IN ('Done', 'Closed')", + }, + }, + expected: "SELECT * FROM issues WHERE status NOT IN ('Done', 'Closed')", + }, + { + name: "snippet referencing built-in variable", + input: "SELECT * FROM issues WHERE {{my_open}}", + cfg: &config.Config{ + Me: "user@example.com", + Snippets: map[string]string{ + "my_open": "assignee = 'user@example.com' AND status != 'Done'", + }, + }, + expected: "SELECT * FROM issues WHERE assignee = 'user@example.com' AND status != 'Done'", + }, + { + name: "recursive snippet — snippet references another snippet", + input: "SELECT * FROM issues WHERE {{my_open}}", + cfg: &config.Config{ + Me: "user@example.com", + Snippets: map[string]string{ + "active": "status NOT IN ('Done', 'Closed')", + "my_open": "assignee = 'user@example.com' AND {{active}}", + }, + }, + expected: "SELECT * FROM issues WHERE assignee = 'user@example.com' AND status NOT IN ('Done', 'Closed')", + }, + { + name: "snippet with built-in var inside", + input: "SELECT * FROM issues WHERE {{my_tasks}}", + cfg: &config.Config{ + Me: "user@example.com", + Snippets: map[string]string{ + "my_tasks": "assignee = '{{me}}'", + }, + }, + // {{me}} is resolved first by resolveBuiltins, then snippet expands + expected: "SELECT * FROM issues WHERE assignee = 'user@example.com'", + }, + { + name: "circular reference A→B→A", + input: "{{a}}", + cfg: &config.Config{ + Snippets: map[string]string{ + "a": "{{b}}", + "b": "{{a}}", + }, + }, + wantErr: true, + errMsg: "circular snippet reference: a", + }, + { + name: "self-referencing snippet", + input: "{{loop}}", + cfg: &config.Config{ + Snippets: map[string]string{ + "loop": "{{loop}}", + }, + }, + wantErr: true, + errMsg: "circular snippet reference: loop", + }, + { + name: "unknown snippet left as-is", + input: "SELECT * FROM issues WHERE {{unknown_snippet}}", + cfg: &config.Config{ + Snippets: map[string]string{ + "active": "status != 'Done'", + }, + }, + expected: "SELECT * FROM issues WHERE {{unknown_snippet}}", + }, + { + name: "empty snippets map", + input: "SELECT * FROM issues WHERE {{active}}", + cfg: &config.Config{}, + // No snippets defined — left as-is + expected: "SELECT * FROM issues WHERE {{active}}", + }, + { + name: "multiple snippets in one query", + input: "SELECT * FROM issues WHERE {{active}} AND {{high_pri}}", + cfg: &config.Config{ + Snippets: map[string]string{ + "active": "status != 'Done'", + "high_pri": "priority IN ('Highest', 'High')", + }, + }, + expected: "SELECT * FROM issues WHERE status != 'Done' AND priority IN ('Highest', 'High')", + }, + { + name: "deep recursive chain within limit", + input: "{{s1}}", + cfg: &config.Config{ + Snippets: map[string]string{ + "s1": "{{s2}}", + "s2": "{{s3}}", + "s3": "resolved", + }, + }, + expected: "resolved", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := resolveTemplatesAt(tc.input, now, tc.cfg) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.errMsg) + } + if !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("error = %q, want it to contain %q", err.Error(), tc.errMsg) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.expected { + t.Errorf("got %q, want %q", got, tc.expected) + } + }) + } +} + +func TestExpandSnippet(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{ + Me: "user@example.com", + Snippets: map[string]string{ + "active": "status NOT IN ('Done', 'Closed')", + "my_open": "assignee = '{{me}}' AND {{active}}", + }, + } + + // Test expanding my_open, which references {{me}} (built-in) and {{active}} (snippet). + got, err := ExpandSnippet(cfg.Snippets["my_open"], now, cfg) + if err != nil { + t.Fatalf("ExpandSnippet error: %v", err) + } + expected := "assignee = 'user@example.com' AND status NOT IN ('Done', 'Closed')" + if got != expected { + t.Errorf("ExpandSnippet = %q, want %q", got, expected) + } +} + func TestJSONBytes(t *testing.T) { r := &Results{ Columns: []string{"key"}, From 05f2697220bc84f838facd8083a3cb699eabba24 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:11:22 +0000 Subject: [PATCH 10/18] docs: add jira-cli assessment and feature adoption recommendations --- docs/jira-cli-assessment.md | 298 +++++++++++++++++++++++ docs/jira-cli-feature-adoption.md | 386 ++++++++++++++++++++++++++++++ 2 files changed, 684 insertions(+) create mode 100644 docs/jira-cli-assessment.md create mode 100644 docs/jira-cli-feature-adoption.md diff --git a/docs/jira-cli-assessment.md b/docs/jira-cli-assessment.md new file mode 100644 index 0000000..732af72 --- /dev/null +++ b/docs/jira-cli-assessment.md @@ -0,0 +1,298 @@ +# jira-cli Assessment + +> Assessment of [ankitpokhrel/jira-cli](https://github.com/ankitpokhrel/jira-cli) — a feature-rich, +> interactive Jira CLI written in Go. + +## Overview + +jira-cli is a comprehensive terminal interface for Jira that covers the vast majority of Jira's +web UI functionality. It takes an **API-first** approach: every command hits the Jira REST API +directly, with no local data layer. It supports Jira Cloud, Server, and Data Center installations. + +**Tech stack**: Go, Cobra, Viper (config), tview (TUI), glamour (markdown rendering), survey +(interactive prompts). CGO disabled — ships as a single static binary via GoReleaser + Homebrew. + +**Codebase**: ~22,600 lines of Go across a well-structured `internal/` + `pkg/` layout. + +--- + +## Authentication + +jira-cli supports three auth methods — more than most Jira CLIs: + +| Method | Details | +|--------|---------| +| **Basic Auth** | API Token + email (Cloud) or username + password (Server) | +| **Bearer / PAT** | Personal Access Tokens (Server 8.14+, Data Center) | +| **mTLS** | Mutual TLS with client certificates — rare in CLI tooling | + +Credentials can be stored in: +- `JIRA_API_TOKEN` environment variable (highest priority) +- `~/.netrc` file (standard GNU format) +- OS keychain (macOS Keychain, GNOME Keyring, Windows Credential Manager) via `go-keyring` + +## Configuration + +YAML config at `~/.jira/.config.yml`. Viper-based with env var substitution. + +Key configurable items: +- Installation type (cloud vs local/server) +- Default project and board +- Epic field mappings (auto-discovered during `jira init`) +- Issue type handles (critical for non-English Jira instances) +- Custom field mappings (auto-discovered) +- Timezone, default comment count, TLS settings +- Multi-config support via `--config` flag or `JIRA_CONFIG_FILE` env var + +The `jira init` wizard auto-discovers custom fields, epic fields, and issue types from the Jira +API metadata — users don't need to manually configure field IDs. + +--- + +## Command Surface + +### Issue Management (`jira issue`) + +| Command | Description | Notable Details | +|---------|-------------|-----------------| +| `list` / `ls` / `search` | Search and list issues | 20+ filter flags, date ranges, negation with `~` prefix, pagination, interactive TUI by default | +| `create` | Create issue | All standard + custom fields, markdown body, templates from file/stdin, `--web` opens in browser after | +| `edit` | Edit issue | Delta operations for arrays (add/remove with `-` prefix), custom fields | +| `view` | View issue detail | Glamour-rendered markdown, ADF conversion, pager integration | +| `assign` | Assign/unassign | Fuzzy user search, `x` for unassign, `$(jira me)` for self | +| `move` / `transition` | Transition status | Interactive transition picker, comment + resolution + assignee during transition | +| `clone` | Clone issue | Copy all fields, find/replace in summary/description | +| `delete` | Delete issue | `--cascade` for subtasks | +| `comment add` | Add comment | Markdown, templates, stdin pipe, `--internal` for Service Desk | +| `worklog add` | Log work | Jira time format (`2h 30m`), started time, new estimate | +| `link` | Link issues | Interactive link type selection | +| `link remote` | Add web link | URL + title | +| `unlink` | Remove link | Auto-determines link ID | +| `watch` | Add watcher | Fuzzy user search | + +### Epic Management (`jira epic`) + +| Command | Description | +|---------|-------------| +| `list` | List epics or issues within an epic — explorer view (sidebar + content pane) | +| `create` | Create epic with `--name` flag, auto-sets type | +| `add` | Add up to 50 issues to epic in batch | +| `remove` | Remove up to 50 issues from epic in batch | + +### Sprint Management (`jira sprint`) + +| Command | Description | +|---------|-------------| +| `list` | List sprints or issues in sprint — `--current`, `--prev`, `--next`, `--state` filters | +| `add` | Add up to 50 issues to sprint in batch | +| `close` | Close sprint, moves incomplete to backlog | + +### Other Commands + +| Command | Description | +|---------|-------------| +| `project list` | List accessible projects | +| `board list` | List boards (with type filter) | +| `release list` | List project versions/releases | +| `open` | Open issue/project in browser | +| `me` | Print current user (useful in `$(jira me)` substitution) | +| `serverinfo` | Jira server version/build info | +| `init` | Interactive setup wizard with field auto-discovery | +| `completion` | Shell completions (bash, zsh, fish, powershell) | +| `man` | Generate UNIX man pages | + +--- + +## Search and Querying + +jira-cli has a sophisticated JQL builder with a fluent Go API: + +- **CLI-level filter composition**: All flags (`-t`, `-s`, `-y`, `-a`, `-l`, etc.) are AND-ed into JQL +- **Negation syntax**: Prefix `~` negates any filter (`-s ~Done` = status != Done, `-a ~x` = assigned) +- **Empty/not-empty**: `-a x` = unassigned, `-a ~x` = assigned +- **Date shortcuts**: `--created week`, `--updated -7d`, `--created-after 2024-01-01` +- **Relative time**: `-1h`, `-30m`, `-7d` (maps to Jira date functions) +- **Text search**: Positional arg maps to JQL `text ~ "..."` +- **Issue history**: `--history` uses `issueHistory()` JQL function +- **Watching**: `-w` for watched issues +- **Raw JQL**: `--jql` for anything the builder can't express +- **Pagination**: `--paginate 10:50` (skip:limit) +- **Sorting**: `--order-by`, `--reverse` + +There is **no SQL layer** — all querying goes through Jira's JQL API. + +--- + +## Output Formats + +| Format | Flag | Use Case | +|--------|------|----------| +| Interactive TUI | (default) | Human browsing with keyboard nav | +| Plain table | `--plain` | Tab-delimited, parseable | +| CSV | `--csv` | Spreadsheet import | +| Raw JSON | `--raw` | Direct API response for scripting | +| Markdown | (view command) | Glamour-rendered issue detail | + +Column customization via `--columns key,summary,status`. Headers can be suppressed with +`--no-headers`. Delimiter is configurable with `--delimiter`. + +There is **no structured JSON envelope** (no `{"ok":true,"data":...}` wrapper) — `--raw` returns +the Jira API response verbatim. + +--- + +## TUI Features + +### Table View +- Keyboard navigation: arrows, j/k/h/l, g/G, Ctrl-f/b, PgUp/PgDn +- `v`: View selected issue detail (rendered markdown) +- `m`: Transition selected issue (modal with available transitions) +- `Enter`: Open in browser +- `c`: Copy issue URL to clipboard +- `Ctrl+k`: Copy issue key to clipboard +- `Ctrl+r` / `F5`: Refresh list +- `?`: Help overlay + +### Explorer View (Epics / Sprints) +- Split-pane: sidebar (hierarchy) + content (issues) +- `w` / `Tab`: Toggle focus between panes +- Same filtering and navigation as table view + +### Interactive Prompts (via survey library) +- Text input with validation +- Single/multi select +- Confirm dialogs +- Editor launch for long-form text (respects `JIRA_EDITOR` > `VISUAL` > `EDITOR`) +- Fuzzy search for users, boards, projects + +### Clipboard +- Requires `xclip`/`xsel` on Linux; native on macOS/Windows +- Copy URL or issue key from TUI + +**Notable absence**: No tab-based multi-view system. No background sync. No configurable views. +The TUI shows one query result at a time. + +--- + +## Markdown / ADF Handling + +One of jira-cli's strongest differentiators: + +- **Input**: GitHub-flavored markdown converted to Jira Wiki Markup (v2 API) or ADF (v3 API) + automatically based on installation type +- **Output**: ADF and Jira Wiki Markup both converted back to terminal-rendered markdown via glamour +- **Bidirectional**: Full round-trip for tables, code blocks, lists, mentions, emojis, panels, + blockquotes, inline cards +- **Custom parsers**: AST-based Jira Wiki parser, ADF translator — not simple regex replacements + +--- + +## Write Operations + +All writes hit the Jira API immediately (no local queue): + +- **Create**: Full field support including custom fields, templates, markdown conversion +- **Edit**: Delta semantics for arrays (add `-label` to remove, add `label` to add) +- **Transition**: With optional comment, resolution, and assignee change in one call +- **Clone**: Deep copy with field overrides and find/replace +- **Delete**: With `--cascade` for subtasks +- **Comments**: Markdown, templates, stdin, internal (Service Desk) +- **Worklogs**: Jira time format, start time, estimate adjustment +- **Links**: Issue-to-issue and remote web links +- **Watchers**: Fuzzy user search and add +- **Epics**: Batch add/remove up to 50 issues +- **Sprints**: Batch add up to 50 issues, close sprint + +--- + +## Integration Points + +| Integration | Details | +|-------------|---------| +| **Browser** | `jira open`, `--web` on create, `Enter` in TUI. Respects `JIRA_BROWSER`/`BROWSER` env vars | +| **Editor** | Opens for long descriptions/comments. `JIRA_EDITOR` > `VISUAL` > `EDITOR` | +| **Pager** | `less` by default, configurable via `PAGER` | +| **Clipboard** | Copy URLs/keys. Platform-native | +| **Shell completion** | Bash, Zsh, Fish, PowerShell | +| **Man pages** | Self-generating via `jira man --generate` | +| **Scripting** | `--plain`, `--raw`, `--csv`, `--no-headers`, `--no-input` for CI/CD | +| **Docker** | `ghcr.io/ankitpokhrel/jira-cli` image | +| **OS Keychain** | macOS Keychain, GNOME Keyring, Windows Credential Manager | +| **`.netrc`** | Standard credential file support | + +--- + +## API Version Handling + +jira-cli has a proxy layer (`api/` package) that abstracts Jira API version differences: + +| API | Usage | +|-----|-------| +| v3 (`/rest/api/3`) | Jira Cloud — ADF for rich text | +| v2 (`/rest/api/2`) | Jira Server/DC — Wiki markup for rich text | +| v1 (`/rest/agile/1.0`) | Agile endpoints (boards, sprints, epics) — both Cloud and Server | + +Version detection uses `/serverInfo` endpoint. The proxy pattern means commands don't need to know +which API version they're talking to. + +--- + +## Architecture Highlights + +### Patterns +- **Command pattern** (Cobra): Each command is its own package under `internal/cmd/` +- **Proxy pattern**: `api/` abstracts v2/v3 differences (`ProxyCreate()`, `ProxyGetIssue()`) +- **Builder pattern**: Fluent JQL construction (`jql.NewJQL("PROJ").FilterBy(...).In(...)`) +- **Strategy pattern**: Output rendering swappable between TUI, plain, CSV, JSON +- **Adapter pattern**: Markdown/ADF/Wiki bidirectional conversion + +### Code Organization +``` +internal/cmd/ — Cobra command implementations (one package per command) +internal/cmdutil/ — Shared utilities (spinners, formatters) +internal/config/ — Config generation and management +internal/query/ — JQL query construction +internal/view/ — Output rendering (table, plain, CSV) +pkg/jira/ — Jira HTTP client with filter support +pkg/jql/ — JQL builder (public, reusable) +pkg/adf/ — ADF parser/translator +pkg/md/ — Markdown conversion (includes Jira Wiki parser) +pkg/tui/ — TUI components and primitives +pkg/netrc/ — .netrc reader +api/ — API version proxy layer +``` + +### Design Decisions +- **No local data layer**: Every read hits Jira API +- **No ORM**: Direct HTTP + JSON marshaling +- **CGO disabled**: Static binary, easy cross-compilation +- **tview for TUI**: Rich widgets but heavier than bubbletea +- **Viper for config**: Env vars, multiple sources, hot reload + +--- + +## Salient Points + +### Strengths +1. **Comprehensive coverage**: Covers ~90% of common Jira workflows +2. **Jira Server/DC support**: Full parity with Cloud — rare among Jira CLIs +3. **ADF bidirectional conversion**: Custom AST-based parsers for round-trip fidelity +4. **mTLS support**: Enterprise-grade auth option +5. **Smart JQL builder**: `~` negation, date shortcuts, empty operators — genuinely easier than raw JQL +6. **Delta edit semantics**: Add/remove for array fields without replacing +7. **Explorer view**: Split-pane for epics/sprints is better than flat tables +8. **Batch operations**: 50 issues at once for epic/sprint management +9. **Template system**: File/stdin templates for standardized issue creation +10. **Cross-platform**: Linux, macOS, FreeBSD, NetBSD, Windows + +### Gaps (relative to jai's approach) +1. **No local data layer**: Every query hits Jira API — no offline reads, no SQL, no aggregations +2. **No structured JSON for agents**: `--raw` returns raw API responses, not agent-optimized envelopes +3. **No schema introspection**: No `jai schema` equivalent for AI agents to discover capabilities +4. **No full-text search on local data**: Depends on Jira's API-based text search +5. **No multi-view TUI**: Shows one query at a time, no tabs or saved views +6. **No background sync**: Data is always fresh but always requires network +7. **No config-driven views**: No equivalent to jai's YAML view definitions +8. **No bulk update by query**: Can batch epic/sprint adds, but can't "update all issues matching X" +9. **Single project context**: Default project baked into config; multi-project requires config switching +10. **No token-efficient output**: Raw API responses are verbose for AI consumption diff --git a/docs/jira-cli-feature-adoption.md b/docs/jira-cli-feature-adoption.md new file mode 100644 index 0000000..8101722 --- /dev/null +++ b/docs/jira-cli-feature-adoption.md @@ -0,0 +1,386 @@ +# Feature Adoption Recommendations + +> Which jira-cli features jai should adopt — and which it should deliberately skip — while +> maintaining its database-first, agent-optimized stance. + +## Guiding Principles + +jai's opinionated stance rests on three pillars: + +1. **Database-first**: Sync once, query instantly. SQL > JQL. Offline reads. +2. **Agent-optimized**: Compact JSON, schema introspection, token efficiency. +3. **Config-driven views**: YAML + SQL views shared between CLI and TUI. + +Any adopted feature must strengthen (or at least not weaken) these pillars. Features that pull +jai toward "Jira API wrapper" territory are rejected — that's jira-cli's domain, and it does it +well. + +--- + +## Adopt: High Value + +### 1. Issue Cloning (`jai clone`) + +**What jira-cli does**: `jira issue clone KEY` copies all fields into a new issue, with overrides +(`-s`, `-y`, `-a`, `-l`) and find/replace in summary/description (`-H find:replace`). + +**Why jai should adopt**: Cloning is a common workflow (duplicate bug, create similar story). +jai already has `jai create` — cloning is `jai get KEY --json` + `jai create` with prefilled +fields. A dedicated command makes this a single operation. + +**How to implement**: Read issue from local DB, apply overrides, call Jira create API directly +(same as `jai create`). No queue — returns the new key immediately. + +**Suggested API**: +``` +jai clone PROJ-123 [--summary "..."] [--set field=value] [--replace find:replace] +``` + +### 2. Sprint and Epic Batch Operations + +**What jira-cli does**: `jira epic add EPIC-KEY ISSUE-1 ISSUE-2 ...` and +`jira sprint add SPRINT-ID ISSUE-1 ...` — batch up to 50 issues per call. + +**Why jai should adopt**: jai already has `jai set` with `--query` for SQL-driven bulk updates. +Sprint and epic assignment via SQL query is a natural extension: +``` +jai sprint add 42 --query "SELECT key FROM issues WHERE status = 'To Do' AND sprint IS NULL" +``` + +**How to implement**: Immediate API call (like `jai transition`), not queued. Uses Agile v1 API. +Fits jai's pattern of SQL-driven bulk operations. + +### 3. Watching / Watchers + +**What jira-cli does**: `jira issue watch KEY [USER]` — add user as watcher. + +**Why jai should adopt**: Watching is lightweight and useful. Sync already has the data (`watchers` +field). Adding a watcher is a simple write operation. + +**Suggested API**: +``` +jai watch PROJ-123 [user@email.com] # add watcher (default: self) +jai unwatch PROJ-123 # remove self +``` + +### 4. Template Variables and User-Defined SQL Snippets + +**What jira-cli does**: Smart shortcuts — `~` for negation, `x` for empty, date shortcuts +(`week`, `-7d`), `--history`, `--watching`. + +**Why jai should adopt — via templates**: jai's SQL approach is more powerful than JQL, but raw +SQL for common patterns is verbose. Template variables and user-defined snippets can give SQL +the ergonomics of jira-cli's filter flags without adding a parallel query language. + +#### Current state + +jai has four built-in template variables today: +- `{{me}}` → config `me` value (email/username) +- `{{team}}` → config `team` value +- `{{today}}` → `2024-01-22` (current date) +- `{{week_ago}}` → `2024-01-15` (7 days ago) + +#### Proposed: More built-in time variables + +| Variable | Expands to | Use case | +|----------|-----------|----------| +| `{{yesterday}}` | `date('now', '-1 day')` | Daily standups, "what changed yesterday" | +| `{{month_ago}}` | `date('now', '-30 days')` | Staleness detection | +| `{{quarter_ago}}` | `date('now', '-90 days')` | Quarterly reviews | +| `{{this_week}}` | Monday of current week | "Issues created this week" | +| `{{this_month}}` | 1st of current month | "Issues created this month" | +| `{{this_quarter}}` | 1st of current quarter | Quarterly metrics | + +Example: +```sql +SELECT key, summary, status FROM issues +WHERE updated < '{{month_ago}}' AND status NOT IN ('Done', 'Closed') +``` + +#### Proposed: Parameterized time variables + +Instead of a fixed set, support `{{days_ago:N}}` and `{{weeks_ago:N}}`: + +| Variable | Expands to | Example | +|----------|-----------|---------| +| `{{days_ago:N}}` | N days before today | `{{days_ago:14}}` → two weeks ago | +| `{{weeks_ago:N}}` | N weeks before today | `{{weeks_ago:4}}` → four weeks ago | +| `{{months_ago:N}}` | N months before today | `{{months_ago:3}}` → quarter ago | + +This replaces an infinite set of fixed variables with three parameterized ones. A user writes: +```sql +SELECT key, summary FROM issues WHERE created >= '{{days_ago:5}}' +``` + +#### Proposed: User-defined SQL snippets in config + +The most powerful extension. Let users define reusable SQL fragments in their config: + +```yaml +snippets: + active: "status NOT IN ('Done', 'Closed', 'Resolved')" + stale: "julianday('now') - julianday(updated) > 28" + my_open: "assignee = '{{me}}' AND {{active}}" + high_pri: "priority IN ('Highest', 'High')" + unassigned: "assignee IS NULL OR assignee = ''" + has_epic: "epic_key IS NOT NULL AND epic_key != ''" + recent: "updated >= '{{week_ago}}'" + blocked: "status = 'Blocked'" + bugs: "type = 'Bug'" + no_sprint: "sprint IS NULL OR sprint = ''" +``` + +Usage — snippets compose with each other and with built-in variables: +```sql +-- "My open high-priority bugs" +SELECT key, summary, priority FROM issues +WHERE {{my_open}} AND {{high_pri}} AND {{bugs}} + +-- expands to: +-- WHERE assignee = 'user@email.com' +-- AND status NOT IN ('Done', 'Closed', 'Resolved') +-- AND priority IN ('Highest', 'High') +-- AND type = 'Bug' +``` + +Snippets are resolved recursively (a snippet can reference another snippet, like `{{my_open}}` +referencing `{{active}}`), with a depth limit to prevent cycles. + +**Why this is powerful**: +- Fits jai's config-driven philosophy — snippets live in YAML alongside views +- Composable — combine snippets in any WHERE clause +- Shareable — team members share the same config, same vocabulary +- Agent-friendly — an AI agent can discover available snippets via `jai schema snippets` +- SQL-native — no new query language, just reusable fragments + +**Why this is better than jira-cli's approach**: jira-cli's filter flags (`-s`, `-a`, `-l`) +are hard-coded into the CLI. Users can't define their own. Snippets are user-extensible and +work in any SQL context — views, ad-hoc queries, even `jai set --query`. + +#### Proposed: Project-aware variables + +| Variable | Expands to | Use case | +|----------|-----------|----------| +| `{{projects}}` | `'PROJ1','PROJ2','PROJ3'` (from sync sources) | `WHERE project IN ({{projects}})` | + +Useful when sync sources span multiple projects and you want to query across all of them +without hardcoding project keys. + +#### Implementation approach + +1. **Built-in time variables** — extend `resolveTemplates()` in `internal/query/engine.go` (trivial) +2. **Parameterized variables** — add regex matching for `{{days_ago:N}}` pattern (small) +3. **User-defined snippets** — add `Snippets map[string]string` to `config.Config`, resolve + in `resolveTemplates()` with recursive expansion (medium) +4. **Schema introspection** — add `jai schema snippets` to list available snippets for agents + +--- + +## Adopt: Medium Value + +### 5. Remote Links + +**What jira-cli does**: `jira issue link remote KEY URL [TITLE]` — attach a web URL to an issue. + +**Why**: Useful for linking PRs, docs, dashboards. jai already has `jai link` for issue-to-issue +links. Extending to remote links is a small surface area addition. + +**Suggested API**: +``` +jai link PROJ-123 https://github.com/... "PR #42" # detect URL → remote link +jai link PROJ-123 PROJ-456 # detect key → issue link (existing) +``` + +### 6. Shell Completions + +**What jira-cli does**: `jira completion bash|zsh|fish|powershell`. + +**Why**: Standard CLI quality-of-life. Cobra generates these for free — it's already built in, +just needs to be exposed as a command. + +**Suggested API**: +``` +jai completion zsh # print completion script +``` + +### 7. Issue Templates + +**What jira-cli does**: `--template file.md` loads description from a file. Supports stdin via `-`. + +**Why**: Standardized issue creation (bug reports, feature requests). Fits jai's config-driven +philosophy — templates could live in the config file or a `templates/` directory. + +**Suggested API**: +``` +jai create PROJ --template bug-report # loads from config-defined template +jai create PROJ --body - # read from stdin +``` + +### 8. `jai open` — Open in Browser + +**What jira-cli does**: `jira open KEY` opens the issue in the default browser. + +**Why**: jai's TUI already has `o` to open in browser. A standalone command is useful outside the +TUI — especially for AI agents that want to give the user a clickable link. + +**Suggested API**: +``` +jai open PROJ-123 # open in browser +jai open PROJ-123 --url-only # print URL (useful for agents) +``` + +--- + +## Skip: Deliberately Out of Scope + +### Worklog Support + +**What jira-cli does**: `jira issue worklog add KEY 2h30m`. + +**Why skip**: Time tracking adds a new sync dimension (worklogs table), a new write command, +and Jira time format parsing — significant surface area for a feature that not all teams use. +Can be revisited if there's demand. + +### Delete Issue + +**What jira-cli does**: `jira issue delete KEY [--cascade]`. + +**Why skip**: Deletion is rare in Jira workflows (most teams transition to Done/Closed/Won't Do). +The risk/reward ratio is poor — a destructive operation that's seldom needed. Users who need it +can use the Jira web UI or API directly. + +### mTLS Authentication + +**What jira-cli does**: Client certificate auth with CA cert, client cert, client key. + +**Why skip**: Enterprise-only edge case. jai targets Jira Cloud with API tokens. Adding mTLS +adds configuration complexity and testing burden for a tiny user base. Can be revisited if +there's demand. + +### Jira Server / Data Center Support + +**What jira-cli does**: Full v2 API support for on-premise Jira, with version detection and +API proxying. + +**Why skip**: jai's DB-first architecture assumes Jira Cloud's API behavior and field +conventions. Supporting Server/DC would require a compatibility layer for API differences, +field naming, pagination, and auth — significant complexity for a shrinking market segment. +jai's spec explicitly targets Jira Cloud. + +### Explorer View (Split-Pane TUI) + +**What jira-cli does**: Sidebar + content pane for epics and sprints. + +**Why skip**: jai's TUI uses a tab-based multi-view model with grouping (`g` key). Grouping by +epic or sprint achieves the same hierarchy visualization within jai's existing paradigm. +A split-pane is a different UX model that would complicate the TUI architecture without +clear benefit over group-by. + +### In-TUI Transitions + +**What jira-cli does**: Press `m` on an issue → modal with transitions → execute. + +**Why skip for now**: jai's TUI already has quick edit (`,` key) for field updates. Transitions +are available via `jai transition KEY STATUS`. Adding a TUI modal for transitions is a nice +enhancement but not a priority — it's a UX polish item, not a capability gap. + +**Revisit**: Worth adding as a TUI enhancement in a future polish phase. + +### Raw JQL Passthrough for List Commands + +**What jira-cli does**: Every list command accepts `--jql` to append raw JQL. + +**Why skip**: jai already has `jai query --jql` for live API queries. Adding JQL to other +commands would blur the line between jai's SQL-first model and JQL — exactly the kind of +compromise that weakens an opinionated tool. + +### Issue History / Recently Viewed + +**What jira-cli does**: `--history` flag shows recently viewed issues (via `issueHistory()` JQL +function). + +**Why skip**: This is a Jira API feature that tracks web UI views. jai's DB-first model means +users query their synced data — "recently viewed in Jira's web UI" is not a useful local +concept. If needed, `ORDER BY updated DESC LIMIT 10` serves the same purpose from local data. + +### CSV Output + +**What jira-cli does**: `--csv` flag for comma-separated output. + +**Why skip**: jai's `--json` output can be piped through `jq` for any format transformation. +Adding CSV is a marginal convenience that doesn't serve jai's primary users (agents want JSON, +humans want the TUI or lipgloss tables). If someone needs CSV: +``` +jai query "SELECT key, summary, status FROM issues" --json | jq -r '.rows[] | @csv' +``` + +### Man Pages + +**What jira-cli does**: `jira man --generate` creates UNIX man pages. + +**Why skip**: Niche. Cobra's built-in `--help` and jai's `schema` command (for agents) cover +discoverability. Man pages are a distribution-era convention; modern CLI users expect `--help` +and web docs. + +### `.netrc` Support + +**What jira-cli does**: Reads credentials from `~/.netrc`. + +**Why skip**: jai uses YAML config with `${ENV_VAR}` substitution. Environment variables are +the standard for secret injection in modern tooling (CI/CD, Docker, agents). `.netrc` adds a +second credential path without clear benefit. + +### OS Keychain Integration + +**What jira-cli does**: Stores credentials in macOS Keychain / GNOME Keyring / Windows Credential +Manager via `go-keyring`. + +**Why skip**: Adds a CGO dependency on some platforms and platform-specific testing burden. jai +already uses env vars for credentials (`${JIRA_API_TOKEN}` in config). For users who want +keychain integration: +```yaml +api_token: ${JIRA_API_TOKEN} +# Then: export JIRA_API_TOKEN=$(security find-generic-password -s jira -w) +``` + +--- + +## Priority Order + +| Priority | Feature | Effort | Impact | +|----------|---------|--------|--------| +| 1 | Shell completions | Low | Medium — free from Cobra, just expose it | +| 2 | `jai open` | Low | Medium — useful for agents and humans | +| 3 | Built-in time variables | Low | Medium — extend `resolveTemplates()` | +| 4 | Parameterized time variables (`{{days_ago:N}}`) | Low | Medium — three patterns replace infinite fixed set | +| 5 | User-defined SQL snippets | Medium | High — composable, shareable, agent-discoverable | +| 6 | Issue cloning | Medium | Medium — common workflow | +| 7 | Remote links | Low | Low — extends existing link command | +| 8 | Sprint/epic batch ops | Medium | Medium — SQL-driven bulk is powerful | +| 9 | Issue templates | Medium | Low — config-driven standardization | +| 10 | Watching | Low | Low — nice to have | + +--- + +## Summary + +jira-cli is a **broad, comprehensive** Jira CLI that aims to replicate the full Jira web UI in +the terminal. jai is a **deep, opinionated** tool that trades API breadth for SQL power, offline +speed, and agent optimization. + +The adopted features fall into three categories: + +1. **SQL ergonomics** (template variables, parameterized time, user-defined snippets) — these + make jai's SQL-first model as convenient as jira-cli's filter flags, while being more + powerful and user-extensible. +2. **Missing write operations** (clone, watch, remote links, sprint/epic batch) — these fill + capability gaps without compromising jai's architecture. +3. **CLI polish** (shell completions, `jai open`, templates) — these make jai more pleasant + without changing its philosophy. + +The features to skip are those that would pull jai toward being an API wrapper (JQL passthrough, +history, Server/DC support, worklog, delete) or add complexity for edge-case users (mTLS, +keychain, `.netrc`, CSV, man pages). + +jai's competitive advantage is that it **doesn't try to be jira-cli**. It syncs data locally +and lets SQL do the heavy lifting. Every adopted feature should reinforce that stance. From 0dbd87c5afb39313236cd583e9bd258e4b27ef4f Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:11:26 +0000 Subject: [PATCH 11/18] docs: update README and user guide with new commands and features Add documentation for: shell completions, open command, clone command, watch/unwatch, remote links, template variables, SQL snippets, and issue templates. --- README.md | 88 +++++++++++++++++++++++++++++++++++++++++- docs/user-guide.md | 95 +++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 176 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 94903f4..ea91e3c 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,49 @@ jai query " jai search "authentication token expired" ``` +### Template variables + +Queries support template variables that are replaced before execution: + +| Variable | Expands to | +|----------|-----------| +| `{{me}}` | Your email (from config `me:`) | +| `{{team}}` | Your team (from config `team:`) | +| `{{today}}` | Today's date | +| `{{yesterday}}` | Yesterday's date | +| `{{week_ago}}` | 7 days ago | +| `{{month_ago}}` | 30 days ago | +| `{{quarter_ago}}` | 90 days ago | +| `{{this_week}}` | Monday of current week | +| `{{this_month}}` | 1st of current month | +| `{{this_quarter}}` | 1st of current quarter | +| `{{projects}}` | Quoted project keys from all sync sources | +| `{{days_ago:N}}` | N days ago | +| `{{weeks_ago:N}}` | N weeks ago | +| `{{months_ago:N}}` | N months ago | + +```sql +jai query "SELECT key, summary FROM issues WHERE created >= '{{days_ago:14}}'" +jai query "SELECT key, summary FROM issues WHERE project IN ({{projects}}) AND updated < '{{month_ago}}'" +``` + +### User-defined snippets + +Define reusable SQL fragments in your config and reference them as `{{name}}` in queries: + +```yaml +snippets: + active: "status NOT IN ('Done', 'Closed', 'Resolved')" + stale: "julianday('now') - julianday(updated) > 28" + my_open: "assignee_email = '{{me}}' AND {{active}}" +``` + +```sql +jai query "SELECT key, summary FROM issues WHERE {{my_open}} AND {{stale}}" +``` + +Snippets can reference other snippets and built-in variables. Circular references are detected and produce an error. Use `jai schema snippets` to list available snippets. + --- ## Status transition history @@ -255,6 +298,12 @@ Changes queue locally and sync to Jira on the next `jai push` or background sync jai create ROX --type Bug --summary "Login fails on SSO" --priority High --labels backend,auth # → ✓ Created ROX-4901: Login fails on SSO +# Create from a template (defined in config YAML) +jai create ROX --type Bug --template bug-report --summary "Login fails on SSO" + +# Create with description from stdin +echo "Detailed description here" | jai create ROX --type Bug --summary "Login fails" --body - + # Create with all the bells and whistles jai create ROX --type Story \ --summary "Add search" \ @@ -270,6 +319,12 @@ jai create ROX --type Story \ --json # → {"ok":true,"data":{"key":"ROX-4902","id":"12345","project":"ROX","status":"created"}} +# Clone an existing issue +jai clone ROX-4821 --summary "Similar bug in staging" --set priority=High +# → ✓ Created ROX-4903: Similar bug in staging +jai clone ROX-4821 --replace "production:staging" +# → ✓ Created ROX-4904 (summary/description text replaced) + # Update a field jai set ROX-4821 priority High # → ROX-4821: priority → "High" (pending sync) @@ -298,6 +353,19 @@ jai link ROX-4821 ROX-4756 --type "Blocks" jai link --list-types # → Available link types: Blocks, Related, Duplicate, ... +# Add a remote link (URL detected automatically) +jai link ROX-4821 https://github.com/org/repo/pull/42 "PR #42" +# → ROX-4821: remote link added + +# Watch/unwatch issues +jai watch ROX-4821 # watch as yourself +jai watch ROX-4821 user@example.com # add another watcher +jai unwatch ROX-4821 # stop watching + +# Open in browser +jai open ROX-4821 # opens in default browser +jai open ROX-4821 --url-only # print URL only + # Add a comment jai comment ROX-4821 "Fixed in PR #4892, deploying to staging" # → ROX-4821: comment added (pending sync) @@ -336,6 +404,17 @@ sync_sources: fields: overrides: customfield_12345: team # override auto-inferred field names + +snippets: # reusable SQL fragments for queries + active: "status NOT IN ('Done', 'Closed', 'Resolved')" + my_open: "assignee_email = '{{me}}' AND {{active}}" + +templates: # issue description templates for jai create --template + bug-report: | + ## Steps to Reproduce + 1. + ## Expected Behavior + ## Actual Behavior ``` `jai init` generates this file interactively. @@ -368,17 +447,22 @@ Both paths can be overridden with `--config` and `--db` flags, or by setting `db | `jai fields` | List available fields and mappings | | `jai schema ` | Command schema for agents | | `jai status` | Sync status and pending changes | -| `jai create ` | Create a new issue | +| `jai open ` | Open issue in browser (`--url-only` to print URL) | +| `jai clone ` | Clone an issue with optional overrides | +| `jai create ` | Create a new issue (`--template`, `--body`) | | `jai set ` | Update an issue field | | `jai set --add ` | Add a value to an array field | | `jai set --remove ` | Remove a value from an array field | | `jai set K1,K2,K3 ` | Bulk set on comma-separated keys | | `jai set --query ` | Bulk set via SQL query | | `jai transition ` | Transition an issue to a new status | -| `jai link ` | Create a link between two issues | +| `jai link ` | Link two issues or add a remote URL link | +| `jai watch ` | Add yourself (or a user) as watcher | +| `jai unwatch ` | Remove yourself as watcher | | `jai comment ` | Add a comment | | `jai push` | Push pending changes to Jira | | `jai tui` | Launch full-screen TUI | +| `jai completion ` | Generate shell completions (bash/zsh/fish) | Global flags: `--json`, `--fields`, `--no-sync`, `--config`, `--db` diff --git a/docs/user-guide.md b/docs/user-guide.md index 10b8cef..7c28dbe 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -47,6 +47,45 @@ jai query "SELECT key, summary, status FROM issues WHERE assignee_email = '{{me} The `{{me}}` template variable is replaced with the `me:` value from your config. +### Template variables + +Queries support built-in and parameterized template variables: + +```sh +# Built-in time variables +jai query "SELECT key, summary FROM issues WHERE created >= '{{this_week}}'" +jai query "SELECT key, summary FROM issues WHERE updated < '{{month_ago}}'" + +# Parameterized — pass any number +jai query "SELECT key, summary FROM issues WHERE created >= '{{days_ago:14}}'" +jai query "SELECT key, summary FROM issues WHERE created >= '{{weeks_ago:4}}'" +jai query "SELECT key, summary FROM issues WHERE created >= '{{months_ago:3}}'" + +# Project list from all sync sources +jai query "SELECT key FROM issues WHERE project IN ({{projects}})" +``` + +Available: `{{today}}`, `{{yesterday}}`, `{{week_ago}}`, `{{month_ago}}`, `{{quarter_ago}}`, `{{this_week}}`, `{{this_month}}`, `{{this_quarter}}`, `{{projects}}`, `{{days_ago:N}}`, `{{weeks_ago:N}}`, `{{months_ago:N}}`. + +### User-defined snippets + +Define reusable SQL fragments in your config: + +```yaml +snippets: + active: "status NOT IN ('Done', 'Closed', 'Resolved')" + stale: "julianday('now') - julianday(updated) > 28" + my_open: "assignee_email = '{{me}}' AND {{active}}" +``` + +Use them in any query: + +```sh +jai query "SELECT key, summary FROM issues WHERE {{my_open}} AND {{stale}}" +``` + +Snippets can reference other snippets and built-in variables (recursive expansion). Circular references produce an error. List available snippets with `jai schema snippets`. + ### Full-text search ```sh @@ -145,17 +184,26 @@ jai transition ROX-123 "NotAStatus" Create links between issues. Links push to Jira immediately. ```sh -# Default link type -jai link ROX-1 ROX-2 - -# Specify link type (case-insensitive) +# Issue-to-issue link jai link ROX-1 ROX-2 --type "Blocks" +# Remote URL link (detected automatically) +jai link ROX-1 https://github.com/org/repo/pull/42 "PR #42" +jai link ROX-1 https://github.com/org/repo/pull/42 # URL used as title + # List available link types jai link --list-types ``` -If the specified type doesn't exist on your Jira instance, jai lists the available types. +### Watch / Unwatch + +Add or remove watchers on issues. Pushes to Jira immediately. + +```sh +jai watch ROX-123 # add yourself as watcher +jai watch ROX-123 user@example.com # add another user +jai unwatch ROX-123 # remove yourself +``` ### Comments @@ -167,6 +215,33 @@ jai comment ROX-123 "Fixed in PR #4892" ```sh jai create ROX --type Bug --summary "Login fails on SSO" --priority High --labels backend,auth + +# Use a template (defined in config under `templates:`) +jai create ROX --type Bug --template bug-report --summary "Login fails on SSO" + +# Read description from stdin +echo "Detailed description" | jai create ROX --type Bug --summary "Login fails" --body - + +# Inline description +jai create ROX --type Bug --summary "Login fails" --body "Short description here" +``` + +### Clone issues + +Create a copy of an existing issue with optional overrides: + +```sh +jai clone ROX-123 # exact copy +jai clone ROX-123 --summary "New title" # override summary +jai clone ROX-123 --set priority=High # override fields +jai clone ROX-123 --replace "production:staging" # find/replace in summary + description +``` + +### Open in browser + +```sh +jai open ROX-123 # open in default browser +jai open ROX-123 --url-only # print URL to stdout ``` ### Push @@ -175,6 +250,16 @@ jai create ROX --type Bug --summary "Login fails on SSO" --priority High --label jai push ``` +### Shell completions + +Generate shell completion scripts: + +```sh +jai completion bash > /etc/bash_completion.d/jai +jai completion zsh > "${fpath[1]}/_jai" +jai completion fish > ~/.config/fish/completions/jai.fish +``` + --- ## Sync From b1e265af5d7051c594611942f1cbddc2ea7ba07b Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Wed, 22 Jul 2026 22:11:29 +0000 Subject: [PATCH 12/18] fix: resolve staticcheck SA5011 lint warnings in test files --- internal/cli/link_test.go | 2 ++ internal/cli/transition_test.go | 1 + 2 files changed, 3 insertions(+) diff --git a/internal/cli/link_test.go b/internal/cli/link_test.go index a3814d4..34e3e2b 100644 --- a/internal/cli/link_test.go +++ b/internal/cli/link_test.go @@ -13,6 +13,7 @@ func TestLinkCmd_DefaultType(t *testing.T) { flag := cmd.Flags().Lookup("type") if flag == nil { t.Fatal("expected --type flag to exist") + return } if flag.DefValue != "Relates" { t.Errorf("expected default link type %q, got %q", "Relates", flag.DefValue) @@ -24,6 +25,7 @@ func TestLinkCmd_ListTypesFlag(t *testing.T) { flag := cmd.Flags().Lookup("list-types") if flag == nil { t.Fatal("expected --list-types flag to exist") + return } if flag.DefValue != "false" { t.Errorf("expected default list-types %q, got %q", "false", flag.DefValue) diff --git a/internal/cli/transition_test.go b/internal/cli/transition_test.go index 448332c..f020c72 100644 --- a/internal/cli/transition_test.go +++ b/internal/cli/transition_test.go @@ -55,6 +55,7 @@ func TestResolveTransition(t *testing.T) { if match == nil { t.Fatal("expected match, got nil") + return } if match.ID != tt.wantMatch { t.Errorf("expected ID %s, got %s", tt.wantMatch, match.ID) From cd4cf9e32805528a2aa3f3ccc78c054c1b1e098a Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Thu, 23 Jul 2026 09:24:27 +0200 Subject: [PATCH 13/18] fix: skip Jira Rank field when cloning issues extractCloneFields copied every customfield_* value verbatim from the source issue's raw_json, including Rank (a lexoRank string on read). Jira's create-issue API expects an object shape for Rank, so sending the string back caused a 400 (rankBeforeIssue/rankAfterIssue: expected Object). Skip known non-clonable, Jira-managed fields via the field map. --- internal/cli/clone.go | 26 +++++++++++++++++++------- internal/cli/clone_test.go | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/internal/cli/clone.go b/internal/cli/clone.go index e8fc9e5..1427ae7 100644 --- a/internal/cli/clone.go +++ b/internal/cli/clone.go @@ -65,7 +65,12 @@ Examples: return fmt.Errorf("%s", msg) } - fields, project, err := extractCloneFields(rawJSON) + fieldMap, err := g.db.FieldMapByJiraID() + if err != nil { + return err + } + + fields, project, err := extractCloneFields(rawJSON, fieldMap) if err != nil { if g.jsonOut { fmt.Println(string(output.Err("DataError", err.Error()))) @@ -100,10 +105,6 @@ Examples: // Apply --set field=value overrides (reuse field resolution from create). if len(cloneFlags.set) > 0 { - fieldMap, err := g.db.FieldMapByJiraID() - if err != nil { - return err - } for _, kv := range cloneFlags.set { parts := strings.SplitN(kv, "=", 2) if len(parts) != 2 { @@ -165,9 +166,16 @@ Examples: }, } +// nonClonableFields lists Jira field names that are computed/managed by Jira +// and cannot be round-tripped through issue creation. Their GET representation +// (e.g. Rank's lexoRank string) doesn't match what the create API accepts. +var nonClonableFields = map[string]bool{ + "Rank": true, +} + // extractCloneFields parses raw_json and extracts fields suitable for creating // a new issue. Returns the fields map and the project key. -func extractCloneFields(rawJSON string) (map[string]interface{}, string, error) { +func extractCloneFields(rawJSON string, fieldMap map[string]*db.FieldMapping) (map[string]interface{}, string, error) { var apiIssue struct { Fields json.RawMessage `json:"fields"` } @@ -277,9 +285,13 @@ func extractCloneFields(rawJSON string) (map[string]interface{}, string, error) // Story points (commonly customfield_10016 but handled via custom fields below). // Epic link is typically a custom field as well. - // Custom fields: copy any customfield_* values as-is. + // Custom fields: copy any customfield_* values as-is, skipping fields + // that Jira manages internally and can't accept back on create (e.g. Rank). for key, v := range raw { if strings.HasPrefix(key, "customfield_") { + if fm, ok := fieldMap[key]; ok && nonClonableFields[fm.JiraName] { + continue + } var val interface{} if json.Unmarshal(v, &val) == nil && val != nil { fields[key] = val diff --git a/internal/cli/clone_test.go b/internal/cli/clone_test.go index de6c9c0..6e3d4d3 100644 --- a/internal/cli/clone_test.go +++ b/internal/cli/clone_test.go @@ -33,7 +33,7 @@ func TestExtractCloneFields(t *testing.T) { } }` - fields, project, err := extractCloneFields(rawJSON) + fields, project, err := extractCloneFields(rawJSON, nil) if err != nil { t.Fatalf("extractCloneFields: %v", err) } @@ -121,7 +121,7 @@ func TestExtractCloneFieldsMinimal(t *testing.T) { } }` - fields, project, err := extractCloneFields(rawJSON) + fields, project, err := extractCloneFields(rawJSON, nil) if err != nil { t.Fatalf("extractCloneFields: %v", err) } @@ -136,6 +136,35 @@ func TestExtractCloneFieldsMinimal(t *testing.T) { } } +func TestExtractCloneFieldsSkipsRank(t *testing.T) { + rawJSON := `{ + "key": "PROJ-123", + "fields": { + "project": {"key": "PROJ"}, + "summary": "Has a rank", + "issuetype": {"name": "Task"}, + "customfield_10019": "0|iq7psn:", + "customfield_10001": "custom value" + } + }` + + fieldMap := map[string]*db.FieldMapping{ + "customfield_10019": {JiraID: "customfield_10019", JiraName: "Rank", Type: "text"}, + } + + fields, _, err := extractCloneFields(rawJSON, fieldMap) + if err != nil { + t.Fatalf("extractCloneFields: %v", err) + } + + if _, ok := fields["customfield_10019"]; ok { + t.Error("customfield_10019 (Rank) should not be copied — Jira's create API rejects its GET representation") + } + if got := fields["customfield_10001"]; got != "custom value" { + t.Errorf("customfield_10001 = %v, want %q", got, "custom value") + } +} + func TestApplyFieldOverride(t *testing.T) { fieldMap := map[string]*db.FieldMapping{ "customfield_10001": {JiraID: "customfield_10001", Name: "story_points", JiraName: "Story Points", Type: "number"}, From b8db35d1570e104db45a3a7dd95a09e02f7e8128 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Thu, 23 Jul 2026 09:27:56 +0200 Subject: [PATCH 14/18] fix: resolve email to account ID before adding/removing watchers jai watch/unwatch passed the configured "me" email (or a user-supplied email) straight through as the Jira accountId. Jira Cloud's watchers endpoint requires an actual account ID and rejects emails, causing a 404 on POST /rest/api/3/issue/{key}/watchers. Add ResolveAccountID, which looks up the account ID via the user search API when given an identifier that looks like an email, and use it in watch/unwatch. --- internal/cli/watch.go | 22 ++++++++++++++++++++-- internal/jira/client.go | 26 ++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/internal/cli/watch.go b/internal/cli/watch.go index 438eb1a..3df9d03 100644 --- a/internal/cli/watch.go +++ b/internal/cli/watch.go @@ -36,7 +36,16 @@ Examples: return fmt.Errorf("%s", msg) } - if err := g.jira.AddWatcher(cmd.Context(), issueKey, user); err != nil { + accountID, err := g.jira.ResolveAccountID(cmd.Context(), user) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("resolving user: %w", err) + } + + if err := g.jira.AddWatcher(cmd.Context(), issueKey, accountID); err != nil { if g.jsonOut { fmt.Println(string(output.Err("JiraError", err.Error()))) return nil @@ -81,7 +90,16 @@ Examples: return fmt.Errorf("%s", msg) } - if err := g.jira.RemoveWatcher(cmd.Context(), issueKey, user); err != nil { + accountID, err := g.jira.ResolveAccountID(cmd.Context(), user) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("resolving user: %w", err) + } + + if err := g.jira.RemoveWatcher(cmd.Context(), issueKey, accountID); err != nil { if g.jsonOut { fmt.Println(string(output.Err("JiraError", err.Error()))) return nil diff --git a/internal/jira/client.go b/internal/jira/client.go index 14adb39..fa2155c 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -8,6 +8,7 @@ import ( "math" "math/rand" "net/http" + "net/url" "strings" "time" @@ -160,6 +161,31 @@ func (c *Client) MySelf(ctx context.Context) (*MySelf, error) { return &m, nil } +// resolvedUser is a single result from the Jira Cloud user search endpoint. +type resolvedUser struct { + AccountID string `json:"accountId"` +} + +// ResolveAccountID resolves an email address to a Jira Cloud account ID via the +// user search API. Jira Cloud write endpoints (watchers, assignee, etc.) require +// an account ID and reject emails/usernames directly (GDPR changes removed that +// lookup). If identifier doesn't look like an email, it's assumed to already be +// an account ID and is returned unchanged. +func (c *Client) ResolveAccountID(ctx context.Context, identifier string) (string, error) { + if !strings.Contains(identifier, "@") { + return identifier, nil + } + var users []resolvedUser + path := "/rest/api/3/user/search?query=" + url.QueryEscape(identifier) + if err := c.get(ctx, path, &users); err != nil { + return "", fmt.Errorf("resolving user %q: %w", identifier, err) + } + if len(users) == 0 { + return "", fmt.Errorf("no Jira user found matching %q", identifier) + } + return users[0].AccountID, nil +} + // GetProject fetches project metadata (name, key). func (c *Client) GetProject(ctx context.Context, key string) (*ProjectInfo, error) { var p ProjectInfo From 6409a6d334282b5a21119a2dec7c3216c4222385 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Thu, 23 Jul 2026 09:30:42 +0200 Subject: [PATCH 15/18] fix: resolve assignee email to account ID in create and clone Same root cause as the watch/unwatch fix (b8db35d): jai create --assignee and jai clone --set assignee=... both document accepting an email but passed it straight through as accountId, which Jira Cloud rejects. create.go now resolves via jira.ResolveAccountID before building the payload. clone.go's applyFieldOverride takes an optional resolver callback (nil in tests) so it stays decoupled from the Jira client. Verified against staging: jai create --assignee and jai clone --set assignee= both correctly set the assignee. --- internal/cli/clone.go | 20 +++++++++++++++++--- internal/cli/clone_test.go | 37 +++++++++++++++++++++++++++++++++---- internal/cli/create.go | 10 +++++++++- 3 files changed, 59 insertions(+), 8 deletions(-) diff --git a/internal/cli/clone.go b/internal/cli/clone.go index 1427ae7..9707698 100644 --- a/internal/cli/clone.go +++ b/internal/cli/clone.go @@ -116,7 +116,10 @@ Examples: return fmt.Errorf("%s", msg) } name, value := parts[0], parts[1] - if err := applyFieldOverride(fields, fieldMap, name, value); err != nil { + resolveAccountID := func(v string) (string, error) { + return g.jira.ResolveAccountID(cmd.Context(), v) + } + if err := applyFieldOverride(fields, fieldMap, name, value, resolveAccountID); err != nil { if g.jsonOut { fmt.Println(string(output.Err("ValidationError", err.Error()))) return nil @@ -304,7 +307,10 @@ func extractCloneFields(rawJSON string, fieldMap map[string]*db.FieldMapping) (m // applyFieldOverride applies a single key=value override to the fields map, // resolving the field name through the field map (same as jai create). -func applyFieldOverride(fields map[string]interface{}, fieldMap map[string]*db.FieldMapping, name, value string) error { +// resolveAccountID resolves an assignee identifier (email or account ID) to a +// Jira account ID; pass nil to skip resolution (e.g. in tests) and use the +// value as-is. +func applyFieldOverride(fields map[string]interface{}, fieldMap map[string]*db.FieldMapping, name, value string, resolveAccountID func(string) (string, error)) error { // Handle well-known fields by their common names. switch strings.ToLower(name) { case "summary": @@ -314,7 +320,15 @@ func applyFieldOverride(fields map[string]interface{}, fieldMap map[string]*db.F fields["priority"] = map[string]string{"name": value} return nil case "assignee": - fields["assignee"] = map[string]string{"accountId": value} + accountID := value + if resolveAccountID != nil { + resolved, err := resolveAccountID(value) + if err != nil { + return fmt.Errorf("resolving assignee: %w", err) + } + accountID = resolved + } + fields["assignee"] = map[string]string{"accountId": accountID} return nil case "labels": fields["labels"] = expandCSV([]string{value}) diff --git a/internal/cli/clone_test.go b/internal/cli/clone_test.go index 6e3d4d3..ca53b75 100644 --- a/internal/cli/clone_test.go +++ b/internal/cli/clone_test.go @@ -2,6 +2,7 @@ package cli import ( "encoding/json" + "fmt" "reflect" "testing" @@ -238,7 +239,7 @@ func TestApplyFieldOverride(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fields := make(map[string]interface{}) - err := applyFieldOverride(fields, fieldMap, tt.fieldName, tt.value) + err := applyFieldOverride(fields, fieldMap, tt.fieldName, tt.value, nil) if err != nil { t.Fatalf("applyFieldOverride: %v", err) } @@ -257,7 +258,7 @@ func TestApplyFieldOverride(t *testing.T) { func TestApplyFieldOverrideUnknown(t *testing.T) { fieldMap := map[string]*db.FieldMapping{} fields := make(map[string]interface{}) - err := applyFieldOverride(fields, fieldMap, "nonexistent", "value") + err := applyFieldOverride(fields, fieldMap, "nonexistent", "value", nil) if err == nil { t.Fatal("expected error for unknown field") } @@ -266,12 +267,40 @@ func TestApplyFieldOverrideUnknown(t *testing.T) { } } +func TestApplyFieldOverrideAssigneeResolvesAccountID(t *testing.T) { + fields := make(map[string]interface{}) + resolveAccountID := func(v string) (string, error) { + if v != "user@example.com" { + t.Fatalf("resolveAccountID called with %q, want user@example.com", v) + } + return "resolved-account-id", nil + } + err := applyFieldOverride(fields, nil, "assignee", "user@example.com", resolveAccountID) + if err != nil { + t.Fatalf("applyFieldOverride: %v", err) + } + if got, ok := fields["assignee"].(map[string]string); !ok || got["accountId"] != "resolved-account-id" { + t.Errorf("assignee = %v, want {accountId: resolved-account-id}", fields["assignee"]) + } +} + +func TestApplyFieldOverrideAssigneeResolveError(t *testing.T) { + fields := make(map[string]interface{}) + resolveAccountID := func(v string) (string, error) { + return "", fmt.Errorf("no Jira user found matching %q", v) + } + err := applyFieldOverride(fields, nil, "assignee", "nobody@example.com", resolveAccountID) + if err == nil { + t.Fatal("expected error when resolveAccountID fails") + } +} + func TestApplyFieldOverrideJSONValue(t *testing.T) { fieldMap := map[string]*db.FieldMapping{ "customfield_10010": {JiraID: "customfield_10010", Name: "config", Type: "string"}, } fields := make(map[string]interface{}) - err := applyFieldOverride(fields, fieldMap, "config", `{"nested": true}`) + err := applyFieldOverride(fields, fieldMap, "config", `{"nested": true}`, nil) if err != nil { t.Fatalf("applyFieldOverride: %v", err) } @@ -362,7 +391,7 @@ func TestApplyFieldOverrideComponents(t *testing.T) { fieldMap := map[string]*db.FieldMapping{} fields := make(map[string]interface{}) - err := applyFieldOverride(fields, fieldMap, "components", "API,DB") + err := applyFieldOverride(fields, fieldMap, "components", "API,DB", nil) if err != nil { t.Fatalf("applyFieldOverride: %v", err) } diff --git a/internal/cli/create.go b/internal/cli/create.go index acda088..ef60176 100644 --- a/internal/cli/create.go +++ b/internal/cli/create.go @@ -146,7 +146,15 @@ Examples: } if createFlags.assignee != "" { - fields["assignee"] = map[string]string{"accountId": createFlags.assignee} + accountID, err := g.jira.ResolveAccountID(cmd.Context(), createFlags.assignee) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("resolving assignee: %w", err) + } + fields["assignee"] = map[string]string{"accountId": accountID} } if createFlags.fixVersion != "" { From 545dc35a181ffe0f466a425d30b1cc72da322c0c Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Thu, 23 Jul 2026 10:35:15 +0200 Subject: [PATCH 16/18] fix: correct jai set value serialization and bulk local-update gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jai set priority/assignee/reporter/components/fixVersions always sent bare strings (or arrays of bare strings) to Jira's write API, which rejects them for these reference fields (e.g. 400: "Specify the Priority (id or name) in the string format"). Add wrapScalarFieldValue and wrapArrayItemValue to shape values correctly (name/accountId/array-of- objects) based on the Jira field ID, with assignee/reporter resolved through jira.ResolveAccountID. UpdateFieldOp's value param is now interface{} so --add/--remove ops can also carry object values. Also fix setBulk (comma-separated keys and --query) to run the same optimistic local DB update that the single-key path already did — bulk changes previously queued correctly but left the local cache stale until the next sync. Verified against staging: set priority/assignee/components on real issues now pushes successfully and reflects live on Jira; bulk set updates the local DB immediately. --- internal/cli/set.go | 150 +++++++++++++++++++++++++++++++++++---- internal/cli/set_test.go | 128 +++++++++++++++++++++++++++++++++ internal/jira/write.go | 6 +- internal/sync/writer.go | 6 +- 4 files changed, 273 insertions(+), 17 deletions(-) diff --git a/internal/cli/set.go b/internal/cli/set.go index ee62511..04bff09 100644 --- a/internal/cli/set.go +++ b/internal/cli/set.go @@ -153,15 +153,73 @@ Bulk operations with a SQL query: }, } +// wrapScalarFieldValue converts a scalar string value into the object shape +// Jira's write API requires for reference fields (priority, assignee, reporter). +// ok is false for fields that accept a bare string/number/date as-is, in which +// case the caller should use the raw value. resolveAccountID resolves an +// assignee/reporter identifier to an account ID; pass nil to use the raw value +// unresolved (e.g. in tests). +func wrapScalarFieldValue(jiraID, value string, resolveAccountID func(string) (string, error)) (result interface{}, ok bool, err error) { + switch jiraID { + case "priority": + return map[string]string{"name": value}, true, nil + case "assignee", "reporter": + accountID := value + if resolveAccountID != nil { + resolved, rerr := resolveAccountID(value) + if rerr != nil { + return nil, true, rerr + } + accountID = resolved + } + return map[string]string{"accountId": accountID}, true, nil + } + return nil, false, nil +} + +// wrapArrayItemValue converts a single array-item string into the object shape +// Jira's write API requires for reference-array fields (components, fixVersions). +// ok is false for plain string arrays (e.g. labels), where the raw string is used. +func wrapArrayItemValue(jiraID, value string) (result interface{}, ok bool) { + switch jiraID { + case "components", "fixVersions": + return map[string]string{"name": value}, true + } + return nil, false +} + func setScalarField(cmd *cobra.Command, issueKey, fieldName, jiraID, value, fieldType string) error { var payloadVal interface{} = value localVal := value if fieldType == "array" { - arr := parseArrayValue(value) - payloadVal = arr - j, _ := json.Marshal(arr) + items := parseArrayValue(value) + wrapped := make([]interface{}, len(items)) + for i, item := range items { + if w, ok := wrapArrayItemValue(jiraID, item); ok { + wrapped[i] = w + } else { + wrapped[i] = item + } + } + payloadVal = wrapped + j, _ := json.Marshal(items) localVal = string(j) + } else { + resolveAccountID := func(v string) (string, error) { + return g.jira.ResolveAccountID(cmd.Context(), v) + } + if wrapped, ok, err := wrapScalarFieldValue(jiraID, value, resolveAccountID); ok { + if err != nil { + msg := fmt.Sprintf("resolving %s: %v", fieldName, err) + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + payloadVal = wrapped + } } payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": payloadVal}) @@ -236,29 +294,89 @@ func setBulk(cmd *cobra.Command, keys []string, fieldName, jiraID, value, fieldT hasAdd := len(setAddValues) > 0 hasRemove := len(setRemoveValues) > 0 + var scalarPayloadVal interface{} + var scalarLocalVal string + if !hasAdd && !hasRemove { + scalarPayloadVal = value + scalarLocalVal = value + if fieldType == "array" { + items := parseArrayValue(value) + wrapped := make([]interface{}, len(items)) + for i, item := range items { + if w, ok := wrapArrayItemValue(jiraID, item); ok { + wrapped[i] = w + } else { + wrapped[i] = item + } + } + scalarPayloadVal = wrapped + j, _ := json.Marshal(items) + scalarLocalVal = string(j) + } else { + resolveAccountID := func(v string) (string, error) { + return g.jira.ResolveAccountID(cmd.Context(), v) + } + if wrapped, ok, err := wrapScalarFieldValue(jiraID, value, resolveAccountID); ok { + if err != nil { + msg := fmt.Sprintf("resolving %s: %v", fieldName, err) + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + scalarPayloadVal = wrapped + } + } + } + for _, key := range keys { if hasAdd || hasRemove { for _, v := range setAddValues { - payload, _ := json.Marshal(map[string]string{"field": jiraID, "op": "add", "value": v}) + var val interface{} = v + if w, ok := wrapArrayItemValue(jiraID, v); ok { + val = w + } + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "op": "add", "value": val}) if err := g.db.InsertPendingChange(key, "update_field", string(payload)); err != nil { return err } } for _, v := range setRemoveValues { - payload, _ := json.Marshal(map[string]string{"field": jiraID, "op": "remove", "value": v}) + var val interface{} = v + if w, ok := wrapArrayItemValue(jiraID, v); ok { + val = w + } + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "op": "remove", "value": val}) if err := g.db.InsertPendingChange(key, "update_field", string(payload)); err != nil { return err } } - } else { - var payloadVal interface{} = value - if fieldType == "array" { - payloadVal = parseArrayValue(value) + + current := readCurrentArray(key, fieldName) + updated := applyArrayOps(current, setAddValues, setRemoveValues) + var keyLocalVal string + if len(updated) > 0 { + b, _ := json.Marshal(updated) + keyLocalVal = string(b) + } + if _, err := g.db.Exec( + fmt.Sprintf("UPDATE issues SET %s = ?, synced_at = datetime('now') WHERE key = ?", fieldName), + keyLocalVal, key, + ); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed for %s: %v\n", key, err) } - payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": payloadVal}) + } else { + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": scalarPayloadVal}) if err := g.db.InsertPendingChange(key, "set_field", string(payload)); err != nil { return err } + if _, err := g.db.Exec( + fmt.Sprintf("UPDATE issues SET %s = ?, synced_at = datetime('now') WHERE key = ?", fieldName), + scalarLocalVal, key, + ); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed for %s: %v\n", key, err) + } } } @@ -275,13 +393,21 @@ func setBulk(cmd *cobra.Command, keys []string, fieldName, jiraID, value, fieldT func setArrayField(cmd *cobra.Command, issueKey, fieldName, jiraID string) error { for _, v := range setAddValues { - payload, _ := json.Marshal(map[string]string{"field": jiraID, "op": "add", "value": v}) + var val interface{} = v + if w, ok := wrapArrayItemValue(jiraID, v); ok { + val = w + } + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "op": "add", "value": val}) if err := g.db.InsertPendingChange(issueKey, "update_field", string(payload)); err != nil { return err } } for _, v := range setRemoveValues { - payload, _ := json.Marshal(map[string]string{"field": jiraID, "op": "remove", "value": v}) + var val interface{} = v + if w, ok := wrapArrayItemValue(jiraID, v); ok { + val = w + } + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "op": "remove", "value": val}) if err := g.db.InsertPendingChange(issueKey, "update_field", string(payload)); err != nil { return err } diff --git a/internal/cli/set_test.go b/internal/cli/set_test.go index 5088b02..51260f0 100644 --- a/internal/cli/set_test.go +++ b/internal/cli/set_test.go @@ -2,9 +2,137 @@ package cli import ( "encoding/json" + "fmt" "testing" ) +func TestWrapScalarFieldValue(t *testing.T) { + tests := []struct { + name string + jiraID string + value string + resolve func(string) (string, error) + want interface{} + wantOK bool + wantErr bool + }{ + { + name: "priority wraps as name object", + jiraID: "priority", + value: "Major", + want: map[string]string{"name": "Major"}, + wantOK: true, + }, + { + name: "assignee wraps as accountId object without resolver", + jiraID: "assignee", + value: "user123", + want: map[string]string{"accountId": "user123"}, + wantOK: true, + }, + { + name: "reporter resolves via callback", + jiraID: "reporter", + value: "user@example.com", + resolve: func(v string) (string, error) { + return "resolved-id", nil + }, + want: map[string]string{"accountId": "resolved-id"}, + wantOK: true, + }, + { + name: "resolver error propagates", + jiraID: "assignee", + value: "nobody@example.com", + resolve: func(v string) (string, error) { + return "", fmt.Errorf("no user found") + }, + wantOK: true, + wantErr: true, + }, + { + name: "plain text field is not wrapped", + jiraID: "summary", + value: "New Summary", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok, err := wrapScalarFieldValue(tt.jiraID, tt.value, tt.resolve) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !tt.wantOK { + return + } + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(tt.want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("got %s, want %s", gotJSON, wantJSON) + } + }) + } +} + +func TestWrapArrayItemValue(t *testing.T) { + tests := []struct { + name string + jiraID string + value string + want interface{} + wantOK bool + }{ + { + name: "components wraps as name object", + jiraID: "components", + value: "API", + want: map[string]string{"name": "API"}, + wantOK: true, + }, + { + name: "fixVersions wraps as name object", + jiraID: "fixVersions", + value: "1.0", + want: map[string]string{"name": "1.0"}, + wantOK: true, + }, + { + name: "labels is not wrapped", + jiraID: "labels", + value: "bug", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := wrapArrayItemValue(tt.jiraID, tt.value) + if ok != tt.wantOK { + t.Fatalf("ok = %v, want %v", ok, tt.wantOK) + } + if !ok { + return + } + gotJSON, _ := json.Marshal(got) + wantJSON, _ := json.Marshal(tt.want) + if string(gotJSON) != string(wantJSON) { + t.Errorf("got %s, want %s", gotJSON, wantJSON) + } + }) + } +} + func TestParseArrayValue(t *testing.T) { tests := []struct { name string diff --git a/internal/jira/write.go b/internal/jira/write.go index e599439..6bf2b0b 100644 --- a/internal/jira/write.go +++ b/internal/jira/write.go @@ -39,10 +39,12 @@ func (c *Client) UpdateField(ctx context.Context, issueKey, fieldID string, valu } // UpdateFieldOp performs an add or remove operation on an array field using Jira's update API. -func (c *Client) UpdateFieldOp(ctx context.Context, issueKey, fieldID, op, value string) error { +// value may be a bare string (e.g. a label) or an object (e.g. {"name": "..."} for +// components/fixVersions), depending on what the target field requires. +func (c *Client) UpdateFieldOp(ctx context.Context, issueKey, fieldID, op string, value interface{}) error { payload := map[string]interface{}{ "update": map[string]interface{}{ - fieldID: []map[string]string{ + fieldID: []map[string]interface{}{ {op: value}, }, }, diff --git a/internal/sync/writer.go b/internal/sync/writer.go index eff76bc..adbbc2e 100644 --- a/internal/sync/writer.go +++ b/internal/sync/writer.go @@ -100,9 +100,9 @@ func (w *Writer) processSetField(ctx context.Context, c *db.PendingChange) error func (w *Writer) processUpdateField(ctx context.Context, c *db.PendingChange) error { var payload struct { - Field string `json:"field"` - Op string `json:"op"` - Value string `json:"value"` + Field string `json:"field"` + Op string `json:"op"` + Value interface{} `json:"value"` } if err := json.Unmarshal([]byte(c.Payload), &payload); err != nil { return fmt.Errorf("parsing update_field payload: %w", err) From 627f7989d7679502742c92f9bdfcd48ba50e2ee4 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Thu, 23 Jul 2026 10:35:20 +0200 Subject: [PATCH 17/18] fix: refresh local DB after jai transition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processTransition pushed the transition to Jira and marked the pending change synced, but never updated the issues table — status (and any workflow side effects like resolution) stayed stale until the next sync, breaking the DB-first invariant that local state reflects the latest write immediately. Re-fetch and re-denormalize the issue after a successful transition, mirroring the same pattern jai clone already uses after create. Verified against staging: after transitioning an issue, a local query immediately shows the new status without a manual sync. --- internal/cli/transition.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/internal/cli/transition.go b/internal/cli/transition.go index ace67c6..47caf5b 100644 --- a/internal/cli/transition.go +++ b/internal/cli/transition.go @@ -129,6 +129,17 @@ var transitionCmd = &cobra.Command{ } } + // Refresh the local DB from Jira so status (and any workflow side effects, + // e.g. resolution) are immediately queryable instead of stale until next sync. + if apiIssue, fetchErr := g.jira.GetIssue(cmd.Context(), issueKey); fetchErr == nil { + rawJSON, _ := json.Marshal(apiIssue) + if fieldMap, fmErr := g.db.FieldMapByJiraID(); fmErr == nil { + if dbIssue, extra, denormErr := synce.Denormalize(rawJSON, fieldMap); denormErr == nil { + _ = g.db.UpsertIssue(dbIssue, extra) + } + } + } + if g.jsonOut { fmt.Println(string(output.OK(map[string]string{ "issue_key": issueKey, From b04e4923fa09f7d998b2265f636c3d8eb633cf52 Mon Sep 17 00:00:00 2001 From: Sukumar Yethadka Date: Thu, 23 Jul 2026 10:35:26 +0200 Subject: [PATCH 18/18] fix: derive {{projects}} template variable from jql-based sync sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit projectKeys() only read cfg.SyncSources[].Projects (the explicit 'projects:' YAML list), so {{projects}} silently expanded to an empty string for sources defined via 'jql:' — a documented, valid config style this project's own staging.yaml uses ('jql: PROJECT = ROX'). Add a best-effort projectKeysFromJQL fallback that extracts 'project = KEY' / 'project in (KEY1, KEY2)' clauses when no explicit projects list is set. Arbitrary JQL isn't fully parsed, but this covers the common single/multi-project sync source pattern. Verified against staging: {{projects}} now expands to 'ROX' using the real staging.yaml config. --- internal/query/engine.go | 33 ++++++++++++++++++++++++++++++-- internal/query/engine_test.go | 36 +++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/internal/query/engine.go b/internal/query/engine.go index 07153b3..ff75c03 100644 --- a/internal/query/engine.go +++ b/internal/query/engine.go @@ -117,12 +117,19 @@ func mondayOfWeek(t time.Time) time.Time { } // projectKeys returns a comma-separated list of single-quoted project keys -// from all configured sync sources (e.g., 'PROJ1','PROJ2'). +// from all configured sync sources (e.g., 'PROJ1','PROJ2'). Sources defined via +// an explicit 'projects:' list are used directly; sources defined via 'jql:' +// fall back to a best-effort extraction of 'project = X' / 'project in (X, Y)' +// clauses, since arbitrary JQL isn't fully parsed. func projectKeys(cfg *config.Config) string { seen := map[string]bool{} var keys []string for _, src := range cfg.SyncSources { - for _, p := range src.Projects { + projects := src.Projects + if len(projects) == 0 && src.JQL != "" { + projects = projectKeysFromJQL(src.JQL) + } + for _, p := range projects { if !seen[p] { seen[p] = true keys = append(keys, "'"+p+"'") @@ -132,6 +139,28 @@ func projectKeys(cfg *config.Config) string { return strings.Join(keys, ",") } +// projectClauseRe matches a 'project = KEY' or 'project in (KEY1, KEY2)' clause +// within a JQL string, stopping at a following AND/OR or the end of the string. +var projectClauseRe = regexp.MustCompile(`(?i)project\s*(?:=|in)\s*\(?\s*([A-Za-z0-9_,\s"']+?)\s*\)?(?:\s+and\b|\s+or\b|$)`) + +// projectKeysFromJQL extracts project keys referenced by a simple 'project = KEY' +// or 'project in (KEY1, KEY2)' clause in a JQL string. Best-effort — arbitrary +// JQL isn't fully parsed, but this covers the common sync source pattern. +func projectKeysFromJQL(jql string) []string { + m := projectClauseRe.FindStringSubmatch(jql) + if m == nil { + return nil + } + var keys []string + for _, part := range strings.Split(m[1], ",") { + key := strings.Trim(strings.TrimSpace(part), `"'`) + if key != "" { + keys = append(keys, key) + } + } + return keys +} + // builtinVarNames is the set of built-in template variable names (without braces). // Used to distinguish built-in variables from user-defined snippets. var builtinVarNames = map[string]bool{ diff --git a/internal/query/engine_test.go b/internal/query/engine_test.go index 44c3b98..b408084 100644 --- a/internal/query/engine_test.go +++ b/internal/query/engine_test.go @@ -278,6 +278,42 @@ func TestResolveTemplates_Projects(t *testing.T) { &config.Config{}, "", }, + { + "jql source with simple equality", + &config.Config{ + SyncSources: []config.SyncSource{ + {JQL: "PROJECT = ROX"}, + }, + }, + "'ROX'", + }, + { + "jql source with IN clause", + &config.Config{ + SyncSources: []config.SyncSource{ + {JQL: "project in (ROX, OTHER)"}, + }, + }, + "'ROX','OTHER'", + }, + { + "jql source with trailing clause", + &config.Config{ + SyncSources: []config.SyncSource{ + {JQL: "PROJECT = ROX AND status != Done"}, + }, + }, + "'ROX'", + }, + { + "explicit projects list takes precedence over jql", + &config.Config{ + SyncSources: []config.SyncSource{ + {JQL: "PROJECT = ROX", Projects: []string{"OVERRIDE"}}, + }, + }, + "'OVERRIDE'", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) {