diff --git a/.agents/skills/beads/SKILL.md b/.agents/skills/beads/SKILL.md new file mode 100644 index 0000000..a5a3344 --- /dev/null +++ b/.agents/skills/beads/SKILL.md @@ -0,0 +1,80 @@ +--- +name: beads +description: Use when working in a repository that uses bd or Beads for durable project task tracking, issue dependencies, blocker management, multi-session handoff, or shared work memory. Trigger when the user asks to find ready work, claim or close tasks, create follow-up work, inspect blockers, recover project context, or choose between local planning and persistent project tracking. +--- + +# Beads + +Use Beads as the shared project task system. Local plans, scratch files, and personal memories are useful, but they are not the durable source of truth for project work. + +## First Step + +Run: + +```bash +bd prime +``` + +If that prints nothing, check whether the repository has an active Beads workspace: + +```bash +bd where +``` + +## Preferred Route + +Use the `bd` CLI when shell access is available. It is the most compact and direct Beads interface. + +## Core CLI Workflow + +1. Find work: + +```bash +bd ready +bd list --status=open +bd list --status=in_progress +``` + +2. Inspect before editing: + +```bash +bd show +``` + +3. Claim work atomically: + +```bash +bd update --claim +``` + +4. Create durable follow-up work when implementation reveals new tasks: + +```bash +bd create "Short title" --description="Why this exists and what needs to be done" --type=task --priority=2 +``` + +5. Close completed work: + +```bash +bd close --reason="Completed" +``` + +## What Belongs In Beads + +Use Beads for: + +- shared project tasks +- blockers and dependencies +- discovered follow-up work +- work that must survive thread reset, compaction, or handoff +- status that another person or agent should be able to resume + +Use agent-local planning tools only for the current turn's execution checklist. Do not treat them as shared project state. + +## Rules + +- Do not create markdown TODO files as the source of truth when Beads is available. +- Do not use `bd edit`; it opens an interactive editor. Use `bd update` flags instead. +- Prefer `--json` when parsing `bd` output programmatically. +- If hooks are installed, `bd prime` may already be injected. Run it manually when context is missing. +- Do not auto-close or mutate tasks unless the work is actually complete. diff --git a/.agents/skills/beads/agents/openai.yaml b/.agents/skills/beads/agents/openai.yaml new file mode 100644 index 0000000..09c3b8f --- /dev/null +++ b/.agents/skills/beads/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Beads" + short_description: "Project task tracking with bd" + default_prompt: "Use $beads to inspect ready work and manage durable project tasks." diff --git a/.beads/.gitignore b/.beads/.gitignore index 830ae10..4176bee 100644 --- a/.beads/.gitignore +++ b/.beads/.gitignore @@ -1,16 +1,32 @@ # Dolt database (managed by Dolt, not git) dolt/ -dolt-access.lock +embeddeddolt/ +proxieddb/ # Runtime files bd.sock bd.sock.startlock sync-state.json last-touched +.exclusive-lock + +# Daemon runtime (lock, log, pid) +daemon.* + +# Push state (runtime, per-machine) +push-state.json + +# Lock files (various runtime locks) +*.lock + +# Credential key (encryption key for federation peer auth β€” never commit) +.beads-credential-key # Local version tracking (prevents upgrade notification spam after git ops) .local_version +proxied_server_client_info.json + # Worktree redirect file (contains relative path to main repo's .beads/) # Must not be committed as paths would be wrong in other clones redirect @@ -19,6 +35,7 @@ redirect # These files are machine-specific and should not be shared across clones .sync.lock export-state/ +export-state.json # Ephemeral store (SQLite - wisps/molecules, intentionally not versioned) ephemeral.sqlite3 @@ -31,10 +48,20 @@ dolt-server.pid dolt-server.log dolt-server.lock dolt-server.port +dolt-server.activity + +# Debug-mode pprof artifacts (written when dolt.debug: true in config.yaml) +dolt-pprof/ + +# Corrupt backup directories (created by bd doctor --fix recovery) +*.corrupt.backup/ # Backup data (auto-exported JSONL, local-only) backup/ +# Per-project environment file (Dolt connection config, GH#2520) +.env + # Legacy files (from pre-Dolt versions) *.db *.db?* diff --git a/.beads/README.md b/.beads/README.md index dbfe363..63e8f4c 100644 --- a/.beads/README.md +++ b/.beads/README.md @@ -36,7 +36,7 @@ Issues in Beads are: - **Git-native**: Stored in Dolt database with version control and branching - **AI-friendly**: CLI-first design works perfectly with AI coding agents - **Branch-aware**: Issues can follow your branch workflow -- **Always in sync**: Auto-syncs with your commits +- **Sync-ready**: Uses Dolt remotes for backup and team sharing ## Why Beads? @@ -51,7 +51,7 @@ Issues in Beads are: - Fast, lightweight, and stays out of your way πŸ”§ **Git Integration** -- Automatic sync with git commits +- Dolt-native sync via bd dolt push / bd dolt pull - Branch-aware issue tracking - Dolt-native three-way merge resolution diff --git a/.beads/config.yaml b/.beads/config.yaml index dde8a55..60f406e 100644 --- a/.beads/config.yaml +++ b/.beads/config.yaml @@ -1 +1,68 @@ -no-db: true +# Beads Configuration File +# This file configures default behavior for all bd commands in this repository +# All settings can also be set via environment variables (BD_* prefix) +# or overridden with command-line flags + +# Issue prefix for this repository (used by bd init) +# If not set, bd init will auto-detect from directory name +# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. +# issue-prefix: "" + +# Use no-db mode: JSONL-only, no Dolt database +# When true, .beads/issues.jsonl is the only local store +# no-db: false + +# Enable JSON output by default +# json: false + +# Feedback title formatting for mutating commands (create/update/close/dep/edit) +# 0 = hide titles, N > 0 = truncate to N characters +# output: +# title-length: 255 + +# Default actor for audit trails (overridden by BEADS_ACTOR or --actor) +# actor: "" + +# Export events (audit trail) to .beads/events.jsonl on each flush/sync +# When enabled, new events are appended incrementally using a high-water mark. +# Use 'bd export --events' to trigger manually regardless of this setting. +# events-export: false + +# Multi-repo configuration (experimental - bd-307) +# Allows hydrating from multiple repositories and routing writes to the correct database +# repos: +# primary: "." # Primary repo (where this database lives) +# additional: # Additional repos to hydrate from (read-only) +# - ~/beads-planning # Personal planning repo +# - ~/work-planning # Work planning repo + +# Dolt-native backup (periodic backup for off-machine recovery) +# This is full database backup only. Cross-machine sync uses Dolt remotes. +# backup: +# enabled: false # Disable auto-backup entirely +# interval: 15m # Minimum time between auto-backups +# git-push: false # Disable git push (backup locally only) +# git-repo: "" # Separate git repo for backups (default: project repo) + +# Optional JSONL auto-export for viewers, interchange, and issue-level migration. +# Disabled by default; enable only when an integration needs fresh .beads/issues.jsonl. +# Use relative paths under .beads/ for JSONL import/export filenames. +# export: +# auto: false +# path: issues.jsonl +# interval: 60s +# git-add: false +# import: +# path: issues.jsonl + +# Integration settings (access with 'bd config get/set') +# Non-secret keys (stored in the database): +# - jira.url, jira.project +# - linear.team_id +# - github.org, github.repo +# +# Secret keys (stored in this file but prefer env vars to avoid git exposure): +# - linear.api_key β†’ use LINEAR_API_KEY env var instead +# - github.token β†’ use GITHUB_TOKEN env var instead + +sync.remote: "git+ssh://git@github.com/sthadka/jai.git" \ No newline at end of file diff --git a/.beads/dolt-config.log b/.beads/dolt-config.log deleted file mode 100644 index d7a891f..0000000 --- a/.beads/dolt-config.log +++ /dev/null @@ -1 +0,0 @@ -2026-03-13T16:14:23Z actor=unknown key=port value=53720 beads_dir= diff --git a/.beads/hooks/post-checkout b/.beads/hooks/post-checkout index c1fa905..2ec8c38 100755 --- a/.beads/hooks/post-checkout +++ b/.beads/hooks/post-checkout @@ -1,24 +1,33 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.60.0 --- +# --- BEGIN BEADS INTEGRATION v1.0.5 --- # 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:-30} + _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=$? - 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 + 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=$? 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 v0.60.0 --- +# --- END BEADS INTEGRATION v1.0.5 --- diff --git a/.beads/hooks/post-merge b/.beads/hooks/post-merge index dfbb586..7ade514 100755 --- a/.beads/hooks/post-merge +++ b/.beads/hooks/post-merge @@ -1,24 +1,33 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.60.0 --- +# --- BEGIN BEADS INTEGRATION v1.0.5 --- # 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:-30} + _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=$? - 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 + 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=$? 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 v0.60.0 --- +# --- END BEADS INTEGRATION v1.0.5 --- diff --git a/.beads/hooks/pre-commit b/.beads/hooks/pre-commit index c644d1f..749dc91 100755 --- a/.beads/hooks/pre-commit +++ b/.beads/hooks/pre-commit @@ -1,24 +1,33 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.60.0 --- +# --- BEGIN BEADS INTEGRATION v1.0.5 --- # 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:-30} + _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=$? - 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 + 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=$? 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 v0.60.0 --- +# --- END BEADS INTEGRATION v1.0.5 --- diff --git a/.beads/hooks/pre-push b/.beads/hooks/pre-push index 46df32c..fb85f89 100755 --- a/.beads/hooks/pre-push +++ b/.beads/hooks/pre-push @@ -1,24 +1,33 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.60.0 --- +# --- BEGIN BEADS INTEGRATION v1.0.5 --- # 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:-30} + _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=$? - 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 + 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=$? 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 v0.60.0 --- +# --- END BEADS INTEGRATION v1.0.5 --- diff --git a/.beads/hooks/prepare-commit-msg b/.beads/hooks/prepare-commit-msg index 25de9ef..8f5b58d 100755 --- a/.beads/hooks/prepare-commit-msg +++ b/.beads/hooks/prepare-commit-msg @@ -1,24 +1,33 @@ #!/usr/bin/env sh -# --- BEGIN BEADS INTEGRATION v0.60.0 --- +# --- BEGIN BEADS INTEGRATION v1.0.5 --- # 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:-30} + _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=$? - 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 + 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=$? 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 v0.60.0 --- +# --- END BEADS INTEGRATION v1.0.5 --- diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl deleted file mode 100644 index e69de29..0000000 diff --git a/.beads/metadata.json b/.beads/metadata.json index e5a57bc..fedf761 100644 --- a/.beads/metadata.json +++ b/.beads/metadata.json @@ -1,8 +1,7 @@ { "database": "dolt", "backend": "dolt", - "dolt_mode": "server", - "dolt_server_port": 53720, + "dolt_mode": "embedded", "dolt_database": "jai", - "project_id": "b8fcc025-c36b-49c7-9a2f-1cc0d62a2ad8" + "project_id": "a0854801-7e65-4e61-93cf-fe1f2479d772" } \ No newline at end of file diff --git a/.beads/plan.md b/.beads/plan.md deleted file mode 100644 index 6b92d38..0000000 --- a/.beads/plan.md +++ /dev/null @@ -1,578 +0,0 @@ -## Goal: jai β€” Query Jira with SQL - -### Type -epic - -### Priority -0 - -### Description -Build jai: a Go CLI tool that syncs Jira Cloud data to a local SQLite database and exposes it via SQL queries, a hybrid CLI, and a full-screen TUI. Targets AI agents (compact JSON output, schema introspection) and humans (interactive TUI, saved views) equally. - -Core loop: jai sync β†’ jai query β†’ jai tui. - -### Acceptance Criteria -- jai sync downloads Jira Cloud issues to local SQLite DB -- jai query runs arbitrary SQL against the DB (JOINs, aggregations, FTS5) -- jai tui provides full-screen interactive views -- --json flag returns compact structured output for agents -- Distributable via Homebrew as a single binary - ---- - -## Phase 1: Project scaffolding - -### Type -task - -### Priority -1 - -### Description -Initialize Go module, directory structure per spec, Makefile with build/test/lint targets, build tag -tags fts5 for mattn/go-sqlite3. Add dependencies: cobra, mattn/go-sqlite3. - -Expected structure: cmd/jai/main.go, internal/{cli,config,db,jira,sync,query,tui,output}/, Makefile, go.mod. - -## Phase 1: Config loading - -### Type -task - -### Priority -1 - -### Description -Implement internal/config/ package: YAML parsing with ${VAR} env var substitution, Config struct with jira/sync/db/fields/views sections, default values, config file path resolution (~/.config/jai/config.yaml), validation of required fields (url, email, token, projects). - -## Phase 1: Database layer - -### Type -task - -### Priority -1 - -### Description -Implement internal/db/ package: SQLite connection with WAL mode + pragmas (journal_mode=WAL, busy_timeout=30000, foreign_keys=ON, synchronous=NORMAL, cache_size=-64000), schema creation for issues/comments/sync_metadata/field_map/schema_version tables, migration framework (sequential versioned migrations), issue upsert (INSERT OR REPLACE), comment upsert, sync metadata read/write. - -Build tag: -tags fts5. - -## Phase 1: Jira client - -### Type -task - -### Priority -1 - -### Description -Implement internal/jira/ package: HTTP client with Basic auth (base64 email:token), rate limiter (golang.org/x/time/rate, 10 req/s), retry with exponential backoff on 429/5xx (max 3 retries), paginated GET /rest/api/3/search iterator (Go 1.23 iter.Seq2), GET /rest/api/3/field for field discovery, GET /rest/api/3/myself for connection test, Jira API response types, ADFβ†’plaintext converter. - -## Phase 1: Sync engine - -### Type -task - -### Priority -1 - -### Description -Implement internal/sync/ package: field discovery (fetch field metadata β†’ populate field_map), denormalization (raw JSON β†’ column values per field type), dynamic column creation (ALTER TABLE for new custom fields + backfill from raw_json), incremental sync (JQL updated>=last_sync_time, paginate, batch upsert), full sync (delete + re-fetch in transaction), comment extraction, sync metadata updates. - -## Phase 1: Query engine - -### Type -task - -### Priority -1 - -### Description -Implement internal/query/ package: SQL execution against DB, template variable resolution ({{me}}, {{team}}, {{today}}, {{week_ago}}), Results struct (columns/rows/count), human table output (columnar format), JSON output envelope ({"ok":true,"columns":[...],"rows":[...],"count":N}). - -## Phase 1: CLI commands (sync, query, get) - -### Type -task - -### Priority -1 - -### Description -Implement internal/cli/ Phase 1 commands using cobra: -- jai sync: run incremental sync, print progress (N new, M updated) -- jai sync --full: full resync -- jai query : execute SQL, print table -- jai get : fetch single issue from DB, print all fields - -## Phase 1: Tests - -### Type -task - -### Priority -2 - -### Description -Write tests for Phase 1: -- DB schema creation and migration tests -- Jira client tests with httptest mock server -- Denormalization tests with fixture JSON (testdata/) -- Sync engine integration test (mock Jira β†’ real SQLite in-memory DB) -- Query execution tests - ---- - -## Phase 2: --json and --fields flags - -### Type -task - -### Priority -1 - -### Description -Implement internal/output/ package: compact JSON serializer (no indentation), envelope {"ok":true,"data":{...}} for single items and {"ok":true,"columns":[...],"rows":[...],"count":N} for queries, error envelope {"ok":false,"error":{"type":"...","message":"..."}}. Apply --json to all commands: get, query, search, fields, status. - -Implement --fields flag: parse comma-separated field list, filter output columns, validate against field_map with Levenshtein typo suggestions. - -## Phase 2: jai schema command - -### Type -task - -### Priority -2 - -### Description -Implement internal/cli/schema.go: schema registry where each command registers its parameter schema, output includes command name/params/flags/output_fields (derived from DB field_map). "jai schema" with no args lists all commands. Always outputs JSON (agent-facing command). - -## Phase 2: jai fields command - -### Type -task - -### Priority -2 - -### Description -Implement internal/cli/fields.go: query field_map table, human output as formatted table (name, jira_id, type, FTS flag), JSON output as array of field objects, --filter flag for pattern matching. - -## Phase 2: jai status command - -### Type -task - -### Priority -2 - -### Description -Implement internal/cli/status.go: sync metadata per project (issues count, last sync time), pending changes count, DB file size. Human and JSON output modes. - -## Phase 2: Auto-sync - -### Type -task - -### Priority -2 - -### Description -Implement auto-sync in internal/cli/root.go: before command execution check last_sync_time vs configured interval, if stale run incremental sync (with brief progress message), --no-sync flag to skip. Skip auto-sync for: sync, init, config, schema, fields commands. - -## Phase 2: Tests - -### Type -task - -### Priority -3 - -### Description -Tests for Phase 2: JSON output formatting, field filtering, --fields flag validation with Levenshtein suggestions, schema output, auto-sync trigger logic. - ---- - -## Phase 3: TUI foundation - -### Type -task - -### Priority -1 - -### Description -Implement internal/tui/app.go: root bubbletea model with layout (tab bar, table area, status bar), view loading from config (ViewConfig struct: title/query/columns/group_by/color_rules/status_summary/sort_by/sort_desc), SQL query execution per view, basic table rendering with lipgloss styling. - -## Phase 3: Tab bar - -### Type -task - -### Priority -2 - -### Description -Implement internal/tui/tabs.go: render view tabs at top, Tab/Shift-Tab navigation, number keys 1-9 for direct jump, active tab highlight with lipgloss. - -## Phase 3: Table component - -### Type -task - -### Priority -1 - -### Description -Implement internal/tui/table.go: column headers with alignment, scrollable rows (j/k/arrows/Ctrl-d/u/PgUp/PgDn/gg/G/Home/End), column width auto-sizing based on content, row highlighting for current selection, mouse scroll support. Sort by column with s key (toggle asc/desc, show β–²/β–Ό indicator in header). - -## Phase 3: Filter component - -### Type -task - -### Priority -2 - -### Description -Implement internal/tui/filter.go: / key opens filter input overlay, in-memory substring match across all visible columns, filter indicator + match count display, Esc to clear filter. - -## Phase 3: Grouping - -### Type -task - -### Priority -2 - -### Description -Implement internal/tui/grouping.go: group_by config option renders collapsible groups, g key opens column selector to group by, expand/collapse with Enter on group header, group header shows count (e.g. "In Progress (12)"). - -## Phase 3: Issue detail pane - -### Type -task - -### Priority -2 - -### Description -Implement internal/tui/detail.go: Enter on row opens split pane (right or bottom), shows all issue fields + comments, o key opens issue in browser (open/xdg-open), Esc closes pane. - -## Phase 3: Status summary bar - -### Type -task - -### Priority -3 - -### Description -Implement internal/tui/statusbar.go: when status_summary: true in view config, bottom bar shows counts by status (e.g. "12 To Do | 8 In Progress | 3 Done"). - -## Phase 3: Hierarchy view - -### Type -task - -### Priority -3 - -### Description -Implement internal/tui/hierarchy.go: detect parent_key/epic_key relationships, render as indented tree, expand/collapse children with Enter. - -## Phase 3: Background sync - -### Type -task - -### Priority -1 - -### Description -Implement internal/tui/sync.go: goroutine runs incremental sync on configured interval, sends SyncMsg via bubbletea program.Send() on completion, TUI refreshes current view query, sync indicator in top-right (spinner during active sync). - -## Phase 3: jai view command - -### Type -task - -### Priority -2 - -### Description -Implement internal/cli/view.go: execute view's SQL query from config, render as CLI table or JSON, list available views when no name given. Template variable overrides via --version/--team flags. - -## Phase 3: Template variables in views - -### Type -task - -### Priority -2 - -### Description -Implement internal/query/template.go: resolve {{me}}, {{team}}, {{version}}, {{project}}, {{today}}, {{week_ago}} from config + CLI flag overrides. Used by both jai view (CLI) and TUI. - -## Phase 3: Tests - -### Type -task - -### Priority -3 - -### Description -Tests for Phase 3: view config parsing, table sorting/filtering logic (unit tests on data not rendering), TUI smoke tests with teatest. - ---- - -## Phase 4: pending_changes table - -### Type -task - -### Priority -1 - -### Description -Implement internal/db/pending.go: insert pending change, list pending changes (for status display), mark as synced (update synced_at), update retry_count and last_error. - -Operations payload format: set_field {"field":"status","value":"In Progress"}, add_comment {"body":"..."}, transition {"transition_id":"31","transition_name":"Start Progress"}. - -## Phase 4: jai set command - -### Type -task - -### Priority -1 - -### Description -Implement internal/cli/set.go: validate field exists in field_map, resolve readable name β†’ jira_id, insert into pending_changes, optimistic local update (also update issues table immediately), print confirmation with pending status. - -## Phase 4: jai comment command - -### Type -task - -### Priority -1 - -### Description -Implement internal/cli/comment.go: insert into pending_changes (operation: add_comment), insert into local comments table immediately, print confirmation. - -## Phase 4: Write sync processor - -### Type -task - -### Priority -1 - -### Description -Implement internal/sync/writer.go: process pending_changes WHERE synced_at IS NULL ORDER BY created_at, build Jira API requests per operation (set_field β†’ PUT /issue/{key}, add_comment β†’ POST /issue/{key}/comment, transition β†’ POST /issue/{key}/transitions), on success mark synced_at=NOW(), on failure increment retry_count/log error, skip after retry_count > 5. - -## Phase 4: jai push command - -### Type -task - -### Priority -1 - -### Description -Implement internal/cli/push.go: trigger write sync processor, print results table (succeeded/failed with details). Format: "βœ“ ROX-123: status β†’ In Progress", "βœ— ROX-456: assignee β†’ jane (error: user not found)". - -## Phase 4: Jira write client - -### Type -task - -### Priority -1 - -### Description -Implement internal/jira/write.go: issue field update (PUT /rest/api/3/issue/{key}), comment creation (POST /rest/api/3/issue/{key}/comment), transition execution (POST /rest/api/3/issue/{key}/transitions), get available transitions (GET /rest/api/3/issue/{key}/transitions). - -## Phase 4: TUI quick actions - -### Type -task - -### Priority -2 - -### Description -Implement internal/tui/editor.go: e key opens field selector then value input β†’ queue pending change, c key opens text input β†’ queue comment, show pending indicator (⟳) on modified rows until confirmed by sync. - -## Phase 4: Tests - -### Type -task - -### Priority -3 - -### Description -Tests for Phase 4: pending_changes CRUD, write processor with mock Jira API (httptest), optimistic local update verification, push command output formatting. - ---- - -## Phase 5: jai init wizard - -### Type -task - -### Priority -2 - -### Description -Implement internal/cli/init.go: bubbletea interactive wizard steps: welcome β†’ Jira URL/email/token input β†’ connection test β†’ project selection (multi-select) β†’ background sync with progress bar β†’ field discovery display β†’ config file generation (~/.config/jai/config.yaml) β†’ done screen with next steps. Goal: zero to working TUI in under 5 minutes. - -## Phase 5: FTS5 search - -### Type -task - -### Priority -1 - -### Description -Implement internal/cli/search.go: jai search command using FTS5 MATCH with ranking, configurable FTS fields from search.fts_fields config, comments concatenated into comments_text column for FTS. Uses issues_fts virtual table with porter unicode61 tokenizer. - -Also create the FTS5 virtual table and triggers in internal/db/schema.go. - -## Phase 5: Color rules - -### Type -task - -### Priority -2 - -### Description -Implement internal/tui/colors.go: evaluate color_rules from view config per row, conditions: older_than (parse duration, compare time.Now()), equals, not_equals, contains, in. Apply lipgloss color to matching rows/cells. - -## Phase 5: Deletion detection - -### Type -task - -### Priority -2 - -### Description -Implement internal/sync/deletions.go: during jai sync --full, fetch total issue count from Jira (GET /search?maxResults=0), if local > remote run full key reconciliation (fetch all keys from Jira paginated, compare with local, mark missing as is_deleted=1/deleted_at=NOW()). - -## Phase 5: Changelog sync - -### Type -task - -### Priority -3 - -### Description -Implement changelog sync in internal/sync/engine.go and internal/db/changelog.go: when sync.history: true, fetch expand=changelog during sync, store in changelog table. Add example views using changelog data (e.g. "what changed this week", "time in status"). - -## Phase 5: Default views - -### Type -task - -### Priority -2 - -### Description -Implement internal/config/defaults.go: ship starter views generated during jai init based on user's project/team: my-work (assignee={{me}}, status!=Done), team-board (group by status), recent-updates (ORDER BY updated DESC), stale-issues (updated < 28 days), stale-bugs (type=Bug, color rules: red >56d, yellow >28d). - -## Phase 5: Human output polish - -### Type -task - -### Priority -2 - -### Description -Implement internal/output/table.go: lipgloss-styled tables for all CLI output, consistent column formatting, colored status values (green=Done, yellow=In Progress, red=Blocked), configurable column widths. Apply to get, query, search, fields, status, view commands. - -## Phase 5: Error UX - -### Type -task - -### Priority -2 - -### Description -Implement Levenshtein-based typo suggestions for unknown field names in QueryError. Helpful error messages with "did you mean X?" for column names. Config validation with actionable error messages for missing required fields. JQL-to-SQL reference in jai help query. - -## Phase 5: Tests - -### Type -task - -### Priority -3 - -### Description -Tests for Phase 5: init wizard flow, FTS5 search tokenization and ranking, color rule evaluation, deletion detection, default view generation. - ---- - -## Phase 6: README - -### Type -task - -### Priority -2 - -### Description -Write README.md: hook "Query Jira with SQL", quick demo (vhs recording), installation (brew install jai), quick start (initβ†’syncβ†’queryβ†’tui), compelling SQL examples from idea.md (JOINs/CTEs/window functions), agent usage section (--json/--fields/schema), view configuration, JQLβ†’SQL migration guide, architecture overview, contributing guide. - -## Phase 6: Homebrew formula - -### Type -task - -### Priority -1 - -### Description -Write .goreleaser.yaml config, create Homebrew tap repository, configure multi-platform builds (darwin-arm64, darwin-amd64, linux-amd64, linux-arm64) with CGO cross-compilation via zig cc. - -## Phase 6: CI/CD - -### Type -task - -### Priority -1 - -### Description -Set up GitHub Actions: build+test on push, golangci-lint, release workflow (tag β†’ goreleaser β†’ Homebrew tap update). All platforms must pass. - -## Phase 6: Terminal recording - -### Type -task - -### Priority -2 - -### Description -Create vhs terminal recording showing initβ†’syncβ†’queryβ†’tui workflow. Include as demo gif in README. - -## Phase 6: License and CHANGELOG - -### Type -task - -### Priority -3 - -### Description -Add LICENSE file (MIT), create CHANGELOG.md with initial v0.1.0 release notes. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..c6907bf --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "command": "bd prime --hook-json", + "type": "command" + } + ], + "matcher": "" + } + ] + } +} \ No newline at end of file diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..146af7e --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[features] +hooks = true diff --git a/.codex/hooks.json b/.codex/hooks.json new file mode 100644 index 0000000..13c7229 --- /dev/null +++ b/.codex/hooks.json @@ -0,0 +1,51 @@ +{ + "hooks": { + "PostCompact": [ + { + "hooks": [ + { + "command": "bd codex-hook PostCompact", + "statusMessage": "Scheduling Beads context refresh", + "type": "command" + } + ], + "matcher": "manual|auto" + } + ], + "PreCompact": [ + { + "hooks": [ + { + "command": "bd codex-hook PreCompact", + "statusMessage": "Checking Beads context", + "type": "command" + } + ], + "matcher": "manual|auto" + } + ], + "SessionStart": [ + { + "hooks": [ + { + "command": "bd codex-hook SessionStart", + "statusMessage": "Loading Beads context", + "type": "command" + } + ], + "matcher": "startup|resume|clear" + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "command": "bd codex-hook UserPromptSubmit", + "statusMessage": "Refreshing Beads context", + "type": "command" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index 81f71e7..442e91f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ # Compiled binary jai + +# Beads / Dolt files (added by bd init) +.beads-credential-key +.beads/proxieddb/ diff --git a/AGENTS.md b/AGENTS.md index 1efabff..3809312 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,115 +36,82 @@ cp -rf source dest # NOT: cp -r source dest - `apt-get` - use `-y` flag - `brew` - use `HOMEBREW_NO_AUTO_UPDATE=1` env var - -## Issue Tracking with bd (beads) + +## Beads Issue Tracker -**IMPORTANT**: This project uses **bd (beads)** for ALL issue tracking. Do NOT use markdown TODOs, task lists, or other tracking methods. +This project uses **bd (beads)** for issue tracking. Run `bd prime` to see full workflow context and commands. -### Why bd? - -- Dependency-aware: Track blockers and relationships between issues -- Version-controlled: Built on Dolt with cell-level merge -- Agent-optimized: JSON output, ready work detection, discovered-from links -- Prevents duplicate tracking systems and confusion - -### Quick Start - -**Check for ready work:** - -```bash -bd ready --json -``` - -**Create new issues:** - -```bash -bd create "Issue title" --description="Detailed context" -t bug|feature|task -p 0-4 --json -bd create "Issue title" --description="What this issue is about" -p 1 --deps discovered-from:bd-123 --json -``` - -**Claim and update:** +### Quick Reference ```bash -bd update --claim --json -bd update bd-42 --priority 1 --json -``` - -**Complete work:** - -```bash -bd close bd-42 --reason "Completed" --json +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work ``` -### Issue Types +### Rules -- `bug` - Something broken -- `feature` - New functionality -- `task` - Work item (tests, docs, refactoring) -- `epic` - Large feature with subtasks -- `chore` - Maintenance (dependencies, tooling) +- Use `bd` for ALL task tracking β€” do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge β€” do NOT use MEMORY.md files -### Priorities +**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. -- `0` - Critical (security, data loss, broken builds) -- `1` - High (major features, important bugs) -- `2` - Medium (default, nice-to-have) -- `3` - Low (polish, optimization) -- `4` - Backlog (future ideas) +## Agent Context Profiles -### Workflow for AI Agents +The managed Beads block is task-tracking guidance, not permission to override repository, user, or orchestrator instructions. -1. **Check ready work**: `bd ready` shows unblocked issues -2. **Claim your task atomically**: `bd update --claim` -3. **Work on it**: Implement, test, document -4. **Discover new work?** Create linked issue: - - `bd create "Found bug" --description="Details about what was found" -p 1 --deps discovered-from:` -5. **Complete**: `bd close --reason "Done"` +- **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. -### Auto-Sync +## Session Completion -bd automatically syncs with git: +This protocol applies when ending a Beads implementation workflow. It is subordinate to explicit user, repository, and orchestrator instructions. -- Exports to `.beads/issues.jsonl` after changes (5s debounce) -- Imports from JSONL when newer (e.g., after `git pull`) -- No manual export/import needed! - -### Important Rules - -- βœ… Use bd for ALL task tracking -- βœ… Always use `--json` flag for programmatic use -- βœ… Link discovered work with `discovered-from` dependencies -- βœ… Check `bd ready` before asking "what should I work on?" -- ❌ Do NOT create markdown TODO lists -- ❌ Do NOT use external issue trackers -- ❌ Do NOT duplicate tracking systems - -For more details, see README.md and docs/QUICKSTART.md. - -## Landing the Plane (Session Completion) - -**When ending a work session**, you MUST complete ALL steps below. Work is NOT complete until `git push` succeeds. - -**MANDATORY WORKFLOW:** - -1. **File issues for remaining work** - Create issues for anything that needs follow-up +1. **File issues for remaining work** - Create beads 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. **PUSH TO REMOTE** - This is MANDATORY: +4. **Handle git/sync by active profile**: ```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 # MUST show "up to date with origin" + git status ``` -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 +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. + + +## Beads Issue Tracker + +Use Beads (`bd`) for durable task tracking in repositories that include it. Use the `beads` skill at `.agents/skills/beads/SKILL.md` (project install) or `~/.agents/skills/beads/SKILL.md` (global install) for Beads workflow guidance, then use the `bd` CLI for issue operations. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +bd prime # Refresh Beads context +``` + +### Rules + +- Use `bd` for all task tracking; do not create markdown TODO lists. +- Run `bd prime` when Beads context is missing or stale. Codex 0.129.0+ can load Beads context automatically through native hooks; use `/hooks` to inspect or toggle them. +- Keep persistent project memory in Beads via `bd remember`; do not create ad hoc memory files. + +**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. + diff --git a/CLAUDE.md b/CLAUDE.md index fd2db10..fce9c4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -107,3 +107,59 @@ Reference docs (read before implementing any feature): - `docs/spec.md` β€” Go data models, full API surface, TUI design, sync engine - `docs/plan.md` β€” phased task breakdown (source of truth for what to build) - `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. + +### Quick Reference + +```bash +bd ready # Find available work +bd show # View issue details +bd update --claim # Claim work +bd close # Complete work +``` + +### Rules + +- Use `bd` for ALL task tracking β€” do NOT use TodoWrite, TaskCreate, or markdown TODO lists +- Run `bd prime` for detailed command reference and session close protocol +- Use `bd remember` for persistent knowledge β€” do NOT use MEMORY.md files + +**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. + +1. **File issues for remaining work** - Create beads 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**: + ```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 + git push + git status + ``` +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. + diff --git a/README.md b/README.md index 5d6b31d..94903f4 100644 --- a/README.md +++ b/README.md @@ -271,8 +271,32 @@ jai create ROX --type Story \ # β†’ {"ok":true,"data":{"key":"ROX-4902","id":"12345","project":"ROX","status":"created"}} # Update a field -jai set ROX-4821 status "In Progress" -# β†’ ROX-4821: status β†’ "In Progress" (pending sync) +jai set ROX-4821 priority High +# β†’ ROX-4821: priority β†’ "High" (pending sync) + +# Array fields β€” add/remove individual values +jai set ROX-4821 labels --add backend --add auth +# β†’ ROX-4821: labels += [backend auth] (pending sync) +jai set ROX-4821 labels --remove backend +# β†’ ROX-4821: labels -= [backend] (pending sync) + +# Bulk set β€” comma-separated keys or SQL query +jai set ROX-1,ROX-2,ROX-3 priority Major +# β†’ queued 3 changes (pending sync) +jai set --query "SELECT key FROM issues WHERE type = 'Bug' LIMIT 5" priority Major +# β†’ queued 5 changes (pending sync) + +# Transition an issue (pushes immediately) +jai transition ROX-4821 "In Progress" +# β†’ ROX-4821: transitioned to "In Progress" +jai transition ROX-4821 --list +# β†’ Available transitions: New, Backlog, In Progress, Done, ... + +# Link two issues +jai link ROX-4821 ROX-4756 --type "Blocks" +# β†’ ROX-4821 -> ROX-4756: linked (Blocks) +jai link --list-types +# β†’ Available link types: Blocks, Related, Duplicate, ... # Add a comment jai comment ROX-4821 "Fixed in PR #4892, deploying to staging" @@ -280,9 +304,10 @@ jai comment ROX-4821 "Fixed in PR #4892, deploying to staging" # Push all pending changes jai push -# β†’ βœ“ ROX-4821: status β†’ "In Progress" +# β†’ βœ“ ROX-4821: priority β†’ "High" +# β†’ βœ“ ROX-4821: labels updated # β†’ βœ“ ROX-4821: comment added -# β†’ 2 succeeded, 0 failed +# β†’ 3 succeeded, 0 failed ``` --- @@ -345,6 +370,12 @@ Both paths can be overridden with `--config` and `--db` flags, or by setting `db | `jai status` | Sync status and pending changes | | `jai create ` | Create a new issue | | `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 comment ` | Add a comment | | `jai push` | Push pending changes to Jira | | `jai tui` | Launch full-screen TUI | diff --git a/docs/user-guide.md b/docs/user-guide.md new file mode 100644 index 0000000..10b8cef --- /dev/null +++ b/docs/user-guide.md @@ -0,0 +1,235 @@ +# jai User Guide + +This guide covers day-to-day usage of jai. For installation and initial setup, see the [README](../README.md). + +--- + +## Getting started + +```sh +export JAI_TOKEN=your-jira-api-token +jai init # interactive setup wizard +jai sync # sync issues from Jira +jai query "SELECT key, summary, status FROM issues LIMIT 10" +``` + +### Multiple environments + +Use `--config` to point to a different config file (e.g., staging vs production): + +```sh +jai --config ~/.config/jai/staging.yaml sync +jai --config ~/.config/jai/staging.yaml query "SELECT key, status FROM issues LIMIT 5" +``` + +The init wizard respects `--config` too β€” it reads/writes the specified file and auto-derives the database path from the config filename (e.g., `staging.yaml` uses `staging.db`): + +```sh +jai init --config ~/.config/jai/staging.yaml +``` + +--- + +## Reading data + +### Single issue + +```sh +jai get ROX-123 +jai get ROX-123 --json --fields key,summary,status,labels +``` + +### SQL queries + +```sh +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. + +### Full-text search + +```sh +jai search "authentication token expired" +``` + +### Named views + +```sh +jai view my-work +``` + +Views are defined in your config YAML. See the README for examples. + +### Field discovery + +```sh +jai fields # list all fields with Jira IDs and types +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. + +### Set a field + +```sh +jai set ROX-123 priority High +jai set ROX-123 summary "Updated title" +``` + +### Array fields (labels, components, fixVersions) + +Add or remove individual values without replacing the entire array: + +```sh +jai set ROX-123 labels --add backend +jai set ROX-123 labels --add security --add urgent +jai set ROX-123 labels --remove backend +``` + +Replace all values at once with comma-separated syntax: + +```sh +jai set ROX-123 labels "bug,security,backend" +``` + +Using `--add`/`--remove` on a non-array field produces an error: + +```sh +jai set ROX-123 priority --add High +# Error: priority is not an array field +``` + +### Bulk set + +Set the same field on multiple issues at once: + +```sh +# Comma-separated keys +jai set ROX-1,ROX-2,ROX-3 priority Major + +# SQL query β€” any query returning a 'key' column +jai set --query "SELECT key FROM issues WHERE type = 'Bug' AND status = 'To Do'" priority Major +``` + +### Transitions + +Transition an issue to a new workflow status. Transitions push to Jira immediately (no `jai push` needed). + +```sh +# List available transitions +jai transition ROX-123 --list + +# Execute a transition (case-insensitive) +jai transition ROX-123 "In Progress" +jai transition ROX-123 "done" +``` + +If the transition name doesn't match, jai lists the available options: + +```sh +jai transition ROX-123 "NotAStatus" +# Error: unknown transition "NotAStatus" for ROX-123 +# Available transitions: +# - To Do (id: 41) +# - In Progress (id: 51) +# - Done (id: 91) +``` + +### Issue links + +Create links between issues. Links push to Jira immediately. + +```sh +# Default link type +jai link ROX-1 ROX-2 + +# Specify link type (case-insensitive) +jai link ROX-1 ROX-2 --type "Blocks" + +# List available link types +jai link --list-types +``` + +If the specified type doesn't exist on your Jira instance, jai lists the available types. + +### Comments + +```sh +jai comment ROX-123 "Fixed in PR #4892" +``` + +### Create issues + +```sh +jai create ROX --type Bug --summary "Login fails on SSO" --priority High --labels backend,auth +``` + +### Push + +```sh +jai push +``` + +--- + +## Sync + +```sh +jai sync # incremental sync +jai sync --full # full resync with deletion detection +jai sync --changelogs # sync status transition history +``` + +Use `--no-sync` on any command to skip the auto-sync that runs before queries: + +```sh +jai query "SELECT count(*) FROM issues" --no-sync +``` + +### Sync status + +```sh +jai status +``` + +--- + +## Agent mode + +Every command supports `--json` for structured output and `--fields` to select columns: + +```sh +jai get ROX-123 --json --fields key,summary,status +# {"ok":true,"data":{"key":"ROX-123","summary":"...","status":"In Progress"}} + +jai set ROX-1,ROX-2 priority Major --json +# {"ok":true,"data":{"count":2,"keys":["ROX-1","ROX-2"]}} + +jai transition ROX-123 --list --json +# {"ok":true,"data":{"issue_key":"ROX-123","transitions":[...]}} + +jai link --list-types --json +# {"ok":true,"data":{"link_types":[...]}} +``` + +Errors are structured: +```json +{"ok":false,"error":{"type":"QueryError","message":"no such column: statuss"}} +``` + +--- + +## Global flags + +| Flag | Description | +|------|-------------| +| `--json` | Structured JSON output | +| `--fields` | Comma-separated field names to include | +| `--no-sync` | Skip auto-sync before the command | +| `--config` | Path to config file (default: `~/.config/jai/config.yaml`) | +| `--db` | Path to database file | diff --git a/internal/cli/get.go b/internal/cli/get.go index 64253fa..286d37b 100644 --- a/internal/cli/get.go +++ b/internal/cli/get.go @@ -204,8 +204,8 @@ func fmQuote(s string) string { s = s[:10] } for _, r := range s { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || - (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.') { + if (r < 'a' || r > 'z') && (r < 'A' || r > 'Z') && + (r < '0' || r > '9') && r != '-' && r != '_' && r != '.' { return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` } } diff --git a/internal/cli/init.go b/internal/cli/init.go index 6ead680..99f8542 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -97,9 +97,15 @@ func runInit(cmd *cobra.Command, args []string) error { fmt.Println() fmt.Printf(" All Jira data is synced locally β€” fast queries, no rate limits.\n") + // Resolve config path: --config flag > default. + cfgPath := g.cfgPath + if cfgPath == "" { + cfgPath = config.DefaultConfigPath() + } + // Load existing config to pre-populate prompts. var existing *config.Config - if cfg, err := config.Load(config.DefaultConfigPath()); err == nil { + if cfg, err := config.Load(cfgPath); err == nil { existing = cfg } defaultFor := func(field string) string { @@ -153,7 +159,6 @@ func runInit(cmd *cobra.Command, args []string) error { stepOK("Account: " + dim(me.EmailAddress)) // Write config file. - cfgPath := config.DefaultConfigPath() if err := os.MkdirAll(filepath.Dir(cfgPath), 0700); err != nil { stepFail("Could not create config directory: " + err.Error()) return fmt.Errorf("creating config directory: %w", err) @@ -206,8 +211,28 @@ func runInit(cmd *cobra.Command, args []string) error { return fmt.Errorf("at least one sync source is required") } + // Resolve DB path: --db flag > existing config > derived from config name > default. + dbPath := config.DefaultDBPath() + if cfgPath != config.DefaultConfigPath() { + // Non-default config: derive DB name from config filename (e.g., staging.yaml β†’ staging.db). + base := strings.TrimSuffix(filepath.Base(cfgPath), filepath.Ext(cfgPath)) + if base != "" && base != "config" { + dbPath = filepath.Join(filepath.Dir(config.DefaultDBPath()), base+".db") + } + } + if existing != nil && existing.DB.Path != "" { + dbPath = existing.DB.Path + } + if g.dbPath != "" { + dbPath = g.dbPath + } + + // Show resolved paths. + stepOK("Config: " + dim(cfgPath)) + stepOK("Database: " + dim(dbPath)) + // Persist config now that we have everything. - cfgContent := buildConfigYAML(jiraURL, email, me.EmailAddress, sources) + cfgContent := buildConfigYAML(jiraURL, email, me.EmailAddress, dbPath, sources) if err := os.WriteFile(cfgPath, []byte(cfgContent), 0600); err != nil { stepFail("Could not write config: " + err.Error()) return fmt.Errorf("writing config: %w", err) @@ -223,7 +248,7 @@ func runInit(cmd *cobra.Command, args []string) error { SyncSources: sources, Sync: config.SyncConfig{Interval: "15m", RateLimit: 10}, Me: me.EmailAddress, - DB: config.DBConfig{Path: config.DefaultDBPath()}, + DB: config.DBConfig{Path: dbPath}, } database, err := db.Open(cfg.DB.Path) @@ -286,18 +311,21 @@ func runInit(cmd *cobra.Command, args []string) error { return nil } -func buildConfigYAML(jiraURL, email, meEmail string, sources []config.SyncSource) string { +func buildConfigYAML(jiraURL, email, meEmail, dbPath string, sources []config.SyncSource) string { var sb strings.Builder sb.WriteString("jira:\n") - sb.WriteString(fmt.Sprintf(" url: %s\n", jiraURL)) - sb.WriteString(fmt.Sprintf(" email: %s\n", email)) + fmt.Fprintf(&sb, " url: %s\n", jiraURL) + fmt.Fprintf(&sb, " email: %s\n", email) sb.WriteString(" token: ${JAI_TOKEN}\n") sb.WriteString("\nsync:\n interval: 15m\n rate_limit: 10\n") - sb.WriteString(fmt.Sprintf("\nme: %s\n", meEmail)) + if dbPath != "" && dbPath != config.DefaultDBPath() { + fmt.Fprintf(&sb, "\ndb:\n path: %s\n", dbPath) + } + fmt.Fprintf(&sb, "\nme: %s\n", meEmail) sb.WriteString("\nsync_sources:\n") for _, s := range sources { - sb.WriteString(fmt.Sprintf(" - name: %s\n", s.Name)) - sb.WriteString(fmt.Sprintf(" jql: %s\n", s.JQL)) + fmt.Fprintf(&sb, " - name: %s\n", s.Name) + fmt.Fprintf(&sb, " jql: %s\n", s.JQL) } sb.WriteString(` views: diff --git a/internal/cli/link.go b/internal/cli/link.go new file mode 100644 index 0000000..0884d54 --- /dev/null +++ b/internal/cli/link.go @@ -0,0 +1,130 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/sthadka/jai/internal/output" +) + +var linkFlags struct { + linkType string + listTypes bool +} + +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. + +Links are pushed immediately and are idempotent β€” creating the same +link twice is a no-op. + +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), + 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 " + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + fromKey := strings.ToUpper(args[0]) + toKey := strings.ToUpper(args[1]) + linkType := linkFlags.linkType + + resolved, err := resolveLinkType(cmd, linkType) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return err + } + linkType = resolved + + if err := g.jira.CreateLink(cmd.Context(), linkType, fromKey, toKey); err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("creating link: %w", err) + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]string{ + "from_key": fromKey, + "to_key": toKey, + "link_type": linkType, + "status": "created", + }))) + return nil + } + + fmt.Printf("%s -> %s: linked (%s)\n", fromKey, toKey, linkType) + return nil + }, +} + +func runListLinkTypes(cmd *cobra.Command) error { + linkTypes, err := g.jira.GetLinkTypes(cmd.Context()) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", err.Error()))) + return nil + } + return fmt.Errorf("fetching link types: %w", err) + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]interface{}{ + "link_types": linkTypes, + }))) + return nil + } + + if len(linkTypes) == 0 { + fmt.Println("No link types available.") + return nil + } + + fmt.Println("Available link types:") + for _, lt := range linkTypes { + fmt.Printf(" - %s (inward: %q, outward: %q)\n", lt.Name, lt.Inward, lt.Outward) + } + return nil +} + +func resolveLinkType(cmd *cobra.Command, name string) (string, error) { + linkTypes, err := g.jira.GetLinkTypes(cmd.Context()) + if err != nil { + return "", fmt.Errorf("fetching link types: %w", err) + } + for _, lt := range linkTypes { + if strings.EqualFold(lt.Name, name) { + return lt.Name, nil + } + } + names := make([]string, len(linkTypes)) + for i, lt := range linkTypes { + names[i] = lt.Name + } + return "", fmt.Errorf("unknown link type %q (available: %s)", name, strings.Join(names, ", ")) +} + +func init() { + linkCmd.Flags().StringVar(&linkFlags.linkType, "type", "Relates", "link type name (e.g. Relates, Blocks)") + linkCmd.Flags().BoolVar(&linkFlags.listTypes, "list-types", false, "list available link types") + rootCmd.AddCommand(linkCmd) +} diff --git a/internal/cli/link_test.go b/internal/cli/link_test.go new file mode 100644 index 0000000..64f67b1 --- /dev/null +++ b/internal/cli/link_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "testing" +) + +func TestLinkCmd_DefaultType(t *testing.T) { + if linkFlags.linkType != "" { + t.Skip("flags already initialized") + } + + cmd := linkCmd + flag := cmd.Flags().Lookup("type") + if flag == nil { + t.Fatal("expected --type flag to exist") + } + if flag.DefValue != "Relates" { + t.Errorf("expected default link type %q, got %q", "Relates", flag.DefValue) + } +} + +func TestLinkCmd_ListTypesFlag(t *testing.T) { + cmd := linkCmd + flag := cmd.Flags().Lookup("list-types") + if flag == nil { + t.Fatal("expected --list-types flag to exist") + } + if flag.DefValue != "false" { + t.Errorf("expected default list-types %q, got %q", "false", flag.DefValue) + } +} + +func TestLinkCmd_ArgsValidation(t *testing.T) { + cmd := linkCmd + if cmd.Args == nil { + t.Fatal("expected Args validator to be set") + } + + tests := []struct { + name string + args []string + wantErr bool + }{ + {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}, + } + + 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) + } + }) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 9ead72d..6996a8d 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,7 +11,6 @@ import ( "github.com/sthadka/jai/internal/config" "github.com/sthadka/jai/internal/db" "github.com/sthadka/jai/internal/jira" - "github.com/sthadka/jai/internal/output" "github.com/sthadka/jai/internal/query" synce "github.com/sthadka/jai/internal/sync" ) @@ -71,7 +70,7 @@ func newRootCmd() *cobra.Command { cfg, err := config.Load(cfgPath) if err != nil { - return fmt.Errorf("loading config: %w\n\nRun 'jai init' to set up jai.", err) + return fmt.Errorf("loading config: %w -- run 'jai init' to set up jai", err) } if err := cfg.Validate(); err != nil { return err @@ -193,13 +192,3 @@ func init() { rootCmd.PersistentFlags().StringVar(&g.fields, "fields", "", "comma-separated field names to include in output") } -// jsonErr prints a JSON error envelope to stdout and exits. -func jsonErr(errType, msg string) { - fmt.Println(string(output.Err(errType, msg))) - os.Exit(1) -} - -// jsonError is an alias kept for backward compat within this package. -func jsonError(errType, msg string) { - jsonErr(errType, msg) -} diff --git a/internal/cli/set.go b/internal/cli/set.go index 3a6a623..ee62511 100644 --- a/internal/cli/set.go +++ b/internal/cli/set.go @@ -1,35 +1,125 @@ package cli import ( + "database/sql" "encoding/json" "fmt" + "strings" "github.com/spf13/cobra" "github.com/sthadka/jai/internal/output" ) +var ( + setAddValues []string + setRemoveValues []string + setQuery string +) + var setCmd = &cobra.Command{ - Use: "set ", - Short: "Set a field value on a Jira issue (queued locally until 'jai push')", - Args: cobra.ExactArgs(3), + 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'). + +For scalar fields: + jai set ROX-123 priority High + +For array fields (labels, components, fixVersions): + jai set ROX-123 labels --add rit-escalated + jai set ROX-123 labels --remove old-label + +Bulk operations with comma-separated keys: + jai set ROX-1,ROX-2,ROX-3 priority Major + +Bulk operations with a SQL query: + jai set --query "SELECT key FROM issues WHERE type='Bug'" priority Major`, + Args: func(cmd *cobra.Command, args []string) error { + if setQuery != "" { + if len(args) < 1 || len(args) > 2 { + return fmt.Errorf("with --query, provide [value] (got %d args)", len(args)) + } + return nil + } + return cobra.RangeArgs(2, 3)(cmd, args) + }, RunE: func(cmd *cobra.Command, args []string) error { - issueKey, fieldName, value := args[0], args[1], args[2] + var keys []string + var fieldName string + var scalarValue string + hasAdd := len(setAddValues) > 0 + hasRemove := len(setRemoveValues) > 0 + + if setQuery != "" { + fieldName = args[0] + if len(args) == 2 { + scalarValue = args[1] + } + results, err := g.query.Execute(setQuery) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("QueryError", err.Error()))) + return nil + } + return fmt.Errorf("query: %w", err) + } + keys, err = extractKeys(results.Columns, results.Rows) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("QueryError", err.Error()))) + return nil + } + return err + } + if len(keys) == 0 { + msg := "query returned 0 rows" + if g.jsonOut { + fmt.Println(string(output.Err("QueryError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + } else { + keys = expandKeys(args[0]) + fieldName = args[1] + if len(args) == 3 { + scalarValue = args[2] + } + } + + hasScalarValue := scalarValue != "" + + if (hasAdd || hasRemove) && hasScalarValue { + msg := "cannot combine --add/--remove with a positional value" + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + if !hasAdd && !hasRemove && !hasScalarValue { + msg := "provide a value or use --add/--remove for array fields" + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } - // Ensure pending_changes table exists. if err := g.db.EnsurePendingChangesTable(); err != nil { return err } - // Look up the field to get its jira_id. fieldMap, err := g.db.FieldMapByJiraID() if err != nil { return err } var jiraID string + var fieldType string for id, f := range fieldMap { if f.Name == fieldName { jiraID = id + fieldType = f.Type break } } @@ -42,37 +132,238 @@ var setCmd = &cobra.Command{ return fmt.Errorf("%s", msg) } - // Queue the change. - payload, _ := json.Marshal(map[string]string{"field": jiraID, "value": value}) - if err := g.db.InsertPendingChange(issueKey, "set_field", string(payload)); err != nil { - return err + if (hasAdd || hasRemove) && fieldType != "array" { + msg := fmt.Sprintf("%s is not an array field", fieldName) + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) } - // Optimistic local update. - _, err = g.db.Exec( - fmt.Sprintf("UPDATE issues SET %s = ?, synced_at = datetime('now') WHERE key = ?", fieldName), - value, issueKey, - ) - if err != nil { - // Non-fatal: the pending change is queued. - fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed: %v\n", err) - } - - msg := fmt.Sprintf("%s: %s β†’ %q (pending sync)", issueKey, fieldName, value) - if g.jsonOut { - fmt.Println(string(output.OK(map[string]string{ - "issue_key": issueKey, - "field": fieldName, - "value": value, - "status": "pending", - }))) - return nil + if len(keys) > 1 { + return setBulk(cmd, keys, fieldName, jiraID, scalarValue, fieldType) } - fmt.Println(msg) - return nil + + issueKey := keys[0] + if hasAdd || hasRemove { + return setArrayField(cmd, issueKey, fieldName, jiraID) + } + return setScalarField(cmd, issueKey, fieldName, jiraID, scalarValue, fieldType) }, } +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) + localVal = string(j) + } + + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": payloadVal}) + if err := g.db.InsertPendingChange(issueKey, "set_field", string(payload)); err != nil { + return err + } + + _, err := g.db.Exec( + fmt.Sprintf("UPDATE issues SET %s = ?, synced_at = datetime('now') WHERE key = ?", fieldName), + localVal, issueKey, + ) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed: %v\n", err) + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]interface{}{ + "issue_key": issueKey, + "field": fieldName, + "value": payloadVal, + "status": "pending", + }))) + return nil + } + fmt.Printf("%s: %s β†’ %q (pending sync)\n", issueKey, fieldName, localVal) + return nil +} + +func parseArrayValue(value string) []string { + parts := strings.Split(value, ",") + result := make([]string, 0, len(parts)) + for _, p := range parts { + if v := strings.TrimSpace(p); v != "" { + result = append(result, v) + } + } + return result +} + +func expandKeys(keyArg string) []string { + parts := strings.Split(keyArg, ",") + keys := make([]string, 0, len(parts)) + for _, p := range parts { + if k := strings.TrimSpace(p); k != "" { + keys = append(keys, k) + } + } + return keys +} + +func extractKeys(columns []string, rows [][]interface{}) ([]string, error) { + keyCol := -1 + for i, col := range columns { + if strings.EqualFold(col, "key") { + keyCol = i + break + } + } + if keyCol == -1 { + return nil, fmt.Errorf("query must return a 'key' column") + } + keys := make([]string, 0, len(rows)) + for _, row := range rows { + if keyCol < len(row) && row[keyCol] != nil { + keys = append(keys, fmt.Sprint(row[keyCol])) + } + } + return keys, nil +} + +func setBulk(cmd *cobra.Command, keys []string, fieldName, jiraID, value, fieldType string) error { + hasAdd := len(setAddValues) > 0 + hasRemove := len(setRemoveValues) > 0 + + for _, key := range keys { + if hasAdd || hasRemove { + for _, v := range setAddValues { + payload, _ := json.Marshal(map[string]string{"field": jiraID, "op": "add", "value": v}) + 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}) + 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) + } + payload, _ := json.Marshal(map[string]interface{}{"field": jiraID, "value": payloadVal}) + if err := g.db.InsertPendingChange(key, "set_field", string(payload)); err != nil { + return err + } + } + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]interface{}{ + "count": len(keys), + "keys": keys, + }))) + return nil + } + fmt.Printf("queued %d changes (pending sync)\n", len(keys)) + return nil +} + +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}) + 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}) + if err := g.db.InsertPendingChange(issueKey, "update_field", string(payload)); err != nil { + return err + } + } + + current := readCurrentArray(issueKey, fieldName) + updated := applyArrayOps(current, setAddValues, setRemoveValues) + var localVal string + if len(updated) > 0 { + b, _ := json.Marshal(updated) + localVal = string(b) + } + _, err := g.db.Exec( + fmt.Sprintf("UPDATE issues SET %s = ?, synced_at = datetime('now') WHERE key = ?", fieldName), + localVal, issueKey, + ) + if err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: local update failed: %v\n", err) + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]interface{}{ + "issue_key": issueKey, + "field": fieldName, + "added": setAddValues, + "removed": setRemoveValues, + "status": "pending", + }))) + return nil + } + if len(setAddValues) > 0 { + fmt.Printf("%s: %s += %v (pending sync)\n", issueKey, fieldName, setAddValues) + } + if len(setRemoveValues) > 0 { + fmt.Printf("%s: %s -= %v (pending sync)\n", issueKey, fieldName, setRemoveValues) + } + return nil +} + +func readCurrentArray(issueKey, fieldName string) []string { + var raw sql.NullString + _ = g.db.QueryRow( + fmt.Sprintf("SELECT %s FROM issues WHERE key = ?", fieldName), + issueKey, + ).Scan(&raw) + if !raw.Valid || raw.String == "" { + return nil + } + var arr []string + if err := json.Unmarshal([]byte(raw.String), &arr); err != nil { + return []string{raw.String} + } + return arr +} + +func applyArrayOps(current, adds, removes []string) []string { + removeSet := make(map[string]bool, len(removes)) + for _, v := range removes { + removeSet[v] = true + } + var result []string + for _, v := range current { + if !removeSet[v] { + result = append(result, v) + } + } + existSet := make(map[string]bool, len(result)) + for _, v := range result { + existSet[v] = true + } + for _, v := range adds { + if !existSet[v] { + result = append(result, v) + existSet[v] = true + } + } + return result +} + 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") rootCmd.AddCommand(setCmd) } diff --git a/internal/cli/set_test.go b/internal/cli/set_test.go new file mode 100644 index 0000000..5088b02 --- /dev/null +++ b/internal/cli/set_test.go @@ -0,0 +1,273 @@ +package cli + +import ( + "encoding/json" + "testing" +) + +func TestParseArrayValue(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "single value", + input: "bug", + want: []string{"bug"}, + }, + { + name: "comma separated", + input: "bug,security", + want: []string{"bug", "security"}, + }, + { + name: "comma separated with spaces", + input: "bug, security, rit-escalated", + want: []string{"bug", "security", "rit-escalated"}, + }, + { + name: "empty segments skipped", + input: "bug,,security", + want: []string{"bug", "security"}, + }, + { + name: "whitespace only segments skipped", + input: "bug, ,security", + want: []string{"bug", "security"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseArrayValue(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got[%d]=%q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestArrayFieldPayloadSerialization(t *testing.T) { + arr := parseArrayValue("bug,security") + payload, err := json.Marshal(map[string]interface{}{"field": "labels", "value": arr}) + if err != nil { + t.Fatal(err) + } + + var decoded struct { + Field string `json:"field"` + Value interface{} `json:"value"` + } + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatal(err) + } + + values, ok := decoded.Value.([]interface{}) + if !ok { + t.Fatalf("expected []interface{}, got %T", decoded.Value) + } + if len(values) != 2 { + t.Fatalf("expected 2 values, got %d", len(values)) + } + if values[0] != "bug" || values[1] != "security" { + t.Fatalf("expected [bug, security], got %v", values) + } +} + +func TestExpandKeys(t *testing.T) { + tests := []struct { + name string + input string + want []string + }{ + { + name: "single key", + input: "ROX-123", + want: []string{"ROX-123"}, + }, + { + name: "comma separated", + input: "ROX-1,ROX-2,ROX-3", + want: []string{"ROX-1", "ROX-2", "ROX-3"}, + }, + { + name: "with spaces", + input: "ROX-1, ROX-2, ROX-3", + want: []string{"ROX-1", "ROX-2", "ROX-3"}, + }, + { + name: "empty segments skipped", + input: "ROX-1,,ROX-2", + want: []string{"ROX-1", "ROX-2"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := expandKeys(tt.input) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got[%d]=%q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestExtractKeys(t *testing.T) { + tests := []struct { + name string + columns []string + rows [][]interface{} + want []string + wantErr bool + }{ + { + name: "extracts key column", + columns: []string{"key", "summary"}, + rows: [][]interface{}{ + {"ROX-1", "first"}, + {"ROX-2", "second"}, + }, + want: []string{"ROX-1", "ROX-2"}, + }, + { + name: "case insensitive column match", + columns: []string{"KEY", "summary"}, + rows: [][]interface{}{ + {"ROX-1", "first"}, + }, + want: []string{"ROX-1"}, + }, + { + name: "no key column errors", + columns: []string{"summary", "status"}, + rows: [][]interface{}{ + {"first", "Open"}, + }, + wantErr: true, + }, + { + name: "nil values skipped", + columns: []string{"key"}, + rows: [][]interface{}{ + {"ROX-1"}, + {nil}, + {"ROX-3"}, + }, + want: []string{"ROX-1", "ROX-3"}, + }, + { + name: "empty rows", + columns: []string{"key"}, + rows: nil, + want: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := extractKeys(tt.columns, tt.rows) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got[%d]=%q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestApplyArrayOps(t *testing.T) { + tests := []struct { + name string + current []string + adds []string + removes []string + want []string + }{ + { + name: "add to empty", + current: nil, + adds: []string{"bug"}, + want: []string{"bug"}, + }, + { + name: "add to existing", + current: []string{"bug"}, + adds: []string{"security"}, + want: []string{"bug", "security"}, + }, + { + name: "add duplicate is idempotent", + current: []string{"bug", "security"}, + adds: []string{"bug"}, + want: []string{"bug", "security"}, + }, + { + name: "remove existing", + current: []string{"bug", "security", "rit"}, + removes: []string{"security"}, + want: []string{"bug", "rit"}, + }, + { + name: "remove non-existing is no-op", + current: []string{"bug"}, + removes: []string{"nonexistent"}, + want: []string{"bug"}, + }, + { + name: "add and remove simultaneously", + current: []string{"old-label", "keep"}, + adds: []string{"new-label"}, + removes: []string{"old-label"}, + want: []string{"keep", "new-label"}, + }, + { + name: "remove all leaves empty", + current: []string{"a", "b"}, + removes: []string{"a", "b"}, + want: nil, + }, + { + name: "add multiple", + current: nil, + adds: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := applyArrayOps(tt.current, tt.adds, tt.removes) + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("got[%d]=%q, want %q", i, got[i], tt.want[i]) + } + } + }) + } +} diff --git a/internal/cli/transition.go b/internal/cli/transition.go new file mode 100644 index 0000000..ace67c6 --- /dev/null +++ b/internal/cli/transition.go @@ -0,0 +1,150 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/sthadka/jai/internal/jira" + "github.com/sthadka/jai/internal/output" + synce "github.com/sthadka/jai/internal/sync" +) + +func resolveTransition(name string, transitions []*jira.Transition) (match *jira.Transition, ambiguous []*jira.Transition) { + lower := strings.ToLower(name) + var matches []*jira.Transition + for _, t := range transitions { + if strings.ToLower(t.Name) == lower { + matches = append(matches, t) + } + } + if len(matches) == 1 { + return matches[0], nil + } + if len(matches) > 1 { + return nil, matches + } + return nil, nil +} + +func formatTransitionNames(transitions []*jira.Transition) string { + names := make([]string, len(transitions)) + for i, t := range transitions { + names[i] = fmt.Sprintf(" - %s (id: %s)", t.Name, t.ID) + } + return strings.Join(names, "\n") +} + +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), + RunE: func(cmd *cobra.Command, args []string) error { + issueKey := args[0] + listFlag, _ := cmd.Flags().GetBool("list") + + transitions, err := g.jira.GetTransitions(cmd.Context(), issueKey) + if err != nil { + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", fmt.Sprintf("fetching transitions: %v", err)))) + return nil + } + return fmt.Errorf("fetching transitions for %s: %w", issueKey, err) + } + + if listFlag || len(args) == 1 { + type transitionInfo struct { + ID string `json:"id"` + Name string `json:"name"` + } + items := make([]transitionInfo, len(transitions)) + for i, t := range transitions { + items[i] = transitionInfo{ID: t.ID, Name: t.Name} + } + if g.jsonOut { + fmt.Println(string(output.OK(map[string]interface{}{ + "issue_key": issueKey, + "transitions": items, + }))) + return nil + } + if len(transitions) == 0 { + fmt.Printf("%s: no transitions available\n", issueKey) + return nil + } + fmt.Printf("Available transitions for %s:\n%s\n", issueKey, formatTransitionNames(transitions)) + return nil + } + + targetName := args[1] + match, ambiguous := resolveTransition(targetName, transitions) + + if match == nil && ambiguous != nil { + msg := fmt.Sprintf("ambiguous transition %q matches multiple options:\n%s", targetName, formatTransitionNames(ambiguous)) + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + + if match == nil { + msg := fmt.Sprintf("unknown transition %q for %s", targetName, issueKey) + if len(transitions) > 0 { + msg += fmt.Sprintf("\nAvailable transitions:\n%s", formatTransitionNames(transitions)) + } + if g.jsonOut { + fmt.Println(string(output.Err("ValidationError", msg))) + return nil + } + 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) + if g.jsonOut { + fmt.Println(string(output.Err("JiraError", msg))) + return nil + } + return fmt.Errorf("%s", msg) + } + } + + if g.jsonOut { + fmt.Println(string(output.OK(map[string]string{ + "issue_key": issueKey, + "transition": match.Name, + "transition_id": match.ID, + "status": "pushed", + }))) + return nil + } + + fmt.Printf("%s: transitioned to %q\n", issueKey, match.Name) + return nil + }, +} + +func init() { + transitionCmd.Flags().Bool("list", false, "list available transitions") + rootCmd.AddCommand(transitionCmd) +} diff --git a/internal/cli/transition_test.go b/internal/cli/transition_test.go new file mode 100644 index 0000000..448332c --- /dev/null +++ b/internal/cli/transition_test.go @@ -0,0 +1,97 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/sthadka/jai/internal/jira" +) + +func TestResolveTransition(t *testing.T) { + transitions := []*jira.Transition{ + {ID: "11", Name: "To Do"}, + {ID: "21", Name: "In Progress"}, + {ID: "31", Name: "Done"}, + } + + tests := []struct { + name string + input string + wantMatch string + wantAmbig int + wantNone bool + }{ + {name: "exact match", input: "Done", wantMatch: "31"}, + {name: "case insensitive", input: "done", wantMatch: "31"}, + {name: "mixed case", input: "in progress", wantMatch: "21"}, + {name: "upper case", input: "IN PROGRESS", wantMatch: "21"}, + {name: "no match", input: "Closed", wantNone: true}, + {name: "empty input", input: "", wantNone: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + match, ambiguous := resolveTransition(tt.input, transitions) + + if tt.wantNone { + if match != nil { + t.Errorf("expected no match, got %s", match.Name) + } + if ambiguous != nil { + t.Errorf("expected no ambiguous, got %d", len(ambiguous)) + } + return + } + + if tt.wantAmbig > 0 { + if match != nil { + t.Errorf("expected no match for ambiguous, got %s", match.Name) + } + if len(ambiguous) != tt.wantAmbig { + t.Errorf("expected %d ambiguous, got %d", tt.wantAmbig, len(ambiguous)) + } + return + } + + if match == nil { + t.Fatal("expected match, got nil") + } + if match.ID != tt.wantMatch { + t.Errorf("expected ID %s, got %s", tt.wantMatch, match.ID) + } + }) + } +} + +func TestResolveTransition_Ambiguous(t *testing.T) { + transitions := []*jira.Transition{ + {ID: "11", Name: "Done"}, + {ID: "21", Name: "done"}, + } + + match, ambiguous := resolveTransition("done", transitions) + if match != nil { + t.Errorf("expected no match for ambiguous, got %s", match.Name) + } + if len(ambiguous) != 2 { + t.Errorf("expected 2 ambiguous, got %d", len(ambiguous)) + } +} + +func TestFormatTransitionNames(t *testing.T) { + transitions := []*jira.Transition{ + {ID: "11", Name: "To Do"}, + {ID: "21", Name: "In Progress"}, + } + + result := formatTransitionNames(transitions) + if result == "" { + t.Fatal("expected non-empty result") + } + if !strings.Contains(result, "To Do") || !strings.Contains(result, "In Progress") { + t.Errorf("expected transition names in output, got %s", result) + } + if !strings.Contains(result, "11") || !strings.Contains(result, "21") { + t.Errorf("expected transition IDs in output, got %s", result) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 7142896..40c8946 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -144,6 +144,9 @@ func Load(path string) (*Config, error) { if cfg.DB.Path == "" { cfg.DB.Path = DefaultDBPath() + } else if strings.HasPrefix(cfg.DB.Path, "~/") { + home, _ := os.UserHomeDir() + cfg.DB.Path = filepath.Join(home, cfg.DB.Path[2:]) } return cfg, nil diff --git a/internal/jira/write.go b/internal/jira/write.go index 04f18f0..20dc85b 100644 --- a/internal/jira/write.go +++ b/internal/jira/write.go @@ -38,6 +38,18 @@ func (c *Client) UpdateField(ctx context.Context, issueKey, fieldID string, valu return c.put(ctx, fmt.Sprintf("/rest/api/3/issue/%s", issueKey), payload) } +// 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 { + payload := map[string]interface{}{ + "update": map[string]interface{}{ + fieldID: []map[string]string{ + {op: value}, + }, + }, + } + 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 { payload := map[string]interface{}{ @@ -74,6 +86,37 @@ func (c *Client) ExecuteTransition(ctx context.Context, issueKey, transitionID s return c.post(ctx, fmt.Sprintf("/rest/api/3/issue/%s/transitions", issueKey), payload) } +// LinkType represents an issue link type from the Jira API. +type LinkType struct { + ID string `json:"id"` + Name string `json:"name"` + Inward string `json:"inward"` + Outward string `json:"outward"` +} + +type linkTypesResponse struct { + IssueLinkTypes []LinkType `json:"issueLinkTypes"` +} + +// GetLinkTypes returns the available issue link types. +func (c *Client) GetLinkTypes(ctx context.Context) ([]LinkType, error) { + var resp linkTypesResponse + if err := c.get(ctx, "/rest/api/3/issueLinkType", &resp); err != nil { + return nil, err + } + return resp.IssueLinkTypes, nil +} + +// CreateLink creates a link between two issues. +func (c *Client) CreateLink(ctx context.Context, linkType, inwardKey, outwardKey string) error { + payload := map[string]interface{}{ + "type": map[string]string{"name": linkType}, + "inwardIssue": map[string]string{"key": inwardKey}, + "outwardIssue": map[string]string{"key": outwardKey}, + } + return c.post(ctx, "/rest/api/3/issueLink", payload) +} + func (c *Client) put(ctx context.Context, path string, body interface{}) error { data, err := json.Marshal(body) if err != nil { diff --git a/internal/sync/engine.go b/internal/sync/engine.go index 590eb78..0870a7e 100644 --- a/internal/sync/engine.go +++ b/internal/sync/engine.go @@ -96,9 +96,7 @@ func (e *Engine) DiscoverFields(ctx context.Context, overrides map[string]string // derived from the jira_id to make it unique. if ownerID, taken := takenNames[name]; taken && ownerID != f.ID { suffix := f.ID - if strings.HasPrefix(suffix, "customfield_") { - suffix = suffix[len("customfield_"):] - } + suffix = strings.TrimPrefix(suffix, "customfield_") fmt.Fprintf(os.Stderr, "warning: field %q (%s) renamed to %s_%s (name collision with %s)\n", f.Name, f.ID, name, suffix, ownerID) name = name + "_" + suffix diff --git a/internal/sync/writer.go b/internal/sync/writer.go index 7309163..eff76bc 100644 --- a/internal/sync/writer.go +++ b/internal/sync/writer.go @@ -76,6 +76,8 @@ func (w *Writer) process(ctx context.Context, c *db.PendingChange) error { switch c.Operation { case "set_field": return w.processSetField(ctx, c) + case "update_field": + return w.processUpdateField(ctx, c) case "add_comment": return w.processAddComment(ctx, c) case "transition": @@ -96,6 +98,18 @@ func (w *Writer) processSetField(ctx context.Context, c *db.PendingChange) error return w.client.UpdateField(ctx, c.IssueKey, payload.Field, payload.Value) } +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"` + } + if err := json.Unmarshal([]byte(c.Payload), &payload); err != nil { + return fmt.Errorf("parsing update_field payload: %w", err) + } + return w.client.UpdateFieldOp(ctx, c.IssueKey, payload.Field, payload.Op, payload.Value) +} + func (w *Writer) processAddComment(ctx context.Context, c *db.PendingChange) error { var payload struct { Body string `json:"body"` diff --git a/internal/tui/app.go b/internal/tui/app.go index 8084f0b..3cffe29 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -717,9 +717,10 @@ func (a *App) View() string { } else if (a.mode == ModeDetail || a.mode == ModeFieldPicker || a.mode == ModeFieldValue) && a.detail != nil { detailContent := a.detail.Render(a.width, a.tableHeight()) sb.WriteString(detailContent) - if a.mode == ModeFieldPicker { + switch a.mode { + case ModeFieldPicker: sb.WriteString("\n" + a.renderFieldPickerModal()) - } else if a.mode == ModeFieldValue { + case ModeFieldValue: sb.WriteString("\n" + a.renderFieldValueModal()) } } else { diff --git a/internal/tui/table.go b/internal/tui/table.go index 750ea64..814c01e 100644 --- a/internal/tui/table.go +++ b/internal/tui/table.go @@ -25,8 +25,6 @@ type TableModel struct { groupBy int // column index in rows used for group headers, -1 = none groupVals []string // group value per row (parallel to rows) filtGroupVals []string // group value per filtered row - - colorRules interface{} // []config.ColorRule β€” stored as interface to avoid import cycle } // NewTableModel creates a new table model.