diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index e69de29..7942fb5 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -0,0 +1,23 @@ +{"id":"int-ff04f17d106322290e4268157600a17a","kind":"field_change","created_at":"2026-07-22T14:56:04.299295Z","actor":"Sukumar Yethadka","issue_id":"jai-3xx","extra":{"field":"priority","new_value":"0","old_value":"2"}} +{"id":"int-35c2bd06150edfe2a73260097bf66df3","kind":"field_change","created_at":"2026-07-22T14:56:04.66326Z","actor":"Sukumar Yethadka","issue_id":"jai-z8o","extra":{"field":"priority","new_value":"0","old_value":"2"}} +{"id":"int-c2fd3057f93f87947927bb4fd39fff61","kind":"field_change","created_at":"2026-07-22T14:56:05.048303Z","actor":"Sukumar Yethadka","issue_id":"jai-wmz","extra":{"field":"priority","new_value":"1","old_value":"2"}} +{"id":"int-cbb618ce2fb727dcf0a70b1fda341a5e","kind":"field_change","created_at":"2026-07-22T14:56:05.418541Z","actor":"Sukumar Yethadka","issue_id":"jai-27b","extra":{"field":"priority","new_value":"3","old_value":"2"}} +{"id":"int-0698e1bfc756e55ec5bd8c7470846139","kind":"field_change","created_at":"2026-07-22T14:56:05.815648Z","actor":"Sukumar Yethadka","issue_id":"jai-d3j","extra":{"field":"priority","new_value":"3","old_value":"2"}} +{"id":"int-75ef2f586c96248d241c1be8405701ad","kind":"field_change","created_at":"2026-07-22T15:04:54.506249Z","actor":"Sukumar Yethadka","issue_id":"jai-3xx","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-1a14742a34596a838ca91cc53288d291","kind":"field_change","created_at":"2026-07-22T15:05:00.843542Z","actor":"Sukumar Yethadka","issue_id":"jai-wmz","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-8afb830acb5d700ae83610763e604999","kind":"field_change","created_at":"2026-07-22T15:06:43.29617Z","actor":"Sukumar Yethadka","issue_id":"jai-wmz","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"SUMMARY: Added jai transition CLI command with case-insensitive name resolution, --list flag, ambiguous match detection, immediate auto-push via Writer.ProcessQueue(), and JSON output support. All 93 tests pass.. FILES: internal/cli/transition.go,internal/cli/transition_test.go. CONTEXT: 25%"}} +{"id":"int-72704933fea17cb46ecb2658f53f6416","kind":"field_change","created_at":"2026-07-22T15:07:00.766742Z","actor":"Sukumar Yethadka","issue_id":"jai-3xx","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"SUMMARY: Added --add and --remove flags to jai set for array fields. Validates field type via FieldMapping.Type, queues update_field operations with add/remove op, writer dispatches to new UpdateFieldOp Jira client method using update API format. Optimistic local update reads current JSON array, applies ops, writes back. 8 unit tests for applyArrayOps covering add, remove, duplicates, simultaneous ops.. FILES: internal/cli/set.go,internal/sync/writer.go,internal/jira/write.go,internal/cli/set_test.go. CONTEXT: 30%"}} +{"id":"int-4211fbb5c48427ad87582402de927b45","kind":"field_change","created_at":"2026-07-22T15:08:00.912028Z","actor":"Sukumar Yethadka","issue_id":"jai-z8o","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-21cd5eb358247773d03f9f4d9f939121","kind":"field_change","created_at":"2026-07-22T15:08:04.914611Z","actor":"Sukumar Yethadka","issue_id":"jai-d3j","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-34f515a2b2d5d31add9acdcdb8949121","kind":"field_change","created_at":"2026-07-22T15:09:43.545921Z","actor":"Sukumar Yethadka","issue_id":"jai-d3j","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"SUMMARY: Added jai link command with CreateLink and GetLinkTypes Jira client methods. Command supports default Relates type, --type flag, --list-types, and JSON output. Direct API call, not queued. All 106 tests pass.. FILES: internal/cli/link.go,internal/cli/link_test.go,internal/jira/write.go. CONTEXT: 25%"}} +{"id":"int-1bbf2e829f40c6d8f08c7985b4a3cc55","kind":"field_change","created_at":"2026-07-22T15:10:18.305615Z","actor":"Sukumar Yethadka","issue_id":"jai-z8o","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"SUMMARY: Fixed array field serialization in set command. When fieldType is array, setScalarField now splits comma-separated input via parseArrayValue, stores the value as a JSON array in pending_changes payload (using map[string]interface{} instead of map[string]string), and stores JSON-marshaled array for the optimistic local DB update. Writer processSetField already uses interface{} for Value so it sends the array correctly to Jira. Added tests for parseArrayValue and round-trip serialization.. FILES: internal/cli/set.go,internal/cli/set_test.go. CONTEXT: 25%"}} +{"id":"int-1827d557286be7d7fdbac00c189d91da","kind":"field_change","created_at":"2026-07-22T15:11:06.972043Z","actor":"Sukumar Yethadka","issue_id":"jai-27b","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-f1c69f67e2ccd81f79d9256e192da21f","kind":"field_change","created_at":"2026-07-22T15:14:52.477697Z","actor":"Sukumar Yethadka","issue_id":"jai-27b","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"SUMMARY: Added --query flag and comma-separated key expansion to jai set for bulk operations. --query runs SQL via g.query.Execute, extracts key column, queues pending changes for all matching issues. Comma-separated keys (ROX-1,ROX-2) are split and expanded. Bulk mode prints summary count (human) or {count,keys} JSON. Zero-row queries produce clear error. Unit tests for expandKeys and extractKeys.. FILES: internal/cli/set.go. CONTEXT: 30%"}} +{"id":"int-e340284f611af6fb7cc79524dc4e5899","kind":"field_change","created_at":"2026-07-23T07:28:45.63826Z","actor":"Sukumar Yethadka","issue_id":"jai-j1d","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-30a9376439036d3c0bc830aa1547e27e","kind":"field_change","created_at":"2026-07-23T07:30:48.986332Z","actor":"Sukumar Yethadka","issue_id":"jai-j1d","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed in 6409a6d: create.go and clone.go now resolve assignee emails to account IDs via jira.ResolveAccountID, same fix pattern as watch/unwatch (b8db35d). Verified against staging."}} +{"id":"int-21a68b7a0a1bd187b1ec35c7cf451612","kind":"field_change","created_at":"2026-07-23T08:35:34.103994Z","actor":"Sukumar Yethadka","issue_id":"jai-51m","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in 545dc35: added wrapScalarFieldValue/wrapArrayItemValue in internal/cli/set.go to shape priority/assignee/reporter/components/fixVersions correctly for Jira's write API, plus made UpdateFieldOp's value param interface{} to carry object values for --add/--remove. Verified against staging: priority, assignee, and components all set and pushed correctly."}} +{"id":"int-4df70fffe389bd55a80de4fd2b227bf4","kind":"field_change","created_at":"2026-07-23T08:35:34.788082Z","actor":"Sukumar Yethadka","issue_id":"jai-nv0","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in 545dc35 (same commit as jai-51m): setBulk now runs the same optimistic local DB UPDATE that the single-key path already did, for both scalar and --add/--remove bulk operations. Verified against staging: bulk set on 2 issues updated local DB immediately."}} +{"id":"int-bf1bf4026e45868886491a50ae3149fb","kind":"field_change","created_at":"2026-07-23T08:35:35.336914Z","actor":"Sukumar Yethadka","issue_id":"jai-jjy","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in 627f798: transition.go now re-fetches and re-denormalizes the issue after a successful transition push, mirroring the pattern jai clone already uses. Verified against staging: status reflects immediately after transition without a manual sync."}} +{"id":"int-3d641b17b7d7b329df145f8595df72cb","kind":"field_change","created_at":"2026-07-23T08:35:35.821812Z","actor":"Sukumar Yethadka","issue_id":"jai-1m4","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in b04e492: added projectKeysFromJQL best-effort fallback in internal/query/engine.go for sync sources defined via jql: instead of an explicit projects: list. Verified against staging: {{projects}} now expands to 'ROX' using the real staging.yaml."}} +{"id":"int-37b9441dfd4e789c6df8faf5ddfd6374","kind":"field_change","created_at":"2026-07-27T12:12:59.742155Z","actor":"Sukumar Yethadka","issue_id":"jai-ijr","extra":{"field":"status","new_value":"in_progress","old_value":"open"}} +{"id":"int-6969fc45fbe4bae456922ab3d887b86b","kind":"field_change","created_at":"2026-07-27T12:14:12.934354Z","actor":"Sukumar Yethadka","issue_id":"jai-ijr","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Fixed: reuse stored DB name for existing fields instead of re-inferring from Jira metadata"}} diff --git a/.gitignore b/.gitignore index 442e91f..ac1274a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ jai # Beads / Dolt files (added by bd init) .beads-credential-key .beads/proxieddb/ + +# TreeFlow context (auto-added) +.beads/ diff --git a/CLAUDE.md b/CLAUDE.md index 2a809d3..3b52214 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ When working in any codebase with LSP support (Go, Python, Rust, TypeScript, etc **Key architectural decisions**: - DB-first: SQLite is the single source of truth; no command hits Jira API directly for reads - All fields downloaded as raw JSON and denormalized into queryable columns — no resync needed for new fields -- Write operations queue locally in `pending_changes` table, synced to Jira via `jai push` or background goroutine +- Write operations push to Jira immediately by default; use `--queue` to defer to `pending_changes` table and `jai push` - Custom field names auto-discovered from Jira's field metadata API and stored in `field_map` table - FTS5 virtual table (`issues_fts`) with porter unicode61 tokenizer for full-text search - WAL mode + pragmas for concurrent read/write performance @@ -93,7 +93,7 @@ internal/ - Phase 1: Foundation — sync, query, get (core data loop) - Phase 2: Agent Mode — --json, --fields, jai schema, jai fields, auto-sync - Phase 3: TUI — full-screen views, sorting, filtering, grouping, background sync -- Phase 4: Write Path — jai set, jai comment, jai push, pending_changes +- Phase 4: Write Path — jai set, jai comment, jai push, write-through default with --queue opt-in - Phase 5: Polish — jai init wizard, FTS5 search, color rules, default views, error UX - Phase 6: Release — README, Homebrew, CI/CD, vhs demo diff --git a/README.md b/README.md index ea91e3c..b98d64f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ jai takes a different approach: sync once to a local SQLite database, then query - **10–50× fewer tokens** for AI agents. Select exactly the fields you need. - **Instant queries** from local SQLite. No waiting for the API. - **Full SQL power** — JOINs, aggregations, CTEs, window functions, FTS5. Things JQL will never do. -- **Works offline.** Writes queue locally and sync to Jira when you're back online. +- **Write-through by default.** Changes push to Jira immediately. Use `--queue` / `-q` to defer writes for offline use. - **Full-screen TUI** that replaces the Jira web UI for daily workflows. --- @@ -291,7 +291,7 @@ views: ## Write operations -Changes queue locally and sync to Jira on the next `jai push` or background sync cycle. +Changes push to Jira immediately by default. Use `--queue` / `-q` to defer changes for the next `jai push` or background sync cycle. ```sh # Create a new issue (hits Jira API directly, returns key immediately) @@ -327,21 +327,25 @@ jai clone ROX-4821 --replace "production:staging" # Update a field jai set ROX-4821 priority High -# → ROX-4821: priority → "High" (pending sync) +# → ROX-4821: priority → "High" ✓ # Array fields — add/remove individual values jai set ROX-4821 labels --add backend --add auth -# → ROX-4821: labels += [backend auth] (pending sync) +# → ROX-4821: labels += [backend auth] ✓ jai set ROX-4821 labels --remove backend -# → ROX-4821: labels -= [backend] (pending sync) +# → ROX-4821: labels -= [backend] ✓ # Bulk set — comma-separated keys or SQL query jai set ROX-1,ROX-2,ROX-3 priority Major -# → queued 3 changes (pending sync) +# → 3 changes pushed ✓ jai set --query "SELECT key FROM issues WHERE type = 'Bug' LIMIT 5" priority Major -# → queued 5 changes (pending sync) +# → 5 changes pushed ✓ -# Transition an issue (pushes immediately) +# Use --queue to defer changes instead of pushing immediately +jai set ROX-4821 priority High --queue +# → ROX-4821: priority → "High" (queued) + +# Transition an issue jai transition ROX-4821 "In Progress" # → ROX-4821: transitioned to "In Progress" jai transition ROX-4821 --list @@ -368,9 +372,13 @@ 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) +# → ROX-4821: comment added ✓ + +# Use --queue to defer +jai comment ROX-4821 "Will investigate tomorrow" --queue +# → ROX-4821: comment added (queued) -# Push all pending changes +# Push queued changes (only needed after --queue) jai push # → ✓ ROX-4821: priority → "High" # → ✓ ROX-4821: labels updated @@ -450,7 +458,7 @@ Both paths can be overridden with `--config` and `--db` flags, or by setting `db | `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 ` | Update an issue field (pushes immediately; `--queue` to defer) | | `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 | @@ -459,8 +467,8 @@ Both paths can be overridden with `--config` and `--db` flags, or by setting `db | `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 comment ` | Add a comment (pushes immediately; `--queue` to defer) | +| `jai push` | Push queued changes to Jira (only needed after `--queue`) | | `jai tui` | Launch full-screen TUI | | `jai completion ` | Generate shell completions (bash/zsh/fish) | diff --git a/docs/spec.md b/docs/spec.md index 1df6154..769f84e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -483,6 +483,10 @@ This runs during `jai sync --full` and optionally on a configurable schedule. ### Write Queue Processing +By default, `jai set`, `jai comment`, and `jai transition` call the Jira API immediately (write-through). Pass `--queue` / `-q` to defer the change to the `pending_changes` table instead. + +When `--queue` is used, changes accumulate in `pending_changes` and are pushed to Jira via `jai push`. + ```go func (w *Writer) ProcessQueue(ctx context.Context) error { // 1. SELECT from pending_changes WHERE synced_at IS NULL ORDER BY created_at @@ -692,20 +696,29 @@ Output: Sync progress and summary ``` Input: issue key, field name, new value (positional) -Output: Confirmation + pending status +Flags: --queue / -q Queue the change instead of writing immediately +Output: Confirmation + status - ROX-123: status → "In Progress" (pending sync) + ROX-123: status → "In Progress" ✓ # default (write-through) + ROX-123: status → "In Progress" (queued) # with --queue -Writes to pending_changes table. If auto_sync is on, immediately attempts to push. +Default behavior calls the Jira API immediately. With --queue, writes to +pending_changes table instead; use `jai push` to sync queued changes. ``` #### `jai comment ` ``` Input: issue key, comment text (positional) -Output: Confirmation + pending status +Flags: --queue / -q Queue the comment instead of posting immediately +Output: Confirmation + status + + ROX-123: comment added ✓ # default (write-through) + ROX-123: comment added (queued) # with --queue - ROX-123: comment added (pending sync) +Default behavior calls the Jira API immediately. AddComment returns +(string, error) so write-through comments receive real Jira IDs. +With --queue, writes to pending_changes table instead. ``` #### `jai push` @@ -713,6 +726,9 @@ Output: Confirmation + pending status ``` Output: Push queue processing summary +Only needed for changes created with --queue. Write-through changes +(the default) are already synced and do not appear in the push queue. + Pushing 3 pending changes... ✓ ROX-123: status → "In Progress" ✓ ROX-123: comment added @@ -829,6 +845,8 @@ type SyncMsg struct { ### Pending Change Highlighting +The TUI inline field editor writes through to Jira by default via a background `tea.Cmd`. If the API call fails, the change falls back to the `pending_changes` queue automatically. + Issues with pending (unsynced) changes display a visual indicator: ``` @@ -837,8 +855,7 @@ Issues with pending (unsynced) changes display a visual indicator: ROX-456 | Update docs | To Do ``` -The `⟳` marker (or color highlight) shows that ROX-123 has a pending status change that hasn't been confirmed by sync yet. Cleared once incremental sync returns the confirmed state. - +The `⟳` marker (or color highlight) shows that ROX-123 has a pending status change that hasn't been confirmed by sync yet. This appears when a write-through call fails and the change is queued, or when `--queue` was used explicitly. Cleared once incremental sync returns the confirmed state. ### Color Rules Evaluation ```go diff --git a/docs/user-guide.md b/docs/user-guide.md index 7c28dbe..736b08c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -111,7 +111,7 @@ jai schema get # command schema for agents ## Write operations -All write commands (except `transition` and `link`) queue changes locally. Run `jai push` to sync them to Jira. +Write commands push changes to Jira immediately by default. Use `--queue` / `-q` to defer changes locally until you run `jai push`. ### Set a field @@ -157,7 +157,7 @@ jai set --query "SELECT key FROM issues WHERE type = 'Bug' AND status = 'To Do'" ### Transitions -Transition an issue to a new workflow status. Transitions push to Jira immediately (no `jai push` needed). +Transition an issue to a new workflow status. Like all write commands, transitions push to Jira immediately. Use `--queue` to defer. ```sh # List available transitions @@ -211,6 +211,8 @@ jai unwatch ROX-123 # remove yourself jai comment ROX-123 "Fixed in PR #4892" ``` +Comments push to Jira immediately. Use `--queue` / `-q` to defer until `jai push`. + ### Create issues ```sh @@ -246,6 +248,8 @@ jai open ROX-123 --url-only # print URL to stdout ### Push +Push is only needed for changes that were deferred with `--queue`. It syncs all queued changes to Jira. + ```sh jai push ``` diff --git a/internal/cli/comment.go b/internal/cli/comment.go index ae40df0..640c1cb 100644 --- a/internal/cli/comment.go +++ b/internal/cli/comment.go @@ -10,28 +10,48 @@ import ( "github.com/sthadka/jai/internal/output" ) +var commentQueue bool + var commentCmd = &cobra.Command{ Use: "comment ", - Short: "Add a comment to a Jira issue (queued locally until 'jai push')", - Args: cobra.ExactArgs(2), + Short: "Add a comment to a Jira issue", + Long: `Add a comment to a Jira issue. + +By default, the comment is pushed to Jira immediately. Use --queue to +defer the push until 'jai push'.`, + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { issueKey, text := args[0], args[1] - // Ensure pending_changes table exists. - if err := g.db.EnsurePendingChangesTable(); err != nil { - return err - } + status := "synced" + var commentID string - // Queue the change. - payload, _ := json.Marshal(map[string]string{"body": text}) - if err := g.db.InsertPendingChange(issueKey, "add_comment", string(payload)); err != nil { - return err + if commentQueue { + if err := g.db.EnsurePendingChangesTable(); err != nil { + return err + } + payload, _ := json.Marshal(map[string]string{"body": text}) + if err := g.db.InsertPendingChange(issueKey, "add_comment", string(payload)); err != nil { + return err + } + commentID = fmt.Sprintf("local_%d", time.Now().UnixNano()) + status = "queued" + } else { + id, err := g.jira.AddComment(cmd.Context(), issueKey, text) + if err != nil { + msg := fmt.Sprintf("adding comment to %s: %v", issueKey, err) + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + commentID = id } - // Also insert into local comments table immediately. now := time.Now().UTC().Format(time.RFC3339) localComment := &db.Comment{ - ID: fmt.Sprintf("local_%d", time.Now().UnixNano()), + ID: commentID, IssueKey: issueKey, Author: g.cfg.Me, Body: text, @@ -44,16 +64,21 @@ var commentCmd = &cobra.Command{ if g.jsonOut { fmt.Println(string(output.OK(map[string]string{ "issue_key": issueKey, - "status": "pending", + "status": status, }))) return nil } - fmt.Printf("%s: comment added (pending sync)\n", issueKey) + if status == "queued" { + fmt.Printf("%s: comment added (queued)\n", issueKey) + } else { + fmt.Printf("%s: comment added ✓\n", issueKey) + } return nil }, } func init() { + commentCmd.Flags().BoolVarP(&commentQueue, "queue", "q", false, "Queue change locally instead of pushing to Jira immediately") rootCmd.AddCommand(commentCmd) } diff --git a/internal/cli/set.go b/internal/cli/set.go index 04bff09..3db0811 100644 --- a/internal/cli/set.go +++ b/internal/cli/set.go @@ -14,12 +14,16 @@ var ( setAddValues []string setRemoveValues []string setQuery string + setQueue bool ) var setCmd = &cobra.Command{ Use: "set [key] [value]", - Short: "Set a field value on one or more Jira issues (queued locally until 'jai push')", - Long: `Set a field value on one or more Jira issues (queued locally until 'jai push'). + Short: "Set a field value on one or more Jira issues", + Long: `Set a field value on one or more Jira issues. + +By default, changes are pushed to Jira immediately. Use --queue to +defer the push until 'jai push'. For scalar fields: jai set ROX-123 priority High @@ -105,8 +109,10 @@ Bulk operations with a SQL query: return fmt.Errorf("%s", msg) } - if err := g.db.EnsurePendingChangesTable(); err != nil { - return err + if setQueue { + if err := g.db.EnsurePendingChangesTable(); err != nil { + return err + } } fieldMap, err := g.db.FieldMapByJiraID() @@ -222,9 +228,22 @@ func setScalarField(cmd *cobra.Command, issueKey, fieldName, jiraID, value, fiel } } - payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": payloadVal}) - if err := g.db.InsertPendingChange(issueKey, "set_field", string(payload)); err != nil { - return err + status := "synced" + if setQueue { + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": payloadVal}) + if err := g.db.InsertPendingChange(issueKey, "set_field", string(payload)); err != nil { + return err + } + status = "queued" + } else { + if err := g.jira.UpdateField(cmd.Context(), issueKey, jiraID, payloadVal); err != nil { + msg := fmt.Sprintf("setting %s on %s: %v", fieldName, issueKey, err) + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } } _, err := g.db.Exec( @@ -240,11 +259,15 @@ func setScalarField(cmd *cobra.Command, issueKey, fieldName, jiraID, value, fiel "issue_key": issueKey, "field": fieldName, "value": payloadVal, - "status": "pending", + "status": status, }))) return nil } - fmt.Printf("%s: %s → %q (pending sync)\n", issueKey, fieldName, localVal) + if status == "queued" { + fmt.Printf("%s: %s → %q (queued)\n", issueKey, fieldName, localVal) + } else { + fmt.Printf("%s: %s → %q ✓\n", issueKey, fieldName, localVal) + } return nil } @@ -330,16 +353,25 @@ func setBulk(cmd *cobra.Command, keys []string, fieldName, jiraID, value, fieldT } } + var succeeded, failed int for _, key := range keys { if hasAdd || hasRemove { + var keyErr error for _, v := range setAddValues { 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 + if setQueue { + 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 + } + } else { + if err := g.jira.UpdateFieldOp(cmd.Context(), key, jiraID, "add", val); err != nil { + keyErr = err + fmt.Fprintf(cmd.ErrOrStderr(), " ✗ %s: add %v (%v)\n", key, v, err) + } } } for _, v := range setRemoveValues { @@ -347,9 +379,16 @@ func setBulk(cmd *cobra.Command, keys []string, fieldName, jiraID, value, fieldT 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 + if setQueue { + 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 { + if err := g.jira.UpdateFieldOp(cmd.Context(), key, jiraID, "remove", val); err != nil { + keyErr = err + fmt.Fprintf(cmd.ErrOrStderr(), " ✗ %s: remove %v (%v)\n", key, v, err) + } } } @@ -366,10 +405,24 @@ func setBulk(cmd *cobra.Command, keys []string, fieldName, jiraID, value, fieldT ); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed for %s: %v\n", key, err) } + + if keyErr != nil { + failed++ + } else { + succeeded++ + } } 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 setQueue { + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": scalarPayloadVal}) + if err := g.db.InsertPendingChange(key, "set_field", string(payload)); err != nil { + return err + } + } else { + if err := g.jira.UpdateField(cmd.Context(), key, jiraID, scalarPayloadVal); err != nil { + failed++ + fmt.Fprintf(cmd.ErrOrStderr(), " ✗ %s: %v\n", key, err) + continue + } } if _, err := g.db.Exec( fmt.Sprintf("UPDATE issues SET %s = ?, synced_at = datetime('now') WHERE key = ?", fieldName), @@ -377,17 +430,29 @@ func setBulk(cmd *cobra.Command, keys []string, fieldName, jiraID, value, fieldT ); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed for %s: %v\n", key, err) } + succeeded++ } } if g.jsonOut { + status := "synced" + if setQueue { + status = "queued" + } fmt.Println(string(output.OK(map[string]interface{}{ - "count": len(keys), - "keys": keys, + "count": len(keys), + "keys": keys, + "succeeded": succeeded, + "failed": failed, + "status": status, }))) return nil } - fmt.Printf("queued %d changes (pending sync)\n", len(keys)) + if setQueue { + fmt.Printf("queued %d changes\n", len(keys)) + } else { + fmt.Printf("%d succeeded, %d failed\n", succeeded, failed) + } return nil } @@ -397,9 +462,20 @@ func setArrayField(cmd *cobra.Command, issueKey, fieldName, jiraID string) error 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 + if setQueue { + 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 + } + } else { + if err := g.jira.UpdateFieldOp(cmd.Context(), issueKey, jiraID, "add", val); err != nil { + msg := fmt.Sprintf("adding %v to %s on %s: %v", v, fieldName, issueKey, err) + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } } } for _, v := range setRemoveValues { @@ -407,9 +483,20 @@ func setArrayField(cmd *cobra.Command, issueKey, fieldName, jiraID string) error 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 + if setQueue { + 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 + } + } else { + if err := g.jira.UpdateFieldOp(cmd.Context(), issueKey, jiraID, "remove", val); err != nil { + msg := fmt.Sprintf("removing %v from %s on %s: %v", v, fieldName, issueKey, err) + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } } } @@ -428,21 +515,33 @@ func setArrayField(cmd *cobra.Command, issueKey, fieldName, jiraID string) error fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed: %v\n", err) } + status := "synced" + if setQueue { + status = "queued" + } if g.jsonOut { fmt.Println(string(output.OK(map[string]interface{}{ "issue_key": issueKey, "field": fieldName, "added": setAddValues, "removed": setRemoveValues, - "status": "pending", + "status": status, }))) return nil } if len(setAddValues) > 0 { - fmt.Printf("%s: %s += %v (pending sync)\n", issueKey, fieldName, setAddValues) + if status == "queued" { + fmt.Printf("%s: %s += %v (queued)\n", issueKey, fieldName, setAddValues) + } else { + fmt.Printf("%s: %s += %v ✓\n", issueKey, fieldName, setAddValues) + } } if len(setRemoveValues) > 0 { - fmt.Printf("%s: %s -= %v (pending sync)\n", issueKey, fieldName, setRemoveValues) + if status == "queued" { + fmt.Printf("%s: %s -= %v (queued)\n", issueKey, fieldName, setRemoveValues) + } else { + fmt.Printf("%s: %s -= %v ✓\n", issueKey, fieldName, setRemoveValues) + } } return nil } @@ -491,5 +590,6 @@ func init() { setCmd.Flags().StringArrayVar(&setAddValues, "add", nil, "Add a value to an array field (repeatable)") setCmd.Flags().StringArrayVar(&setRemoveValues, "remove", nil, "Remove a value from an array field (repeatable)") setCmd.Flags().StringVar(&setQuery, "query", "", "SQL query returning a 'key' column to bulk-set") + setCmd.Flags().BoolVarP(&setQueue, "queue", "q", false, "Queue change locally instead of pushing to Jira immediately") rootCmd.AddCommand(setCmd) } diff --git a/internal/cli/transition.go b/internal/cli/transition.go index 47caf5b..d58d860 100644 --- a/internal/cli/transition.go +++ b/internal/cli/transition.go @@ -1,7 +1,6 @@ package cli import ( - "context" "encoding/json" "fmt" "strings" @@ -12,6 +11,8 @@ import ( synce "github.com/sthadka/jai/internal/sync" ) +var transitionQueue bool + func resolveTransition(name string, transitions []*jira.Transition) (match *jira.Transition, ambiguous []*jira.Transition) { lower := strings.ToLower(name) var matches []*jira.Transition @@ -39,9 +40,12 @@ func formatTransitionNames(transitions []*jira.Transition) string { var transitionCmd = &cobra.Command{ Use: "transition [status]", - Short: "Transition a Jira issue to a new status (pushed immediately)", - Long: "Move a Jira issue through its workflow. Transitions are pushed immediately, unlike field edits.", - Args: cobra.RangeArgs(1, 2), + Short: "Transition a Jira issue to a new status", + Long: `Move a Jira issue through its workflow. + +By default, the transition is pushed to Jira immediately. Use --queue to +defer the push until 'jai push'.`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { issueKey := args[0] listFlag, _ := cmd.Flags().GetBool("list") @@ -103,39 +107,33 @@ var transitionCmd = &cobra.Command{ return fmt.Errorf("%s", msg) } - if err := g.db.EnsurePendingChangesTable(); err != nil { - return err - } - - payload, _ := json.Marshal(map[string]string{"transition_id": match.ID}) - if err := g.db.InsertPendingChange(issueKey, "transition", string(payload)); err != nil { - return err - } - - writer := synce.NewWriter(g.db, g.jira) - results, err := writer.ProcessQueue(context.Background()) - if err != nil { - return fmt.Errorf("pushing transition: %w", err) - } - - for _, r := range results { - if r.IssueKey == issueKey && r.Operation == "transition" && !r.Success { - msg := fmt.Sprintf("transition failed: %v", r.Error) + status := "synced" + if transitionQueue { + if err := g.db.EnsurePendingChangesTable(); err != nil { + return err + } + payload, _ := json.Marshal(map[string]string{"transition_id": match.ID}) + if err := g.db.InsertPendingChange(issueKey, "transition", string(payload)); err != nil { + return err + } + status = "queued" + } else { + if err := g.jira.ExecuteTransition(cmd.Context(), issueKey, match.ID); err != nil { + msg := fmt.Sprintf("transition failed: %v", err) if g.jsonOut { fmt.Println(string(output.Err("JiraError", msg))) return nil } return fmt.Errorf("%s", msg) } - } - // 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) + // Refresh the local DB so status is immediately queryable. + 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) + } } } } @@ -145,17 +143,22 @@ var transitionCmd = &cobra.Command{ "issue_key": issueKey, "transition": match.Name, "transition_id": match.ID, - "status": "pushed", + "status": status, }))) return nil } - fmt.Printf("%s: transitioned to %q\n", issueKey, match.Name) + if status == "queued" { + fmt.Printf("%s: transition to %q (queued)\n", issueKey, match.Name) + } else { + fmt.Printf("%s: transitioned to %q \u2713\n", issueKey, match.Name) + } return nil }, } func init() { transitionCmd.Flags().Bool("list", false, "list available transitions") + transitionCmd.Flags().BoolVarP(&transitionQueue, "queue", "q", false, "Queue change locally instead of pushing to Jira immediately") rootCmd.AddCommand(transitionCmd) } diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 64d8631..dede70c 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -12,7 +12,7 @@ var tuiCmd = &cobra.Command{ Use: "tui", Short: "Launch the interactive full-screen TUI", RunE: func(cmd *cobra.Command, args []string) error { - app := tui.New(g.cfg, g.query, g.sync, g.db) + app := tui.New(g.cfg, g.query, g.sync, g.db, g.jira) p := tea.NewProgram(app, tea.WithAltScreen()) if _, err := p.Run(); err != nil { return fmt.Errorf("TUI error: %w", err) diff --git a/internal/jira/write.go b/internal/jira/write.go index 6bf2b0b..aeb6f3b 100644 --- a/internal/jira/write.go +++ b/internal/jira/write.go @@ -52,8 +52,8 @@ func (c *Client) UpdateFieldOp(ctx context.Context, issueKey, fieldID, op string return c.put(ctx, fmt.Sprintf("/rest/api/3/issue/%s", issueKey), payload) } -// AddComment adds a comment to a Jira issue. -func (c *Client) AddComment(ctx context.Context, issueKey, body string) error { +// AddComment adds a comment to a Jira issue and returns the comment ID. +func (c *Client) AddComment(ctx context.Context, issueKey, body string) (string, error) { payload := map[string]interface{}{ "body": map[string]interface{}{ "type": "doc", @@ -68,7 +68,13 @@ func (c *Client) AddComment(ctx context.Context, issueKey, body string) error { }, }, } - return c.post(ctx, fmt.Sprintf("/rest/api/3/issue/%s/comment", issueKey), payload) + var resp struct { + ID string `json:"id"` + } + if err := c.postDecode(ctx, fmt.Sprintf("/rest/api/3/issue/%s/comment", issueKey), payload, &resp); err != nil { + return "", err + } + return resp.ID, nil } // GetTransitions returns available transitions for an issue. diff --git a/internal/sync/writer.go b/internal/sync/writer.go index adbbc2e..d08975d 100644 --- a/internal/sync/writer.go +++ b/internal/sync/writer.go @@ -117,7 +117,8 @@ func (w *Writer) processAddComment(ctx context.Context, c *db.PendingChange) err if err := json.Unmarshal([]byte(c.Payload), &payload); err != nil { return fmt.Errorf("parsing add_comment payload: %w", err) } - return w.client.AddComment(ctx, c.IssueKey, payload.Body) + _, err := w.client.AddComment(ctx, c.IssueKey, payload.Body) + return err } func (w *Writer) processTransition(ctx context.Context, c *db.PendingChange) error { diff --git a/internal/tui/app.go b/internal/tui/app.go index 3cffe29..2d26d64 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -1,6 +1,7 @@ package tui import ( + "context" "encoding/json" "fmt" "strings" @@ -12,6 +13,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/sthadka/jai/internal/config" "github.com/sthadka/jai/internal/db" + "github.com/sthadka/jai/internal/jira" "github.com/sthadka/jai/internal/query" synce "github.com/sthadka/jai/internal/sync" ) @@ -34,7 +36,7 @@ type App struct { queryEng *query.Engine syncEngine *synce.Engine database *db.DB - + jiraClient *jira.Client views []config.ViewConfig activeView int tables []*TableModel @@ -67,7 +69,7 @@ type App struct { } // New creates a new App model. -func New(cfg *config.Config, queryEng *query.Engine, syncEng *synce.Engine, database *db.DB) *App { +func New(cfg *config.Config, queryEng *query.Engine, syncEng *synce.Engine, database *db.DB, jiraClient *jira.Client) *App { ti := textinput.New() ti.Placeholder = "filter..." ti.CharLimit = 100 @@ -85,6 +87,7 @@ func New(cfg *config.Config, queryEng *query.Engine, syncEng *synce.Engine, data queryEng: queryEng, syncEngine: syncEng, database: database, + jiraClient: jiraClient, keys: DefaultKeys(), filterInput: ti, fieldPickerInput: fpi, @@ -248,6 +251,10 @@ type fieldValuesLoadedMsg struct { values []string } +type fieldSavedMsg struct { + err error +} + // loadFieldValuesCmd queries distinct existing values for a field column, // filtered live by the user's current input text. Each keystroke fires a // fresh DB query so there is no cap on the total number of distinct values. @@ -332,6 +339,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.fieldValueSugFiltered = msg.values return a, nil + case fieldSavedMsg: + if msg.err != nil { + a.err = fmt.Sprintf("save failed: %v (queued for push)", msg.err) + } else { + a.err = "" + } + return a, nil case SyncTickMsg: a.syncing = true a.syncStatus = "syncing..." @@ -515,9 +529,8 @@ func (a *App) handleKey(msg tea.KeyMsg, cmds []tea.Cmd) (tea.Model, tea.Cmd) { newVal = a.fieldValueSugFiltered[a.fieldValueSugCursor] } issueKey := a.detail.IssueKey() - fieldName := a.fieldPickerChosen.JiraID - payload := marshalSetPayload(fieldName, newVal) - _ = a.database.InsertPendingChange(issueKey, "set_field", payload) + fieldID := a.fieldPickerChosen.JiraID + cmds = append(cmds, a.saveFieldCmd(issueKey, fieldID, newVal)) } a.fieldValueInput.Blur() a.mode = ModeDetail @@ -945,3 +958,15 @@ func marshalSetPayload(fieldID, value string) string { b, _ := json.Marshal(payload) return string(b) } + +func (a *App) saveFieldCmd(issueKey, fieldID, value string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + if err := a.jiraClient.UpdateField(ctx, issueKey, fieldID, value); err != nil { + payload := marshalSetPayload(fieldID, value) + _ = a.database.InsertPendingChange(issueKey, "set_field", payload) + return fieldSavedMsg{err: err} + } + return fieldSavedMsg{} + } +}