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 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/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. 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 diff --git a/internal/cli/clone.go b/internal/cli/clone.go new file mode 100644 index 0000000..9707698 --- /dev/null +++ b/internal/cli/clone.go @@ -0,0 +1,400 @@ +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) + } + + 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()))) + 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 { + 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] + 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 + } + 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 + }, +} + +// 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, fieldMap map[string]*db.FieldMapping) (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, 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 + } + } + } + + 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). +// 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": + fields["summary"] = value + return nil + case "priority": + fields["priority"] = map[string]string{"name": value} + return nil + case "assignee": + 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}) + 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..ca53b75 --- /dev/null +++ b/internal/cli/clone_test.go @@ -0,0 +1,406 @@ +package cli + +import ( + "encoding/json" + "fmt" + "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, nil) + 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, nil) + 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 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"}, + } + + 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, nil) + 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", nil) + 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 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}`, nil) + 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", nil) + 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/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") + } +} diff --git a/internal/cli/create.go b/internal/cli/create.go index bf40200..ef60176 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, @@ -102,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 != "" { @@ -199,6 +251,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 +305,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/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..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) @@ -43,8 +45,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 +60,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) + } +} 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 6996a8d..3fa27d0 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -36,12 +36,15 @@ var g globals var noAutoSync = map[string]bool{ "sync": true, "init": true, + "clone": true, "create": true, "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, "help": true, } diff --git a/internal/cli/schema.go b/internal/cli/schema.go index e437a8d..871fe8c 100644 --- a/internal/cli/schema.go +++ b/internal/cli/schema.go @@ -3,10 +3,13 @@ package cli import ( "fmt" "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. @@ -111,6 +114,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 +283,87 @@ 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 + }, +} + +// 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/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/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, 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) diff --git a/internal/cli/watch.go b/internal/cli/watch.go new file mode 100644 index 0000000..3df9d03 --- /dev/null +++ b/internal/cli/watch.go @@ -0,0 +1,127 @@ +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) + } + + 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 + } + 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) + } + + 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 + } + 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/config/config.go b/internal/config/config.go index 40c8946..583bc8c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -32,8 +32,10 @@ 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"` + Snippets map[string]string `yaml:"snippets"` } // HierarchyLevel defines one level in the issue hierarchy. 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 diff --git a/internal/jira/write.go b/internal/jira/write.go index 20dc85b..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}, }, }, @@ -117,6 +119,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 { diff --git a/internal/query/engine.go b/internal/query/engine.go index 72b63eb..ff75c03 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,15 @@ 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+)\}\}`) + +// 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 @@ -31,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 } @@ -82,16 +96,213 @@ func requireReadOnly(sql string) error { } // resolveTemplates replaces {{variable}} placeholders with their values. -func (e *Engine) resolveTemplates(sql string) string { - now := time.Now() +func (e *Engine) resolveTemplates(sql string) (string, error) { + 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'). 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 { + 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+"'") + } + } + } + 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{ + "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. +// 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{ - "{{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 } + +// 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 77c390c..b408084 100644 --- a/internal/query/engine_test.go +++ b/internal/query/engine_test.go @@ -2,7 +2,9 @@ package query import ( "path/filepath" + "strings" "testing" + "time" "github.com/sthadka/jai/internal/config" "github.com/sthadka/jai/internal/db" @@ -54,6 +56,298 @@ 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, 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) + } + }) + } +} + +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, 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) + } + } +} + +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, 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) + } + }) + } +} + +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, 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) + } + }) + } +} + +func TestResolveTemplates_DaysAgo7EqualsWeekAgo(t *testing.T) { + now := time.Date(2024, 7, 17, 12, 0, 0, 0, time.UTC) + cfg := &config.Config{} + + 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) + } +} + +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, 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" { + 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, 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) + } + }) + } +} + +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{}, + "", + }, + { + "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) { + 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) + } + }) + } +} + +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, 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) + } +} + func TestTable_Empty(t *testing.T) { r := &Results{Columns: []string{"key"}, Rows: nil, Count: 0} out := r.Table() @@ -74,6 +368,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"}, 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)