diff --git a/app/controllers/account/entropies_controller.rb b/app/controllers/account/entropies_controller.rb index 9876e99d8e..2891e30711 100644 --- a/app/controllers/account/entropies_controller.rb +++ b/app/controllers/account/entropies_controller.rb @@ -3,7 +3,10 @@ class Account::EntropiesController < ApplicationController def update Current.account.entropy.update!(entropy_params) - redirect_to account_settings_path, notice: "Account updated" + respond_to do |format| + format.html { redirect_to account_settings_path, notice: "Account updated" } + format.json { head :no_content } + end end private diff --git a/app/controllers/account/exports_controller.rb b/app/controllers/account/exports_controller.rb index ab40286af7..19665ff699 100644 --- a/app/controllers/account/exports_controller.rb +++ b/app/controllers/account/exports_controller.rb @@ -5,11 +5,25 @@ class Account::ExportsController < ApplicationController CURRENT_EXPORT_LIMIT = 10 def show + respond_to do |format| + format.html + format.json do + if @export + render json: @export.as_json(only: %i[id status created_at]) + else + head :not_found + end + end + end end def create - Current.account.exports.create!(user: Current.user).build_later - redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." + export = Current.account.exports.create!(user: Current.user) + export.build_later + respond_to do |format| + format.html { redirect_to account_settings_path, notice: "Export started. You'll receive an email when it's ready." } + format.json { render json: export.as_json(only: %i[id status created_at]), status: :accepted } + end end private diff --git a/app/controllers/account/join_codes_controller.rb b/app/controllers/account/join_codes_controller.rb index 217ee603ca..5326108d3d 100644 --- a/app/controllers/account/join_codes_controller.rb +++ b/app/controllers/account/join_codes_controller.rb @@ -3,6 +3,10 @@ class Account::JoinCodesController < ApplicationController before_action :ensure_admin, only: %i[ update destroy ] def show + respond_to do |format| + format.html + format.json { render json: @join_code.as_json(only: %i[code usage_limit usage_count]) } + end end def edit @@ -10,15 +14,24 @@ def edit def update if @join_code.update(join_code_params) - redirect_to account_join_code_path + respond_to do |format| + format.html { redirect_to account_join_code_path } + format.json { head :no_content } + end else - render :edit, status: :unprocessable_entity + respond_to do |format| + format.html { render :edit, status: :unprocessable_entity } + format.json { render json: { errors: @join_code.errors }, status: :unprocessable_entity } + end end end def destroy @join_code.reset - redirect_to account_join_code_path + respond_to do |format| + format.html { redirect_to account_join_code_path } + format.json { render json: @join_code.as_json(only: %i[code usage_limit usage_count]) } + end end private diff --git a/app/controllers/account/settings_controller.rb b/app/controllers/account/settings_controller.rb index 27f47ab995..6afab754b7 100644 --- a/app/controllers/account/settings_controller.rb +++ b/app/controllers/account/settings_controller.rb @@ -4,11 +4,18 @@ class Account::SettingsController < ApplicationController def show @users = @account.users.active.alphabetically.includes(:identity) + respond_to do |format| + format.html + format.json { render json: @account.as_json(only: %i[id name]) } + end end def update @account.update!(account_params) - redirect_to account_settings_path + respond_to do |format| + format.html { redirect_to account_settings_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/boards/entropies_controller.rb b/app/controllers/boards/entropies_controller.rb index e42631eaf6..eb6374a472 100644 --- a/app/controllers/boards/entropies_controller.rb +++ b/app/controllers/boards/entropies_controller.rb @@ -5,6 +5,11 @@ class Boards::EntropiesController < ApplicationController def update @board.update!(entropy_params) + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end private diff --git a/app/controllers/boards/involvements_controller.rb b/app/controllers/boards/involvements_controller.rb index a904f2989d..d9898b400b 100644 --- a/app/controllers/boards/involvements_controller.rb +++ b/app/controllers/boards/involvements_controller.rb @@ -3,5 +3,10 @@ class Boards::InvolvementsController < ApplicationController def update @board.access_for(Current.user).update!(involvement: params[:involvement]) + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/boards/publications_controller.rb b/app/controllers/boards/publications_controller.rb index 9435bd8bf4..eb969bf28c 100644 --- a/app/controllers/boards/publications_controller.rb +++ b/app/controllers/boards/publications_controller.rb @@ -5,10 +5,20 @@ class Boards::PublicationsController < ApplicationController def create @board.publish + respond_to do |format| + format.html + format.turbo_stream + format.json { render json: { key: @board.publication.key, url: published_board_url(@board) } } + end end def destroy @board.unpublish @board.reload + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/cards/pins_controller.rb b/app/controllers/cards/pins_controller.rb index f8da07da31..b4e2548e4e 100644 --- a/app/controllers/cards/pins_controller.rb +++ b/app/controllers/cards/pins_controller.rb @@ -8,15 +8,25 @@ def show def create @pin = @card.pin_by Current.user - broadcast_add_pin_to_tray - render_pin_button_replacement + respond_to do |format| + format.html do + broadcast_add_pin_to_tray + render_pin_button_replacement + end + format.json { head :no_content } + end end def destroy @pin = @card.unpin_by Current.user - broadcast_remove_pin_from_tray - render_pin_button_replacement + respond_to do |format| + format.html do + broadcast_remove_pin_from_tray + render_pin_button_replacement + end + format.json { head :no_content } + end end private diff --git a/app/controllers/cards/publishes_controller.rb b/app/controllers/cards/publishes_controller.rb index a0378eec32..729b27c6ad 100644 --- a/app/controllers/cards/publishes_controller.rb +++ b/app/controllers/cards/publishes_controller.rb @@ -4,11 +4,16 @@ class Cards::PublishesController < ApplicationController def create @card.publish - if add_another_param? - card = @board.cards.create!(status: :drafted) - redirect_to card_draft_path(card), notice: "Card added" - else - redirect_to @card.board + respond_to do |format| + format.html do + if add_another_param? + card = @board.cards.create!(status: :drafted) + redirect_to card_draft_path(card), notice: "Card added" + else + redirect_to @card.board + end + end + format.json { head :no_content } end end diff --git a/app/controllers/columns/left_positions_controller.rb b/app/controllers/columns/left_positions_controller.rb index 7161c093fb..7a27c381ba 100644 --- a/app/controllers/columns/left_positions_controller.rb +++ b/app/controllers/columns/left_positions_controller.rb @@ -4,5 +4,10 @@ class Columns::LeftPositionsController < ApplicationController def create @left_column = @column.left_column @column.move_left + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/columns/right_positions_controller.rb b/app/controllers/columns/right_positions_controller.rb index d43beb6628..2248d1c741 100644 --- a/app/controllers/columns/right_positions_controller.rb +++ b/app/controllers/columns/right_positions_controller.rb @@ -4,5 +4,10 @@ class Columns::RightPositionsController < ApplicationController def create @right_column = @column.right_column @column.move_right + respond_to do |format| + format.html + format.turbo_stream + format.json { head :no_content } + end end end diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index bb605e0acb..3ddc00b43a 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -1,4 +1,21 @@ class Oauth::AuthorizationsController < Oauth::BaseController + # Allow form submission to the client's redirect_uri for OAuth callbacks + # Only widen CSP if the client allows this redirect_uri (validated in before_action) + content_security_policy only: :new do |policy| + if (redirect_uri = params[:redirect_uri]).present? && (client_id = params[:client_id]).present? + client = Oauth::Client.find_by(client_id: client_id) + if client&.allows_redirect?(redirect_uri) + begin + uri = URI.parse(redirect_uri) + origin = "#{uri.scheme}://#{uri.host}#{":#{uri.port}" if uri.port}" + policy.form_action :self, origin + rescue URI::InvalidURIError + # Invalid URI - don't widen CSP + end + end + end + end + before_action :save_oauth_return_url before_action :require_authentication diff --git a/app/controllers/users/roles_controller.rb b/app/controllers/users/roles_controller.rb index 711308d13a..ec4057eb47 100644 --- a/app/controllers/users/roles_controller.rb +++ b/app/controllers/users/roles_controller.rb @@ -4,7 +4,10 @@ class Users::RolesController < ApplicationController def update @user.update!(role_params) - redirect_to account_settings_path + respond_to do |format| + format.html { redirect_to account_settings_path } + format.json { head :no_content } + end end private diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb index 7d79c45873..71f58450e0 100644 --- a/app/controllers/webhooks_controller.rb +++ b/app/controllers/webhooks_controller.rb @@ -16,8 +16,11 @@ def new end def create - webhook = @board.webhooks.create!(webhook_params) - redirect_to webhook + @webhook = @board.webhooks.create!(webhook_params) + respond_to do |format| + format.html { redirect_to @webhook } + format.json + end end def edit @@ -25,12 +28,18 @@ def edit def update @webhook.update!(webhook_params.except(:url)) - redirect_to @webhook + respond_to do |format| + format.html { redirect_to @webhook } + format.json { head :no_content } + end end def destroy @webhook.destroy! - redirect_to board_webhooks_path + respond_to do |format| + format.html { redirect_to board_webhooks_path } + format.json { head :no_content } + end end private diff --git a/app/views/webhooks/create.json.jbuilder b/app/views/webhooks/create.json.jbuilder new file mode 100644 index 0000000000..938c32eade --- /dev/null +++ b/app/views/webhooks/create.json.jbuilder @@ -0,0 +1,2 @@ +json.(@webhook, :id, :name, :url, :subscribed_actions) +json.created_at @webhook.created_at.utc diff --git a/app/views/webhooks/index.json.jbuilder b/app/views/webhooks/index.json.jbuilder new file mode 100644 index 0000000000..c8a13c03c6 --- /dev/null +++ b/app/views/webhooks/index.json.jbuilder @@ -0,0 +1,4 @@ +json.array! @webhooks do |webhook| + json.(webhook, :id, :name, :url, :subscribed_actions) + json.created_at webhook.created_at.utc +end diff --git a/app/views/webhooks/show.json.jbuilder b/app/views/webhooks/show.json.jbuilder new file mode 100644 index 0000000000..79d3cbb526 --- /dev/null +++ b/app/views/webhooks/show.json.jbuilder @@ -0,0 +1,3 @@ +json.(@webhook, :id, :name, :url, :subscribed_actions) +json.created_at @webhook.created_at.utc +json.updated_at @webhook.updated_at.utc diff --git a/cli/PARITY.md b/cli/PARITY.md new file mode 100644 index 0000000000..1aa7199c59 --- /dev/null +++ b/cli/PARITY.md @@ -0,0 +1,43 @@ +# API ↔ CLI Parity Matrix + +Status: ✅ full | ⚠️ partial | — not covered + +| Area | API Docs | CLI Commands | Notes | +|------|----------|--------------|-------| +| Auth | ✅ PAT + magic link | ✅ PAT (`FIZZY_TOKEN`) | OAuth undocumented | +| Identity | ✅ `GET /my/identity` | ✅ `fizzy identity` | | +| Boards | ✅ list/show/create/update/delete | ✅ `boards`, `board show/create/update/delete` | | +| Board publish | ✅ publish/unpublish | ✅ `board publish/unpublish` | Uses publish response URL | +| Board entropy | ✅ | ✅ `board entropy` | | +| Columns | ✅ list/show/create/update/delete | ✅ `columns`, `column show/create/update/delete` | | +| Column reorder | ✅ left/right | ✅ `column left/right` | Shallow routes (no board in path) | +| Cards list/show | ✅ filters + show | ✅ `cards`, `show` | Search via `terms[]` filter | +| Card create/update/delete | ✅ | ✅ `card`, `card update`, `card delete` | | +| Card image | ✅ delete | ✅ `card image delete` | | +| Card lifecycle | ✅ close/reopen/postpone | ✅ `close`, `reopen`, `postpone` | | +| Card triage | ✅ triage/untriage | ✅ `triage`, `untriage` | | +| Card tagging | ✅ | ✅ `tag`, `untag` | | +| Card assignment | ✅ | ✅ `assign`, `unassign` | | +| Card watch | ✅ | ✅ `watch`, `unwatch` | | +| Card gold | ✅ | ✅ `gild`, `ungild` | | +| Card publish | ✅ | ✅ `card publish` | | +| Card move | ✅ | ✅ `card move --to` | Top-level `board_id` | +| Comments | ✅ list/show/create/update/delete | ✅ `comments`, `comment`, `comment edit/delete` | Update returns 204 | +| Reactions | ✅ list/create/delete | ✅ `reactions`, `react`, `react delete` | | +| Steps | ✅ show/create/update/delete | ✅ `step show/create/update/delete` | | +| Tags | ✅ list | ✅ `tags` | | +| Users | ✅ list/show/update/delete/role | ✅ `people`, `user show/update/delete/role` | | +| Notifications | ✅ list/read/unread/bulk | ✅ `notifications`, `read/unread/read-all` | | +| Account | ✅ show/update/entropy/join-code/export | ✅ `account show/update/entropy/join-code/export` | Export accepts 202 | +| Webhooks | ✅ list/show/create/update/delete | ✅ `webhooks`, `webhook create/show/update/delete` | Actions match `PERMITTED_ACTIONS` | + +## Intentional Omissions + +These API endpoints exist but are intentionally not documented or exposed in the CLI: + +| Endpoint | Reason | +|----------|--------| +| `POST/DELETE /cards/:card_id/pin` | Internal UI feature | +| `PUT /boards/:board_id/involvement` | Internal UI feature | + +These are Tier 3 endpoints—functional but not part of the public API contract. diff --git a/cli/PLAN.md b/cli/PLAN.md new file mode 100644 index 0000000000..cf8232b0eb --- /dev/null +++ b/cli/PLAN.md @@ -0,0 +1,1141 @@ +# Fizzy CLI (`fizzy`) - Agent-First Design + +**fizzy** — the Fizzy CLI — a tool designed primarily for AI coding agents while remaining intuitive for humans. The name is the product name: direct, memorable, and easy to script. + +> **Reference implementation**: `~/Work/basecamp/bcq` — port architecture, patterns, and test approach. +> **Dev server**: `http://fizzy.localhost:3006` — smoke testing target. + +## Agent Compatibility + +`fizzy` is designed to work with **any AI agent that can execute shell commands**: + +| Agent | Integration Level | Notes | +|-------|-------------------|-------| +| **Claude Code** | Full | Hooks, skills, MCP (advanced features) | +| **OpenCode** | Full | CLI + JSON output | +| **Codex** | Full | CLI + JSON output | +| **Any shell-capable agent** | Full | Standard CLI interface | + +**Philosophy**: The CLI is the universal foundation. Agent-specific enhancements (Claude Code hooks, skills, MCP) are optional layers on top. No agent is privileged over another for core functionality. + +--- + +## Naming: `fizzy` + + +```bash +fizzy boards | jq '.data[0]' # Extract from envelope +fizzy boards -q | jq '.[0]' # Quiet mode: raw data +fizzy cards --assignee me --json # Pipeline-friendly JSON +``` + +--- + +## Core Philosophy: Agent-First Design + +### What Would an AI Agent Want? + +1. **Instant orientation** — `fizzy` with no args → everything needed to start +2. **Predictable patterns** — Learn once, apply everywhere +3. **Rich context** — Equivalent to web UI, in agent-digestible form +4. **Token efficiency** — Dense output, no fluff +5. **Breadcrumbs** — "What can I do next?" after every action +6. **Error recovery** — Errors that help fix the problem +7. **ID/URL-based writes** — Unambiguous, explicit operations + +### Output Philosophy + +| Context | Format | Reason | +|---------|--------|--------| +| Piped / scripted | JSON envelope | Structured, with context and breadcrumbs | +| TTY / interactive | Markdown | Rich, readable, scannable | +| `--json` | JSON envelope | Force JSON output | +| `--md` | Markdown | Force Markdown output | +| `--quiet` / `--data` | Raw data only | Just `.data`, no envelope | + +### JSON Output Contract + +**Default (envelope):** +```bash +fizzy boards # Returns envelope with data, summary, breadcrumbs +fizzy boards --json # Forces JSON envelope even in TTY +``` + +**Quiet mode (data-only):** +```bash +fizzy boards --quiet # Returns raw data array, no envelope +fizzy boards -q # Same as --quiet +fizzy boards --data # Alias for --quiet +``` + +**Piping examples:** +```bash +fizzy boards | jq '.data[0]' # Extract from envelope +fizzy boards --quiet | jq '.[0]' # Direct array access +fizzy boards -q | jq '.[] | .name' # Iterate raw data +``` + +--- + +## S-Tier Outcome (Bar) + +- Exhaustive coverage of public Fizzy API endpoints with parity checks +- Zero ambiguity on writes (IDs/URLs only) with clear, corrective errors +- High-confidence tests: happy paths, error paths, and output formats +- Agent ergonomics: consistent verbs, rich JSON envelopes, breadcrumbs +- Fast by default with resilient auth, retries, and rate-limit handling + +### Quality Gates + +Every task must pass before commit: +1. **Unit tests pass** — `bats test/*.bats` +2. **Smoke test pass** — Manual verification against `http://fizzy.localhost:3006` +3. **No regressions** — Existing tests continue to pass + +Atomic commits: one logical change per commit, tests green before moving on. + +## Layered Configuration + +Configuration builds up from global → local, like git config: + +``` +~/.config/fizzy/ +├── config.json # Global defaults (all repos) +├── credentials.json # OAuth tokens +├── client.json # DCR client registration +└── accounts.json # Discovered accounts + +.fizzy/ # Per-directory/repo configuration +├── config.json # Local overrides +└── cache/ # Local cache (optional) +``` + +### Config Hierarchy + +``` +Global (~/.config/fizzy/config.json) + └─ Local (.fizzy/config.json) + └─ Environment variables + └─ Command-line flags +``` + +Each layer overrides the previous. + +### Configuration Options + +```json +// ~/.config/fizzy/config.json (global) +{ + "default_account_id": 12345, + "output_format": "auto", // auto | json | markdown + "color": true, + "pager": "less -R" +} + +// .fizzy/config.json (per-directory) +{ + "board_id": 67890, + "board_name": "Launch Board", // For display + "column_id": 11111, // Default column + "column_name": "In Progress", + "team": ["@jeremy", "@david"] // Quick @-mention completion +} +``` + +### Config Commands + +```bash +fizzy config # Show effective config +fizzy config --global # Show global only +fizzy config --local # Show local only + +fizzy config init # Interactive: create .fizzy/config.json +fizzy config set board 67890 # Set locally +fizzy config set board 67890 --global # Set globally +fizzy config unset board # Remove local override + +fizzy config board # Interactive board picker +fizzy config column # Interactive column picker +``` + +--- + +## Quick-Start Mode (No Arguments) + +``` +$ fizzy + +fizzy v0.1.0 — Fizzy CLI +Auth: ✓ dev@example.com @ Acme + +QUICK START + fizzy boards List boards + fizzy cards Your assigned cards + fizzy search "query" Find anything + +COMMON TASKS + fizzy card "title" Create card + fizzy triage --to Triage card to column + fizzy close Close card + fizzy comment "text" --on Add comment + +CONTEXT + Account: Acme (897362094) + Board: Fizzy 4 (67890) ← from .fizzy/config.json + Column: Development (11111) + +NEXT: fizzy cards +``` + +### Fast Mode (No State Fetching) + +Quick-start can optionally fetch live counts (cards assigned, unread messages, etc.), but this requires API calls and can be slow for agents: + +```bash +fizzy # Default: no API calls, just orientation +fizzy --state # Include live counts (requires API calls) +fizzy --fast # Explicit: no state fetching (same as default) +``` + +The default prioritizes speed for agents. Use `--state` when you want live statistics. + +--- + +## Response Structure + +### Universal Envelope (JSON) + +```json +{ + "ok": true, + "data": [ ... ], + "summary": "47 cards assigned to you", + "context": { + "account": {"id": 12345, "name": "Acme"}, + "board": {"id": 67890, "name": "Fizzy 4"}, + "column": {"id": 11111, "name": "Development"} + }, + "breadcrumbs": [ + {"action": "create", "cmd": "fizzy card \"content\""}, + {"action": "filter", "cmd": "fizzy cards --status closed"}, + {"action": "search", "cmd": "fizzy search \"query\""} + ], + "meta": { + "total": 47, + "showing": 25, + "page": 1, + "next": "fizzy cards --page 2" + } +} +``` + +### Markdown Mode + +```markdown +## Your Cards (47) + +| # | Content | Due | Assignee | +|---|---------|-----|----------| +| 123 | Fix login bug | Jan 15 | @jeremy | +| 124 | Update docs | Jan 20 | @david | + +*Showing 25 of 47* — `fizzy cards --page 2` for more + +### Actions +- Create: `fizzy card "content"` +- Close: `fizzy close ` +- Filter: `fizzy cards --status closed` +``` + +--- + +## Commands + +### Query Commands (Read) + +```bash +fizzy # Quick-start +fizzy boards # List boards +fizzy cards # Assigned cards (uses context) +fizzy cards --all # All cards in board +fizzy cards --in "Fizzy 4" # In specific board +fizzy cards --column "In Progress" # In specific column +fizzy search "query" # Global search +fizzy show card 123 # Full card details +fizzy show board "Fizzy 4" # Full board details +fizzy columns --in "Fizzy 4" # List columns +fizzy people # List people +``` + +### Action Commands (Write) + +**Write commands require explicit IDs or full Fizzy URLs. No name resolution for writes.** + +```bash +fizzy card "Fix the login bug" # Create (uses context board/column) +fizzy card "Fix bug" --board 67890 # Create with explicit board ID +fizzy card "Fix bug" --board 67890 --column 11111 # Create with explicit column ID +fizzy close 123 # Close card by number +fizzy close 123 124 125 # Close multiple +fizzy reopen 123 # Reopen by number +fizzy triage 123 --to 456 # Triage to column ID +fizzy postpone 123 # Move to "Not Now" +fizzy comment "LGTM" --on 123 # Add comment by card number +fizzy assign 123 --to 456 # Toggle assignment by person ID +fizzy gild 123 # Mark as golden +``` + +**Why ID-only for writes?** +- Prevents accidental writes to wrong resources +- Eliminates ambiguity errors during write operations +- Name resolution can fail; writes should be deterministic +- Agents should resolve names in read operations, then use IDs for writes + +**Fizzy URLs work too:** +```bash +fizzy close http://fizzy.localhost:3006/1234567/boards/67890/cards/123 +``` + +### Utility Commands + +```bash +fizzy config # Show/set configuration +fizzy auth login # OAuth flow (browser) +fizzy auth login --no-browser # OAuth flow (manual code entry) +fizzy auth logout # Clear credentials +fizzy auth status # Auth info +fizzy version # Version info +``` + +### Global Flags + +```bash +--json, -j # Force JSON envelope output +--md, -m # Force Markdown output +--quiet, -q # Raw data only (no envelope) +--data # Alias for --quiet +--verbose, -v # Debug output +--board, -b ID # Override board context +--account, -a ID # Override account +--help, -h # Help +``` + +--- + +## Command → API Mapping + +### API URL Structure + +All API calls are scoped to an **account slug** (numeric external_account_id): + +``` +http://fizzy.localhost:3006/{account_slug}/boards +http://fizzy.localhost:3006/{account_slug}/cards +http://fizzy.localhost:3006/897362094/cards/123 +``` + +The account slug is a 7+ digit numeric ID (e.g., `897362094`). + +### Query Commands → API Endpoints + +| Command | API Endpoint | Notes | +|---------|--------------|-------| +| `fizzy boards` | `GET /:slug/boards` | List accessible boards | +| `fizzy show board ` | `GET /:slug/boards/:id` | Board details | +| `fizzy columns --board ` | `GET /:slug/boards/:id/columns` | List columns | +| `fizzy cards` | `GET /:slug/cards` | List cards (with filters) | +| `fizzy cards --search "q"` | `GET /:slug/cards?terms[]=q` | Search via `terms[]` param | +| `fizzy show card ` | `GET /:slug/cards/:num` | Card details (includes steps) | +| `fizzy comments --on ` | `GET /:slug/cards/:num/comments` | List comments | +| `fizzy people` | `GET /:slug/users` | List users | +| `fizzy tags` | `GET /:slug/tags` | List tags | +| `fizzy notifications` | `GET /:slug/notifications` | List notifications | + +### Action Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy card "title"` | `/:slug/boards/:bid/cards` | POST | Create card | +| `fizzy close ` | `/:slug/cards/:num/closure` | POST | Close card | +| `fizzy reopen ` | `/:slug/cards/:num/closure` | DELETE | Reopen card | +| `fizzy triage --to ` | `/:slug/cards/:num/triage` | POST | Move to column (`column_id` param) | +| `fizzy untriage ` | `/:slug/cards/:num/triage` | DELETE | Send back to triage | +| `fizzy postpone ` | `/:slug/cards/:num/not_now` | POST | Move to "Not Now" | +| `fizzy comment "text" --on ` | `/:slug/cards/:num/comments` | POST | Add comment | +| `fizzy assign --to ` | `/:slug/cards/:num/assignments` | POST | Toggle assignment | +| `fizzy tag --with "name"` | `/:slug/cards/:num/taggings` | POST | Toggle tag | +| `fizzy watch ` | `/:slug/cards/:num/watch` | POST | Subscribe | +| `fizzy unwatch ` | `/:slug/cards/:num/watch` | DELETE | Unsubscribe | +| `fizzy gild ` | `/:slug/cards/:num/goldness` | POST | Mark golden | +| `fizzy ungild ` | `/:slug/cards/:num/goldness` | DELETE | Remove golden | +| `fizzy step "text" --on ` | `/:slug/cards/:num/steps` | POST | Add step | +| `fizzy react "👍" --on ` | `/:slug/cards/:num/comments/:cid/reactions` | POST | Add reaction | + +### Notification Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy notifications` | `/:slug/notifications` | GET | List notifications | +| `fizzy notifications read ` | `/:slug/notifications/:id/reading` | POST | Mark as read | +| `fizzy notifications unread ` | `/:slug/notifications/:id/reading` | DELETE | Mark as unread | +| `fizzy notifications read --all` | `/:slug/notifications/bulk_reading` | POST | Mark all as read | + +### Board Management Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy board create "name"` | `/:slug/boards` | POST | Create board | +| `fizzy board update ` | `/:slug/boards/:id` | PUT | Update board | +| `fizzy board delete ` | `/:slug/boards/:id` | DELETE | Delete board | +| `fizzy board publish ` | `/:slug/boards/:id/publication` | POST | Publish publicly, returns `shareable_key` | +| `fizzy board unpublish ` | `/:slug/boards/:id/publication` | DELETE | Unpublish board | +| `fizzy board entropy --days N` | `/:slug/boards/:id/entropy` | PUT | Set auto-postpone period | + +### Column Management Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy column create "name" --board ` | `/:slug/boards/:bid/columns` | POST | Create column | +| `fizzy column update ` | `/:slug/boards/:bid/columns/:id` | PUT | Update column | +| `fizzy column delete ` | `/:slug/boards/:bid/columns/:id` | DELETE | Delete column | +| `fizzy column left ` | `/:slug/boards/:bid/columns/:id/left_position` | POST | Move column left | +| `fizzy column right ` | `/:slug/boards/:bid/columns/:id/right_position` | POST | Move column right | + +### Card Extended Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy card publish ` | `/:slug/cards/:num/publish` | POST | Publish drafted card | +| `fizzy card move --board ` | `/:slug/cards/:num/board` | PUT | Move card to another board | + +### User Management Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy user show ` | `/:slug/users/:id` | GET | Show user details | +| `fizzy user update ` | `/:slug/users/:id` | PUT | Update user | +| `fizzy user delete ` | `/:slug/users/:id` | DELETE | Deactivate user | +| `fizzy user role --role admin` | `/:slug/users/:id/role` | PUT | Change user role | + +### Account Management Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy account show` | `/:slug/account/settings` | GET | Show account settings | +| `fizzy account update` | `/:slug/account/settings` | PUT | Update account name | +| `fizzy account entropy --days N` | `/:slug/account/entropy` | PUT | Set default auto-postpone | +| `fizzy account join-code` | `/:slug/account/join_code` | GET | Show join code | +| `fizzy account join-code reset` | `/:slug/account/join_code` | DELETE | Reset join code | +| `fizzy account export` | `/:slug/account/exports` | POST | Start data export | + +### Webhook Commands → API Endpoints + +| Command | API Endpoint | HTTP | Notes | +|---------|--------------|------|-------| +| `fizzy webhooks --board ` | `/:slug/boards/:bid/webhooks` | GET | List webhooks | +| `fizzy webhook show ` | `/:slug/boards/:bid/webhooks/:id` | GET | Show webhook | +| `fizzy webhook create --url ` | `/:slug/boards/:bid/webhooks` | POST | Create webhook | +| `fizzy webhook update ` | `/:slug/boards/:bid/webhooks/:id` | PUT | Update webhook | +| `fizzy webhook delete ` | `/:slug/boards/:bid/webhooks/:id` | DELETE | Delete webhook | + +### Card Query Parameters (Filters) + +The `GET /:slug/cards` endpoint supports rich filtering: + +| Filter | Parameter | Example | +|--------|-----------|---------| +| By board | `board_ids[]` | `?board_ids[]=abc123` | +| By tag | `tag_ids[]` | `?tag_ids[]=xyz789` | +| By assignee | `assignee_ids[]` | `?assignee_ids[]=user1` | +| By creator | `creator_ids[]` | `?creator_ids[]=user2` | +| By status | `indexed_by` | `?indexed_by=closed` (values: `all`, `closed`, `not_now`, `stalled`, `postponing_soon`, `golden`) | +| By search | `terms[]` | `?terms[]=bug&terms[]=login` | +| Sort | `sorted_by` | `?sorted_by=newest` (values: `latest`, `newest`, `oldest`) | +| Assignment | `assignment_status` | `?assignment_status=unassigned` | +| Created date | `creation` | `?creation=thisweek` | +| Closed date | `closure` | `?closure=today` | + +### Important: Card Numbers vs IDs + +Cards have both: +- **`number`**: Sequential per-account (e.g., `1`, `2`, `123`) — used in URLs and display +- **`id`**: UUID (e.g., `03f5vaeq985jlvwv3arl4srq2`) — internal identifier + +API endpoints use **card number** in URLs: `GET /:slug/cards/123` (not the UUID). + +--- + +## Authentication + +### OAuth 2.1 with Dynamic Client Registration + +Fizzy implements **full OAuth 2.1** (verified in `config/routes.rb` and `app/controllers/oauth/`): + +| Feature | Endpoint | Notes | +|---------|----------|-------| +| **Discovery** | `GET /.well-known/oauth-authorization-server` | RFC 8414 metadata | +| **DCR** | `POST /oauth/clients` | Dynamic client registration | +| **Authorization** | `GET /oauth/authorization/new` | Authorization code flow | +| **Token** | `POST /oauth/token` | Token exchange | +| **Revocation** | `POST /oauth/revocation` | Token revocation | + +**OAuth Metadata** (from `Oauth::MetadataController`): +```json +{ + "issuer": "http://fizzy.localhost:3006", + "authorization_endpoint": "http://fizzy.localhost:3006/oauth/authorization/new", + "token_endpoint": "http://fizzy.localhost:3006/oauth/token", + "registration_endpoint": "http://fizzy.localhost:3006/oauth/clients", + "revocation_endpoint": "http://fizzy.localhost:3006/oauth/revocation", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "token_endpoint_auth_methods_supported": ["none"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["read", "write"] +} +``` + +**Key points**: +- Public clients only (`token_endpoint_auth_methods_supported: ["none"]`) +- PKCE required (`S256`) +- Two scopes: `read` (read-only), `write` (read+write) + +### Auth Flow + +```bash +# Standard flow (opens browser) +fizzy auth login + +# Headless/remote flow (manual code entry) +fizzy auth login --no-browser +# Prints: Visit http://fizzy.localhost:3006/oauth/authorization/new?... +# Prints: Enter authorization code: [user pastes code] +``` + +### Alternative: Personal Access Tokens + +For simple scripts, users can generate tokens manually: +1. Go to profile → API section → Personal access tokens +2. Generate token with `read` or `read+write` scope +3. Use via `FIZZY_TOKEN` environment variable + +### Token File Security + +Credentials stored at `~/.config/fizzy/credentials.json` with permissions `0600` (owner read/write only). + +### Account Resolution + +Account ID is required for all API calls. Resolution order: + +1. `--account` flag +2. `FIZZY_ACCOUNT_ID` environment variable +3. `.fizzy/config.json` → `account_id` +4. `~/.config/fizzy/config.json` → `account_id` +5. Auto-discovered from token (if only one account) + +**Fail fast with clear hints:** +```json +{ + "ok": false, + "error": "No account configured", + "code": "auth_required", + "hint": "Run: fizzy auth login", + "accounts": [ + {"id": 12345, "name": "37signals"}, + {"id": 67890, "name": "Side Board"} + ] +} +``` + +--- + +## Name Resolution (Read Commands Only) + +**Names are supported for read operations; write operations require IDs.** + +```bash +# Read commands support names: +fizzy cards --in "Fizzy 4" # By name +fizzy cards --in 67890 # By ID +fizzy show board "Fizzy 4" # By name +fizzy people --search "@david" # By @handle + +# Write commands require IDs: +fizzy close 123 # ID required +fizzy comment "Done!" --on 123 # ID required +fizzy assign 123 --to 456 # IDs required +``` + +### Workflow: Resolve Then Act + +```bash +# Step 1: Find the resource (read, supports names) +fizzy show board "Fizzy 4" --quiet | jq '.id' +# → 67890 + +# Step 2: Act on it (write, requires ID) +fizzy card "Fix bug" --board 67890 +``` + +### Ambiguous Name Handling + +When a name matches multiple resources: +```json +{ + "ok": false, + "error": "Ambiguous board name", + "code": "ambiguous", + "matches": [ + {"id": 67890, "name": "Fizzy 4"}, + {"id": 11111, "name": "Fizzy Classic"} + ], + "hint": "Use --board 67890 or more specific name" +} +``` + +--- + +## Rich Detail Views + +```bash +$ fizzy show card 123 --md +``` + +```markdown +## Card #123: Fix the login bug + +| Field | Value | +|-------|-------| +| Board | Fizzy 4 > Development | +| Status | Active | +| Assignee | @jeremy | +| Due | January 15, 2024 (in 5 days) | +| Created | January 10 by @david | + +### Description +Login form throws 500 when email contains "+". + +### Comments (2) +| When | Who | Comment | +|------|-----|---------| +| Jan 11 | @jeremy | On it, fix by EOD. | +| Jan 10 | @david | Can you look at this? | + +### Actions +- `fizzy close 123` — Close +- `fizzy comment "text" --on 123` — Comment +- `fizzy assign 123 --to @name` — Reassign +``` + +--- + +## Error Design + +### Helpful, Actionable Errors + +```bash +$ fizzy show card 99999 +``` + +```json +{ + "ok": false, + "error": "Card not found", + "code": "not_found", + "searched": {"type": "card", "id": 99999}, + "suggestions": [ + {"id": 999, "content": "Fix header", "board": "Fizzy 4"}, + {"id": 9999, "content": "Update docs", "board": "Marketing"} + ], + "hint": "Try: fizzy search \"your keywords\"" +} +``` + +### Error Codes + +| Code | Exit | Meaning | +|------|------|---------| +| `success` | 0 | OK | +| `usage` | 1 | Bad arguments | +| `not_found` | 2 | Resource not found | +| `auth_required` | 3 | Need to login | +| `forbidden` | 4 | Permission denied | +| `rate_limit` | 5 | Too many requests | +| `network` | 6 | Connection failed | +| `api_error` | 7 | Server error | +| `ambiguous` | 8 | Multiple matches for name | + +--- + +## Architecture + +``` +fizzy/cli/ +├── bin/ +│ └── fizzy # Entry point (~130 lines) +├── lib/ +│ ├── core.sh # Output, formatting, utilities +│ ├── config.sh # 7-layer config management +│ ├── api.sh # HTTP client, caching, retries +│ ├── auth.sh # OAuth 2.1 + DCR + PKCE +│ ├── names.sh # Name → ID resolution +│ └── commands/ +│ ├── quick_start.sh # No-args orientation +│ ├── boards.sh # boards, show board +│ ├── cards.sh # cards, show card, card (create) +│ ├── actions.sh # close, reopen, move, assign, etc. +│ ├── comments.sh # comment, react +│ ├── search.sh # search +│ ├── people.sh # people +│ ├── config.sh # config management +│ └── auth.sh # auth login/logout/status +├── test/ +│ ├── run.sh # Test runner +│ ├── helpers.sh # Test utilities & assertions +│ ├── fixtures/ # Mock API responses +│ └── *.bats # bats-core test files +├── completions/ +│ ├── fizzy.bash +│ └── fizzy.zsh +└── docs/ + └── README.md +``` + +### Key bcq Patterns to Port + +These patterns from bcq make it "S-Tier" — port them to fizzy: + +#### 1. OAuth 2.1 Flow (`lib/auth.sh`) +``` +_discover_oauth_config() → Fetch /.well-known/oauth-authorization-server +_register_client() → DCR to get client_id (stored in client.json) +_authorize() → PKCE flow with code_verifier/challenge +_exchange_code() → Token exchange, store in credentials.json +ensure_auth() → Auto-refresh expired tokens +``` + +#### 2. 7-Layer Config (`lib/config.sh`) +``` +1. /etc/fizzy/config.json (system-wide) +2. ~/.config/fizzy/config.json (user global) +3. /.fizzy/config.json (repo-level) +4. .fizzy/config.json (current dir, walks up) +5. Environment: FIZZY_* +6. Flags: --account, --board +7. Global flags parsed early +``` + +#### 3. HTTP Client (`lib/api.sh`) +``` +api_get(), api_post(), api_put(), api_delete() +├── ETag-based caching (If-None-Match → 304) +├── Exponential backoff (1s → 2s → 4s → 8s → 16s) +├── Rate limit handling (429 + Retry-After) +├── Token refresh on 401 +└── Semantic error mapping (curl codes → exit codes) +``` + +#### 4. Output System (`lib/core.sh`) +``` +output(data, summary, breadcrumbs, md_renderer) +├── Auto-detect TTY vs pipe +├── JSON envelope with context +├── Markdown for humans +└── --quiet for raw data +``` + +#### 5. Test Infrastructure (`test/`) +``` +bats-core with: +├── setup/teardown (temp dirs, isolated home) +├── Assertions (assert_success, assert_json_value) +├── Fixtures (mock API responses) +└── Helpers (create_credentials, init_git_repo) +``` + +--- + +## Implementation Phases + +Each phase must have passing tests before moving to the next. + +### Phase 1: Foundation (Core Infrastructure) + +**Goal**: Skeleton that can authenticate and make API calls. + +- [ ] `bin/fizzy` — Entry point with command dispatch +- [ ] `lib/core.sh` — `output()`, `die()`, format detection +- [ ] `lib/config.sh` — 7-layer config loading +- [ ] `lib/api.sh` — `api_get()`, `api_post()`, caching, retries +- [ ] `lib/auth.sh` — OAuth 2.1 discovery, DCR, PKCE, token management +- [ ] `cmd_auth()` — login, logout, status subcommands +- [ ] Quick-start mode (no args → orientation) + +**Tests**: `test/auth.bats`, `test/config.bats`, `test/core.bats` +**Smoke test**: `fizzy auth login` against dev server + +### Phase 2: Core Queries + +**Goal**: Read operations for boards, cards, users. + +- [ ] `fizzy boards` — List boards +- [ ] `fizzy show board ` — Board details +- [ ] `fizzy columns --board ` — List columns +- [ ] `fizzy cards` — List with filters +- [ ] `fizzy show card ` — Card details with steps/comments +- [ ] `fizzy people` — List users +- [ ] `fizzy tags` — List tags + +**Tests**: `test/boards.bats`, `test/cards.bats`, `test/people.bats` +**Smoke test**: All queries return valid JSON against dev server + +### Phase 3: Core Actions + +**Goal**: Write operations for cards. + +- [ ] `fizzy card "title"` — Create card +- [ ] `fizzy close ` — Close card +- [ ] `fizzy reopen ` — Reopen card +- [ ] `fizzy triage --to ` — Triage to column +- [ ] `fizzy untriage ` — Send back to triage +- [ ] `fizzy postpone ` — Move to Not Now +- [ ] `fizzy comment "text" --on ` — Add comment +- [ ] `fizzy assign --to ` — Toggle assignment +- [ ] `fizzy tag --with "name"` — Toggle tag +- [ ] `fizzy watch ` / `unwatch` — Toggle subscription +- [ ] `fizzy gild ` / `ungild` — Toggle golden +- [ ] `fizzy step "text" --on ` — Add step +- [ ] `fizzy notifications` — List notifications +- [ ] `fizzy notifications read ` — Mark as read +- [ ] `fizzy notifications read --all` — Mark all as read + +**Tests**: `test/actions.bats`, `test/comments.bats`, `test/notifications.bats` +**Smoke test**: Create → triage → close → reopen → comment flow + +### Phase 4: Agent Ergonomics + +**Goal**: Make it delightful for AI agents. + +- [ ] Name resolution for read commands (boards, people) +- [ ] Breadcrumb generation in JSON envelope +- [ ] Error suggestions (similar cards, hint text) +- [ ] `fizzy config` — show/set/init +- [ ] Pagination handling + +**Tests**: `test/names.bats`, `test/errors.bats` +**Smoke test**: Name resolution works, errors are helpful + +### Phase 5: Polish + +**Goal**: Production-ready. + +- [ ] Tab completion (bash, zsh) +- [ ] Rate limiting + retry (429 handling) +- [ ] Comprehensive test coverage +- [ ] README documentation +- [ ] MCP server mode (`fizzy mcp serve`) — optional + +--- + +## Test Requirements + +### Test Categories + +Every command must have: + +1. **Happy path test** — Normal usage works +2. **Error path tests** — Proper error codes and messages +3. **Output format tests** — JSON and Markdown both correct +4. **Context tests** — Respects config hierarchy + +### Test Infrastructure (from bcq) + +```bash +# test/run.sh — Test runner +#!/usr/bin/env bash +bats test/*.bats + +# test/helpers.sh — Shared test utilities +setup() { + TEST_DIR="$(mktemp -d)" + export HOME="$TEST_DIR/home" + export XDG_CONFIG_HOME="$HOME/.config" + mkdir -p "$XDG_CONFIG_HOME/fizzy" +} + +teardown() { + rm -rf "$TEST_DIR" +} + +# Custom assertions +assert_json_value() { + local path="$1" expected="$2" + actual=$(echo "$output" | jq -r "$path") + [[ "$actual" == "$expected" ]] || fail "Expected $path to be '$expected', got '$actual'" +} + +assert_exit_code() { + [[ "$status" -eq "$1" ]] || fail "Expected exit code $1, got $status" +} +``` + +### Example Tests + +```bash +# test/cards.bats + +load helpers + +@test "fizzy cards returns JSON envelope when piped" { + run bash -c 'fizzy cards | jq -r .ok' + assert_success + assert_output 'true' +} + +@test "fizzy cards returns raw array with --quiet" { + run bash -c 'fizzy cards --quiet | jq -r type' + assert_success + assert_output 'array' +} + +@test "fizzy cards respects .fizzy/config.json board" { + mkdir -p .fizzy + echo '{"board_id": "abc123"}' > .fizzy/config.json + run fizzy cards --json + assert_success + assert_json_value '.context.board.id' 'abc123' +} + +@test "fizzy card requires title" { + run fizzy card + assert_failure + assert_exit_code 1 + assert_json_value '.code' 'usage' +} + +@test "fizzy close requires card number" { + run fizzy close + assert_failure + assert_exit_code 1 +} + +@test "fizzy auth status shows logged out when no credentials" { + run fizzy auth status --json + assert_success + assert_json_value '.data.authenticated' 'false' +} +``` + +### Running Tests + +```bash +# Run all tests +./test/run.sh + +# Run specific test file +bats test/auth.bats + +# Run with verbose output +bats --verbose-run test/cards.bats + +# Run single test by name +bats test/cards.bats --filter "returns JSON envelope" +``` + +--- + +## Success Criteria + +### Universal (All Agents) +1. **Agent orients in 1 command**: `fizzy` → knows what to do +2. **Task completion in 1-2 commands**: Create card, close card +3. **Error recovery**: Errors suggest fixes with actionable hints +4. **Web UI parity**: Detail views show everything humans see +5. **Token efficiency**: Dense output, no fluff +6. **Breadcrumbs**: Every response guides next action +7. **Config layering**: Global + local settings work correctly + +### Agent-Specific Enhancements (Optional) +8. **Claude Code**: Hooks for commit linking, `/fizzy` skill +9. **MCP**: Structured tool definitions for advanced agents +10. **Future agents**: Extensible design accommodates new capabilities + +--- + +## Interoperability Design + +### Universal Interface (Required) +Every agent gets these capabilities via standard CLI: +- **JSON output**: `fizzy cards --json` → parseable data +- **Exit codes**: Semantic success/failure +- **Error format**: JSON errors with `code`, `hint` +- **Help text**: `fizzy --help`, `fizzy --help` +- **Piping**: Works with jq, grep, xargs, etc. + +### Claude Code Enhancements (Optional) +For Claude Code users who want deeper integration: +- **Hooks**: Auto-link commits to Fizzy cards +- **Skills**: `/fizzy` slash command with context awareness +- **MCP server**: Structured tools with rich schemas + +### OpenCode / Codex / Others +These agents use the same CLI interface. As they add features (hooks, plugins, MCP), we can add support. The foundation is agent-agnostic. + +--- + +## MCP Server: `fizzy mcp serve` + +When MCP integration is compelling and delightful, `fizzy` itself can serve as an MCP server: + +```bash +# Start MCP server on stdio (for local agents) +fizzy mcp serve + +# Start MCP server on port (for remote agents) +fizzy mcp serve --port 8080 + +# Configure in .mcp.json +{ + "fizzy": { + "command": "fizzy", + "args": ["mcp", "serve"] + } +} +``` + +**Why built-in?** +- Single tool to install and maintain +- CLI and MCP share the same code, auth, config +- No separate MCP server package to manage +- `fizzy` commands become MCP tools automatically + +### Command Schema (Source of Truth) + +Commands are defined in `lib/schema.json`, which drives: +1. CLI argument parsing and validation +2. CLI help text generation +3. MCP tool definitions + +```json +// lib/schema.json +{ + "commands": { + "cards": { + "description": "List cards", + "category": "query", + "args": { + "board": { + "type": "integer", + "aliases": ["-b", "--board", "--in"], + "description": "Board ID" + }, + "assignee": { + "type": "string", + "aliases": ["-a", "--assignee"], + "description": "Filter by assignee (ID, @handle, or 'me')" + }, + "status": { + "type": "string", + "enum": ["active", "closed"], + "aliases": ["-s", "--status"], + "description": "Filter by status" + } + } + }, + "close": { + "description": "Close card(s)", + "category": "action", + "args": { + "id": { + "type": "integer", + "required": true, + "positional": true, + "variadic": true, + "description": "Card ID(s) to close" + } + } + } + } +} +``` + +### MCP Output Mode + +In MCP mode, all responses use the JSON envelope format: + +```bash +fizzy mcp serve # Forces --json for all tool responses +``` + +**When to use MCP vs CLI?** +| Scenario | CLI | MCP | +|----------|-----|-----| +| Quick queries | ✓ | | +| Piping/scripting | ✓ | | +| Structured tool calling | | ✓ | +| Rich type schemas | | ✓ | +| Resource subscriptions | | ✓ | +| Real-time updates | | ✓ | + +The CLI remains the foundation. `fizzy mcp serve` is an adapter for MCP-native agents. + +--- + +## Open Questions + +Decisions to finalize before/during implementation: + +### 1. Card Identifiers + +Cards have both `number` (sequential, human-friendly) and `id` (UUID). The API uses `number` in URLs. + +**Recommendation**: Use card numbers everywhere in CLI (matches web UI and API URLs). + +### 2. Verb Naming + +**DECIDED**: Use Fizzy's ubiquitous language (from domain models): + +| CLI Command | Model Method | API Endpoint | +|-------------|--------------|--------------| +| `close` | `card.close` | POST /closure | +| `reopen` | `card.reopen` | DELETE /closure | +| `triage` | `card.triage_into(column)` | POST /triage | +| `postpone` | `card.postpone` | POST /not_now | +| `gild` | `card.gild` | POST /goldness | +| `ungild` | — | DELETE /goldness | + +Source: `app/models/card/closeable.rb`, `triageable.rb`, `postponable.rb`, `golden.rb` + +### 3. Search Interface + +Options: +- `fizzy search "query"` — Dedicated search command +- `fizzy cards --search "query"` — Filter on cards command +- Both? + +**Recommendation**: Support both. Search is common enough to warrant its own command. + +### 4. Identity Endpoint + +`GET /my/identity` returns accounts list. Use this for: +- Account discovery after OAuth +- Populating `accounts.json` +- `fizzy auth status` output + +### 5. Notifications + +**DECIDED**: Include in scope. Full API coverage is the goal. + +Commands: +- `fizzy notifications` — List notifications +- `fizzy notifications read ` — Mark as read +- `fizzy notifications read --all` — Mark all as read + +--- + +## References + +- **Fizzy API docs**: `docs/API.md` +- **bcq reference**: `~/Work/basecamp/bcq/` +- **Dev server**: `http://fizzy.localhost:3006` +- **OAuth metadata**: `GET /.well-known/oauth-authorization-server` diff --git a/cli/README.md b/cli/README.md new file mode 100644 index 0000000000..61df85582e --- /dev/null +++ b/cli/README.md @@ -0,0 +1,275 @@ +# fizzy + +Fizzy CLI — an agent-first interface for the Fizzy API. + +## Install + +```bash +curl -fsSL https://raw.githubusercontent.com/basecamp/fizzy/main/cli/install.sh | bash +``` + +This installs to `~/.local/share/fizzy` and creates `~/.local/bin/fizzy`. Run again to update. + +**Requirements:** `bash 4+`, `curl`, `jq` + +### macOS + +macOS ships with bash 3.2. Install modern bash first: + +```bash +brew install bash jq +curl -fsSL https://raw.githubusercontent.com/basecamp/fizzy/main/cli/install.sh | bash +``` + +### Updating + +Run the installer again to update to the latest version. + +## Quick Start + +```bash +# Authenticate (opens browser) +fizzy auth login + +# Request read-only access (least-privilege) +fizzy auth login --scope read + +# Headless mode (manual code entry) +fizzy auth login --no-browser + +# Check auth status +fizzy auth status + +# Orient yourself +fizzy + +# List boards +fizzy boards + +# List cards on a board +fizzy cards --in "My Board" + +# Show card details +fizzy show 42 + +# Create a card +fizzy card "Fix the login bug" --in "My Board" + +# Close a card +fizzy close 42 + +# Search cards +fizzy search "login bug" +``` + +## Output Contract + +**Default**: JSON envelope when piped, markdown when TTY. + +```bash +# JSON envelope (piped or --json) +fizzy boards | jq '.data[0]' + +# Raw data only (--quiet or --data) +fizzy --quiet boards | jq '.[0]' + +# Force markdown +fizzy --md boards +``` + +**Note**: Global flags (`--quiet`, `--json`, `--md`) must come *before* the command name. + +### JSON Envelope Structure + +```json +{ + "ok": true, + "data": [...], + "summary": "5 boards", + "breadcrumbs": [ + {"action": "show", "cmd": "fizzy show board "} + ] +} +``` + +## Commands + +### Query Commands + +| Command | Description | +|---------|-------------| +| `boards` | List boards in the account | +| `cards` | List or filter cards (supports `--page` pagination) | +| `columns` | List columns on a board | +| `comments` | List comments on a card | +| `reactions` | List reactions on a comment | +| `notifications` | List notifications | +| `people` | List users in account | +| `search` | Search cards | +| `show` | Show card or board details | +| `tags` | List tags | + +### Action Commands + +| Command | Description | +|---------|-------------| +| `card` | Manage cards (create/update/delete/image) | +| `board` | Manage boards (create/update/delete/show) | +| `column` | Manage columns on a board | +| `close` | Close a card | +| `reopen` | Reopen a closed card | +| `triage` | Move card to a column | +| `untriage` | Move card back to triage | +| `postpone` | Move card to "not now" | +| `comment` | Manage comments (add/edit/delete) | +| `assign` | Assign a card to someone | +| `tag` | Add a tag to a card | +| `watch` | Subscribe to card notifications | +| `unwatch` | Unsubscribe from card | +| `gild` | Mark card as golden | +| `ungild` | Remove golden status | +| `step` | Manage steps (add/show/update/delete) | +| `react` | Manage reactions (add/delete) | + +#### Card Subcommands + +| Command | Description | +|---------|-------------| +| `card "title"` | Create a new card (default) | +| `card update ` | Update card title/description | +| `card delete ` | Permanently delete a card | +| `card image delete ` | Remove header image from card | + +## Global Flags + +Global flags must appear **before** the command name (e.g., `fizzy --json boards`, not `fizzy boards --json`). + +| Flag | Description | +|------|-------------| +| `--json`, `-j` | Force JSON output | +| `--md`, `-m` | Force markdown output | +| `--quiet`, `-q` | Raw data only (no envelope) | +| `--verbose`, `-v` | Debug output | +| `--board`, `-b`, `--in` | Board ID or name (can also go after command) | +| `--account`, `-a` | Account slug | + +## Authentication + +fizzy uses OAuth 2.1 with Dynamic Client Registration (DCR). On first login, it registers itself as an OAuth client and opens your browser for authorization. + +**Scope options:** +- `write` (default): Read and write access to all resources +- `read`: Read-only access — cannot create, update, or delete + +```bash +fizzy auth login # Write access (default) +fizzy auth login --scope read # Read-only access +fizzy auth status # Shows current scope +``` + +Fizzy issues long-lived tokens that don't expire, so you only need to re-authenticate if you explicitly logout or revoke your token. + +### Token for CI/Scripts + +For non-interactive environments, set `FIZZY_TOKEN`: + +```bash +export FIZZY_URL=http://fizzy.localhost:3006/897362094 +export FIZZY_TOKEN=fzt_... +fizzy cards --in "My Board" +``` + +Generate tokens at Profile → API → Personal access tokens. + +## Configuration + +``` +~/.config/fizzy/ +├── config.json # Global defaults +├── credentials.json # OAuth tokens (0600) +├── client.json # DCR client registration +└── accounts.json # Discovered accounts + +.fizzy/ +└── config.json # Per-directory overrides +``` + +Config hierarchy: system → user → repo → local → environment → flags + +### Common Configuration + +```bash +# Set default board for a project directory +cd ~/projects/my-app +fizzy config set board_id abc123 + +# View all configuration +fizzy config list + +# View config sources +fizzy config path +``` + +## Environment + +| Variable | Description | +|----------|-------------| +| `FIZZY_URL` | Base URL + account slug (e.g., `http://fizzy.localhost:3006/897362094`) | +| `FIZZY_BASE_URL` | Base URL only (default: `http://fizzy.localhost:3006`) | +| `FIZZY_ACCOUNT_SLUG` | Account ID (7+ digit number) | +| `FIZZY_TOKEN` | Personal access token for CI/scripts | + +`FIZZY_URL` is a convenience — it sets both `FIZZY_BASE_URL` and `FIZZY_ACCOUNT_SLUG` from a single URL. Explicit vars take precedence. + +```bash +# Local development (default: http://fizzy.localhost:3006) +fizzy boards + +# Production +FIZZY_BASE_URL=https://fizzy.37signals.com fizzy auth login +``` + +OAuth endpoints are discovered automatically via `.well-known/oauth-authorization-server` (RFC 8414). + +## Name Resolution + +Commands accept names, not just IDs: + +```bash +# By name +fizzy cards --in "Fizzy 4" +fizzy triage 42 --to "In Progress" +fizzy assign 42 --to "Jane Doe" +fizzy tag 42 --with "bug" + +# By ID (always works) +fizzy cards --in abc123xyz +``` + +Name resolution is case-insensitive and supports partial matching. Ambiguous matches show suggestions. + +## Tab Completion + +```bash +# Bash (add to ~/.bashrc) +source ~/.local/share/fizzy/completions/fizzy.bash + +# Zsh (add to ~/.zshrc) +fpath=(~/.local/share/fizzy/completions $fpath) +autoload -Uz compinit && compinit +``` + +Provides completion for commands, subcommands, and flags. + +## Testing + +```bash +./test/run.sh # Run all tests +bats test/*.bats # Alternative: run bats directly +``` + +Tests use [bats-core](https://github.com/bats-core/bats-core). Install with `apt install bats` or `brew install bats-core`. + +## License + +[MIT](LICENSE.md) diff --git a/cli/bin/fizzy b/cli/bin/fizzy new file mode 100755 index 0000000000..5f87289752 --- /dev/null +++ b/cli/bin/fizzy @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# fizzy - Fizzy CLI +# Agent-first CLI for Fizzy API interaction +# +# Usage: fizzy [options] +# Run 'fizzy' with no arguments for quick-start guide + +set -euo pipefail + +# Require bash 4+ for associative arrays +if ((BASH_VERSINFO[0] < 4)); then + echo "fizzy requires bash 4.0 or later (found: $BASH_VERSION)" >&2 + echo "" >&2 + if [[ "$(uname)" == "Darwin" ]]; then + echo "macOS ships with bash 3.2. Install modern bash via Homebrew:" >&2 + echo " brew install bash" >&2 + echo "" >&2 + echo "Then run fizzy with:" >&2 + echo " /opt/homebrew/bin/bash $(realpath "$0") [args]" >&2 + echo "" >&2 + echo "Or add to your shell config (~/.zshrc or ~/.bash_profile):" >&2 + echo " alias fizzy='/opt/homebrew/bin/bash \$HOME/Work/basecamp/fizzy/cli/bin/fizzy'" >&2 + else + echo "Install bash 4+ from your package manager." >&2 + fi + exit 1 +fi + +# Determine script location for loading libs +FIZZY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIZZY_COMMIT="$(cat "$FIZZY_ROOT/.commit" 2>/dev/null || echo "dev")" +FIZZY_VERSION="main${FIZZY_COMMIT:+ ($FIZZY_COMMIT)}" + +# Handle --version early (before loading libs) +if [[ "${1:-}" == "--version" || "${1:-}" == "-V" ]]; then + echo "fizzy $FIZZY_VERSION" + exit 0 +fi + +# Load libraries +source "$FIZZY_ROOT/lib/core.sh" +source "$FIZZY_ROOT/lib/config.sh" +source "$FIZZY_ROOT/lib/api.sh" +source "$FIZZY_ROOT/lib/auth.sh" +source "$FIZZY_ROOT/lib/names.sh" + +# Load command handlers +for cmd_file in "$FIZZY_ROOT/lib/commands"/*.sh; do + [[ -f "$cmd_file" ]] && source "$cmd_file" +done + + +main() { + # Parse global flags first + parse_global_flags "$@" + shift $GLOBAL_FLAGS_CONSUMED + + # Reload config to pick up command-line flag overrides + load_config + + # No arguments → quick-start + if [[ $# -eq 0 ]]; then + cmd_quick_start + exit 0 + fi + + local command="$1" + shift + + case "$command" in + # Core query commands + boards) cmd_boards "$@" ;; + cards) cmd_cards "$@" ;; + columns) cmd_columns "$@" ;; + comments) cmd_comments "$@" ;; + reactions) cmd_reactions "$@" ;; + notifications) cmd_notifications "$@" ;; + people) cmd_people "$@" ;; + search) cmd_search "$@" ;; + show) cmd_show "$@" ;; + tags) cmd_tags "$@" ;; + webhooks) cmd_webhooks "$@" ;; + + # Action commands (using Fizzy's ubiquitous language) + card) cmd_card "$@" ;; + board) cmd_board "$@" ;; + column) cmd_column "$@" ;; + close) cmd_close "$@" ;; + reopen) cmd_reopen "$@" ;; + triage) cmd_triage "$@" ;; + untriage) cmd_untriage "$@" ;; + postpone) cmd_postpone "$@" ;; + comment) cmd_comment "$@" ;; + assign) cmd_assign "$@" ;; + tag) cmd_tag "$@" ;; + watch) cmd_watch "$@" ;; + unwatch) cmd_unwatch "$@" ;; + gild) cmd_gild "$@" ;; + ungild) cmd_ungild "$@" ;; + step) cmd_step "$@" ;; + react) cmd_react "$@" ;; + webhook) cmd_webhook "$@" ;; + + # Identity & users + identity) cmd_identity "$@" ;; + user) cmd_user "$@" ;; + account) cmd_account "$@" ;; + + # Auth & config + auth) cmd_auth "$@" ;; + config) cmd_config "$@" ;; + + # Meta + version) echo "fizzy $FIZZY_VERSION" ;; + self-update) cmd_self_update "$@" ;; + uninstall) cmd_uninstall "$@" ;; + help|--help|-h) + cmd_help "$@" + ;; + + *) + json_error "Unknown command: $command" "usage" \ + "Run 'fizzy' for available commands" + exit 1 + ;; + esac +} + +# Run main with all arguments +main "$@" diff --git a/cli/completions/_fizzy b/cli/completions/_fizzy new file mode 100644 index 0000000000..cb957dfd01 --- /dev/null +++ b/cli/completions/_fizzy @@ -0,0 +1,149 @@ +#compdef fizzy +# fizzy zsh completion +# Place in a directory in your $fpath (e.g., ~/.zsh/completions/) + +_fizzy() { + local -a commands + commands=( + 'auth:Authentication (login, logout, status)' + 'config:Configuration management' + 'help:Show help' + 'version:Show version' + 'boards:List boards in the account' + 'cards:List or filter cards' + 'columns:List columns on a board' + 'comments:List comments on a card' + 'reactions:List reactions on a comment' + 'notifications:List notifications' + 'people:List users in account' + 'search:Search cards' + 'show:Show card or board details' + 'tags:List tags' + 'card:Manage cards (create, update, delete, image)' + 'board:Manage boards (create, update, delete, show)' + 'column:Manage columns on a board' + 'close:Close a card' + 'reopen:Reopen a closed card' + 'triage:Move card to a column' + 'untriage:Move card back to triage' + 'postpone:Move card to "not now"' + 'comment:Add, edit, or delete comments' + 'assign:Assign a card to someone' + 'tag:Add a tag to a card' + 'watch:Subscribe to card notifications' + 'unwatch:Unsubscribe from card' + 'gild:Mark card as golden' + 'ungild:Remove golden status' + 'step:Manage steps (checklist items) on a card' + 'react:Manage reactions on comments' + ) + + local -a auth_commands + auth_commands=( + 'login:Authenticate via OAuth' + 'logout:Clear credentials' + 'status:Show auth status' + 'refresh:Check token status' + ) + + local -a config_commands + config_commands=( + 'list:List all configuration' + 'get:Get a configuration value' + 'set:Set a configuration value' + 'unset:Remove a configuration value' + 'path:Show configuration paths' + ) + + _arguments -C \ + '(-j --json)'{-j,--json}'[Force JSON output]' \ + '(-m --md)'{-m,--md}'[Force Markdown output]' \ + '(-q --quiet --data)'{-q,--quiet,--data}'[Minimal output]' \ + '(-v --verbose)'{-v,--verbose}'[Debug output]' \ + '(-b --board --in)'{-b,--board,--in}'[Board ID or name]:board:' \ + '(-a --account)'{-a,--account}'[Account slug]:account:' \ + '(-h --help)'{-h,--help}'[Show help]' \ + '1: :->command' \ + '*::arg:->args' + + case "$state" in + command) + _describe -t commands 'fizzy command' commands + ;; + args) + # In args state, $words is shifted so $words[1] is the subcommand + case "$words[1]" in + auth) + _describe -t auth_commands 'auth subcommand' auth_commands + ;; + config) + _describe -t config_commands 'config subcommand' config_commands + ;; + card) + # Handle nested card subcommands + if [[ ${#words[@]} -ge 3 && "$words[2]" == "image" ]]; then + local card_image_commands=( + 'delete:Remove header image from card' + ) + _describe -t card_image_commands 'card image subcommand' card_image_commands + else + local card_commands=( + 'update:Update a card' + 'delete:Permanently delete a card' + 'image:Manage card header image' + ) + _describe -t card_commands 'card subcommand' card_commands + fi + ;; + board) + local board_commands=( + 'create:Create a new board' + 'update:Update a board' + 'delete:Delete a board' + 'show:Show board details' + ) + _describe -t board_commands 'board subcommand' board_commands + ;; + column) + local column_commands=( + 'create:Create a column' + 'update:Update a column' + 'delete:Delete a column' + 'show:Show column details' + ) + _describe -t column_commands 'column subcommand' column_commands + ;; + notifications) + local notification_commands=( + 'read:Mark notification as read' + 'unread:Mark notification as unread' + ) + _describe -t notification_commands 'notifications subcommand' notification_commands + ;; + comment) + local comment_commands=( + 'edit:Update a comment' + 'delete:Delete a comment' + ) + _describe -t comment_commands 'comment subcommand' comment_commands + ;; + step) + local step_commands=( + 'show:Show step details' + 'update:Update a step' + 'delete:Delete a step' + ) + _describe -t step_commands 'step subcommand' step_commands + ;; + react) + local react_commands=( + 'delete:Delete a reaction' + ) + _describe -t react_commands 'react subcommand' react_commands + ;; + esac + ;; + esac +} + +_fizzy "$@" diff --git a/cli/completions/fizzy.bash b/cli/completions/fizzy.bash new file mode 100644 index 0000000000..35d1a2c411 --- /dev/null +++ b/cli/completions/fizzy.bash @@ -0,0 +1,99 @@ +# fizzy bash completion +# Source this file or place in /etc/bash_completion.d/ + +_fizzy_completions() { + local cur prev words cword + _init_completion || return + + local commands=" + auth config help version + boards cards columns comments reactions notifications people search show tags + card board column close reopen triage untriage postpone comment assign tag watch unwatch gild ungild step react + identity user + " + + local auth_subcommands="login logout status refresh" + local config_subcommands="list get set unset path" + local card_subcommands="update delete image" + local board_subcommands="create update delete show" + local column_subcommands="create update delete show" + local card_image_subcommands="delete" + local comment_subcommands="edit delete" + local step_subcommands="show update delete" + local react_subcommands="delete" + local user_subcommands="show update delete" + + # Handle two-level deep subcommands (card image) + if [[ ${#words[@]} -ge 3 && "${words[1]}" == "card" && "${words[2]}" == "image" ]]; then + COMPREPLY=($(compgen -W "$card_image_subcommands" -- "$cur")) + return + fi + + case "$prev" in + fizzy) + COMPREPLY=($(compgen -W "$commands" -- "$cur")) + return + ;; + auth) + COMPREPLY=($(compgen -W "$auth_subcommands" -- "$cur")) + return + ;; + config) + COMPREPLY=($(compgen -W "$config_subcommands" -- "$cur")) + return + ;; + card) + COMPREPLY=($(compgen -W "$card_subcommands" -- "$cur")) + return + ;; + board) + COMPREPLY=($(compgen -W "$board_subcommands" -- "$cur")) + return + ;; + column) + COMPREPLY=($(compgen -W "$column_subcommands" -- "$cur")) + return + ;; + comment) + COMPREPLY=($(compgen -W "$comment_subcommands" -- "$cur")) + return + ;; + step) + COMPREPLY=($(compgen -W "$step_subcommands" -- "$cur")) + return + ;; + react) + COMPREPLY=($(compgen -W "$react_subcommands" -- "$cur")) + return + ;; + user) + COMPREPLY=($(compgen -W "$user_subcommands" -- "$cur")) + return + ;; + --board|-b|--in) + # Could complete board names if cached, for now just return + return + ;; + --status) + COMPREPLY=($(compgen -W "all closed not_now stalled golden postponing_soon" -- "$cur")) + return + ;; + --scope) + COMPREPLY=($(compgen -W "write read" -- "$cur")) + return + ;; + --sort) + COMPREPLY=($(compgen -W "latest newest oldest" -- "$cur")) + return + ;; + esac + + # Handle flags + if [[ "$cur" == -* ]]; then + local flags="--json -j --md -m --quiet -q --data --verbose -v --board -b --in --account -a --help -h" + COMPREPLY=($(compgen -W "$flags" -- "$cur")) + return + fi +} + +complete -F _fizzy_completions fizzy diff --git a/cli/install.sh b/cli/install.sh new file mode 100755 index 0000000000..0f1466f048 --- /dev/null +++ b/cli/install.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# fizzy CLI installer +# Usage: curl -fsSL https://raw.githubusercontent.com/basecamp/fizzy/main/cli/install.sh | bash +# +# Environment variables: +# FIZZY_INSTALL_DIR - Installation directory (default: ~/.local/share/fizzy) +# FIZZY_BIN_DIR - Symlink directory (default: ~/.local/bin) +# FIZZY_REPO - GitHub repo (default: basecamp/fizzy) +# FIZZY_REF - Git ref to install (default: main) +# FIZZY_COMMIT - Skip API call, use this commit SHA for version tracking + +set -euo pipefail + +REPO="${FIZZY_REPO:-basecamp/fizzy}" +REF="${FIZZY_REF:-main}" +INSTALL_DIR="${FIZZY_INSTALL_DIR:-$HOME/.local/share/fizzy}" +BIN_DIR="${FIZZY_BIN_DIR:-$HOME/.local/bin}" + +info() { echo "==> $*"; } +warn() { echo "Warning: $*" >&2; } +die() { echo "Error: $*" >&2; exit 1; } + +# Check dependencies +check_deps() { + local missing=() + + # Check bash version (need 4+) + if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then + missing+=("bash 4+ (found ${BASH_VERSION})") + fi + + command -v curl &>/dev/null || missing+=("curl") + command -v jq &>/dev/null || missing+=("jq") + + if [[ ${#missing[@]} -gt 0 ]]; then + die "Missing dependencies: ${missing[*]}" + fi +} + +# Get commit SHA from GitHub API (best-effort, not critical) +get_commit_sha() { + # Allow override via environment (useful for CI/offline installs) + if [[ -n "${FIZZY_COMMIT:-}" ]]; then + echo "$FIZZY_COMMIT" + return 0 + fi + + # Try GitHub API (may fail due to rate limits) + local sha + sha=$(curl -fsSL --max-time 5 "https://api.github.com/repos/$REPO/commits/$REF" 2>/dev/null | \ + grep '"sha"' | head -1 | cut -d'"' -f4 | cut -c1-7) || true + + if [[ -n "$sha" ]]; then + echo "$sha" + fi +} + +# Download and install +install_fizzy() { + info "Installing fizzy to $INSTALL_DIR" + + # Create directories + mkdir -p "$INSTALL_DIR" "$BIN_DIR" + + # Download from GitHub + local tmp + tmp=$(mktemp -d) + trap "rm -rf $tmp" EXIT + + # Determine archive name (branch uses refs/heads/, tags use refs/tags/) + local archive_url="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" + local strip_prefix="fizzy-$REF" + + info "Downloading from GitHub ($REF)..." + if ! curl -fsSL "$archive_url" | tar -xz -C "$tmp" --strip-components=2 "$strip_prefix/cli" 2>/dev/null; then + # Try as tag instead + archive_url="https://github.com/$REPO/archive/refs/tags/$REF.tar.gz" + curl -fsSL "$archive_url" | tar -xz -C "$tmp" --strip-components=2 "$strip_prefix/cli" || \ + die "Failed to download from $REPO at ref $REF" + fi + + # Copy files + cp -r "$tmp"/* "$INSTALL_DIR/" + + # Write commit SHA for version tracking (best-effort) + local commit_sha + commit_sha=$(get_commit_sha) + if [[ -n "$commit_sha" ]]; then + echo "$commit_sha" > "$INSTALL_DIR/.commit" + info "Installed fizzy $REF ($commit_sha)" + else + # No commit SHA available, just record the ref + echo "$REF" > "$INSTALL_DIR/.commit" + info "Installed fizzy $REF" + fi + + # Make executable + chmod +x "$INSTALL_DIR/bin/fizzy" + + # Create symlink in bin + ln -sf "$INSTALL_DIR/bin/fizzy" "$BIN_DIR/fizzy" +} + +# Setup completions hint +setup_hint() { + echo + info "Setup shell completions (optional):" + echo + echo " # Bash (add to ~/.bashrc)" + echo " source $INSTALL_DIR/completions/fizzy.bash" + echo + echo " # Zsh (add to ~/.zshrc)" + echo " fpath=($INSTALL_DIR/completions \$fpath)" + echo " autoload -Uz compinit && compinit" + echo +} + +# Check PATH +check_path() { + if [[ ":$PATH:" != *":$BIN_DIR:"* ]]; then + warn "$BIN_DIR is not in your PATH" + echo " Add to your shell profile:" + echo " export PATH=\"$BIN_DIR:\$PATH\"" + fi +} + +main() { + info "fizzy CLI installer" + echo + + check_deps + install_fizzy + setup_hint + check_path + + echo + info "Done! Run 'fizzy auth login' to get started." +} + +main "$@" diff --git a/cli/lib/api.sh b/cli/lib/api.sh new file mode 100644 index 0000000000..006cbeda05 --- /dev/null +++ b/cli/lib/api.sh @@ -0,0 +1,690 @@ +#!/usr/bin/env bash +# api.sh - HTTP helpers for Fizzy API +# Handles authentication, rate limiting, caching, retries + + +# Configuration + +FIZZY_USER_AGENT="fizzy/$FIZZY_VERSION (https://github.com/basecamp/fizzy)" +FIZZY_MAX_RETRIES="${FIZZY_MAX_RETRIES:-5}" +FIZZY_BASE_DELAY="${FIZZY_BASE_DELAY:-1}" + + +# ETag Cache Helpers + +_cache_dir() { + local configured + configured=$(get_config "cache_dir" "") + if [[ -n "$configured" ]]; then + echo "$configured" + else + echo "${XDG_CACHE_HOME:-$HOME/.cache}/fizzy" + fi +} + +_cache_key() { + local account_slug="$1" url="$2" token="$3" + local token_hash="" + if [[ -n "$token" ]]; then + if command -v shasum &>/dev/null; then + token_hash=$(echo -n "$token" | shasum -a 256 | cut -c1-16) + else + token_hash=$(echo -n "$token" | sha256sum | cut -c1-16) + fi + fi + local cache_input="${FIZZY_BASE_URL:-}:${account_slug}:${token_hash}:${url}" + if command -v shasum &>/dev/null; then + echo -n "$cache_input" | shasum -a 256 | cut -d' ' -f1 + else + echo -n "$cache_input" | sha256sum | cut -d' ' -f1 + fi +} + +_cache_get_etag() { + local key="$1" + local etags_file="$(_cache_dir)/etags.json" + if [[ -f "$etags_file" ]]; then + jq -r --arg k "$key" '.[$k] // empty' "$etags_file" 2>/dev/null || true + fi +} + +_cache_get_body() { + local key="$1" + local body_file="$(_cache_dir)/responses/${key}.body" + if [[ -f "$body_file" ]]; then + cat "$body_file" + fi +} + +_cache_set() { + local key="$1" body="$2" etag="$3" headers="$4" + local cache_dir="$(_cache_dir)" + local etags_file="$cache_dir/etags.json" + local body_file="$cache_dir/responses/${key}.body" + local headers_file="$cache_dir/responses/${key}.headers" + + mkdir -p "$cache_dir/responses" + + echo "$body" > "${body_file}.tmp" && mv "${body_file}.tmp" "$body_file" + + if [[ -n "$headers" ]]; then + echo "$headers" > "${headers_file}.tmp" && mv "${headers_file}.tmp" "$headers_file" + fi + + if [[ -f "$etags_file" ]]; then + jq --arg k "$key" --arg v "$etag" '.[$k] = $v' "$etags_file" > "${etags_file}.tmp" \ + && mv "${etags_file}.tmp" "$etags_file" + else + jq -n --arg k "$key" --arg v "$etag" '{($k): $v}' > "$etags_file" + fi +} + + +# Authentication + +ensure_auth() { + local token + token=$(get_access_token) || { + die "Not authenticated. Run: fizzy auth login" $EXIT_AUTH + } + + if is_token_expired && [[ -z "${FIZZY_TOKEN:-}" ]]; then + debug "Token expired, refreshing..." + if ! refresh_token; then + die "Token expired and refresh failed. Run: fizzy auth login" $EXIT_AUTH + fi + token=$(get_access_token) + fi + + echo "$token" +} + +ensure_account_slug() { + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "No account configured. Run: fizzy config set account_slug " $EXIT_USAGE \ + "Or set FIZZY_ACCOUNT_SLUG environment variable" + fi + echo "$account_slug" +} + + +# HTTP Request Helpers + +api_get() { + local path="$1" + shift + + local token account_slug + token=$(ensure_auth) || exit $? + account_slug=$(ensure_account_slug) || exit $? + + # Strip leading slash from path and add it explicitly to avoid double slashes + path="${path#/}" + local url="$FIZZY_BASE_URL/$account_slug/$path" + _api_request GET "$url" "$token" "" "$@" +} + +api_post() { + local path="$1" + local body="${2:-}" + shift 2 || shift + + local token account_slug + token=$(ensure_auth) || exit $? + account_slug=$(ensure_account_slug) || exit $? + + # Strip leading slash from path and add it explicitly to avoid double slashes + path="${path#/}" + local url="$FIZZY_BASE_URL/$account_slug/$path" + _api_request POST "$url" "$token" "$body" "$@" +} + +api_put() { + local path="$1" + local body="${2:-}" + shift 2 || shift + + local token account_slug + token=$(ensure_auth) || exit $? + account_slug=$(ensure_account_slug) || exit $? + + # Strip leading slash from path and add it explicitly to avoid double slashes + path="${path#/}" + local url="$FIZZY_BASE_URL/$account_slug/$path" + _api_request PUT "$url" "$token" "$body" "$@" +} + +api_patch() { + local path="$1" + local body="${2:-}" + shift 2 || shift + + local token account_slug + token=$(ensure_auth) || exit $? + account_slug=$(ensure_account_slug) || exit $? + + # Strip leading slash from path and add it explicitly to avoid double slashes + path="${path#/}" + local url="$FIZZY_BASE_URL/$account_slug/$path" + _api_request PATCH "$url" "$token" "$body" "$@" +} + +api_delete() { + local path="$1" + shift + + local token account_slug + token=$(ensure_auth) || exit $? + account_slug=$(ensure_account_slug) || exit $? + + # Strip leading slash from path and add it explicitly to avoid double slashes + path="${path#/}" + local url="$FIZZY_BASE_URL/$account_slug/$path" + _api_request DELETE "$url" "$token" "" "$@" +} + +# Unauthenticated request (for OAuth discovery) +api_get_unauth() { + local url="$1" + shift + + _api_request GET "$url" "" "" "$@" +} + +api_post_unauth() { + local url="$1" + local body="${2:-}" + shift 2 || shift + + _api_request POST "$url" "" "$body" "$@" +} + +_api_request() { + local method="$1" + local url="$2" + local token="$3" + local body="${4:-}" + shift 4 || shift 3 || shift 2 || shift + + local attempt=1 + local delay=$FIZZY_BASE_DELAY + local response http_code headers_file + + headers_file=$(mktemp) + trap "rm -f '$headers_file'" RETURN + + # ETag cache setup (GET requests only) + local cache_key="" cached_etag="" + if [[ "$method" == "GET" ]] && [[ "${FIZZY_CACHE_ENABLED:-true}" == "true" ]] && [[ -n "$token" ]]; then + local account_slug + account_slug=$(get_account_slug 2>/dev/null || echo "") + if [[ -n "$account_slug" ]]; then + cache_key=$(_cache_key "$account_slug" "$url" "$token") + cached_etag=$(_cache_get_etag "$cache_key") + fi + fi + + while (( attempt <= FIZZY_MAX_RETRIES )); do + debug "API $method $url (attempt $attempt)" + + local curl_args=( + -s + -X "$method" + -H "User-Agent: $FIZZY_USER_AGENT" + -H "Content-Type: application/json" + -H "Accept: application/json" + -D "$headers_file" + -w '\n%{http_code}' + ) + + # Add Authorization header if token provided + if [[ -n "$token" ]]; then + curl_args+=(-H "Authorization: Bearer $token") + fi + + # Add If-None-Match header for cached responses + if [[ -n "$cached_etag" ]]; then + curl_args+=(-H "If-None-Match: $cached_etag") + debug "Cache: If-None-Match $cached_etag" + fi + + if [[ -n "$body" ]]; then + curl_args+=(-d "$body") + fi + + curl_args+=("$@") + curl_args+=("$url") + + # Log curl command in verbose mode (with redacted token) + if [[ "$FIZZY_VERBOSE" == "true" ]]; then + local curl_cmd="curl" + local prev_was_H=false + for arg in "${curl_args[@]}"; do + if [[ "$arg" == "-H" ]]; then + prev_was_H=true + continue + elif $prev_was_H; then + prev_was_H=false + if [[ "$arg" == "Authorization: Bearer"* ]]; then + curl_cmd+=" -H 'Authorization: Bearer [REDACTED]'" + else + curl_cmd+=" -H '$arg'" + fi + elif [[ "$arg" == *" "* ]]; then + curl_cmd+=" '$arg'" + else + curl_cmd+=" $arg" + fi + done + echo "[curl] $curl_cmd" >&2 + fi + + local output curl_exit + output=$(curl "${curl_args[@]}") || curl_exit=$? + + if [[ -n "${curl_exit:-}" ]]; then + case "$curl_exit" in + 6) die "Could not resolve host" $EXIT_NETWORK ;; + 7) die "Connection refused" $EXIT_NETWORK ;; + 28) die "Connection timed out" $EXIT_NETWORK ;; + 35) die "SSL/TLS handshake failed" $EXIT_NETWORK ;; + *) die "Network error (curl exit $curl_exit)" $EXIT_NETWORK ;; + esac + fi + + http_code=$(echo "$output" | tail -n1) + response=$(echo "$output" | sed '$d') + + debug "HTTP $http_code" + + case "$http_code" in + 304) + # Not Modified - return cached response + if [[ -n "$cache_key" ]]; then + debug "Cache hit: 304 Not Modified" + _cache_get_body "$cache_key" + return 0 + fi + die "304 received but no cached response available" $EXIT_API + ;; + 200|201|202|204) + # Cache successful GET responses with ETag + if [[ "$method" == "GET" ]] && [[ -n "$cache_key" ]]; then + local etag + etag=$(grep -i '^ETag:' "$headers_file" | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r\n') + if [[ -n "$etag" ]]; then + local cached_headers + cached_headers=$(cat "$headers_file") + _cache_set "$cache_key" "$response" "$etag" "$cached_headers" + debug "Cache: stored with ETag $etag" + fi + fi + + # Follow Location header on 201 with empty body (RESTful create pattern) + if [[ "$http_code" == "201" ]] && [[ -z "$response" ]]; then + local location + location=$(grep -i '^location:' "$headers_file" 2>/dev/null | sed 's/^[^:]*:[[:space:]]*//' | tr -d '\r\n' || true) + if [[ -n "$location" ]]; then + # Convert relative URL to absolute + if [[ "$location" != http* ]]; then + location="$FIZZY_BASE_URL$location" + fi + # Strip .json suffix if present (API returns /cards/1.json but endpoint is /cards/1) + location="${location%.json}" + debug "Following Location header: $location" + response=$(curl -s -H "Authorization: Bearer $token" -H "Accept: application/json" "$location") + fi + fi + + echo "$response" + return 0 + ;; + 429) + local retry_after + retry_after=$(grep -i "Retry-After:" "$headers_file" | awk '{print $2}' | tr -d '\r') + delay=${retry_after:-$((FIZZY_BASE_DELAY * 2 ** (attempt - 1)))} + info "Rate limited, waiting ${delay}s..." + sleep "$delay" + ((attempt++)) + ;; + 401) + if [[ $attempt -eq 1 ]] && [[ -z "${FIZZY_TOKEN:-}" ]] && [[ -n "$token" ]]; then + debug "401 received, attempting token refresh" + if refresh_token; then + token=$(get_access_token) + ((attempt++)) + continue + fi + fi + die "Authentication failed" $EXIT_AUTH "Run: fizzy auth login" + ;; + 403) + if [[ "$method" =~ ^(POST|PUT|PATCH|DELETE)$ ]]; then + local current_scope + current_scope=$(get_token_scope 2>/dev/null || echo "unknown") + if [[ "$current_scope" == "read" ]]; then + die "Permission denied: read-only token cannot perform write operations" $EXIT_FORBIDDEN \ + "Re-authenticate with write scope: fizzy auth login --scope write" + fi + fi + die "Permission denied" $EXIT_FORBIDDEN \ + "You don't have access to this resource" + ;; + 404) + die "Not found" $EXIT_NOT_FOUND + ;; + 422) + # Unprocessable Entity - validation error + local error_msg + error_msg=$(echo "$response" | jq -r '.errors | to_entries | map("\(.key): \(.value | join(", "))") | join("; ")' 2>/dev/null || echo "Validation failed") + die "Validation error: $error_msg" $EXIT_USAGE + ;; + 500) + die "Server error (500)" $EXIT_API \ + "The server encountered an internal error" + ;; + 502|503|504) + delay=$((FIZZY_BASE_DELAY * 2 ** (attempt - 1))) + info "Gateway error ($http_code), retrying in ${delay}s..." + sleep "$delay" + ((attempt++)) + ;; + *) + local error_msg + error_msg=$(echo "$response" | jq -r '.error // .message // "Unknown error"' 2>/dev/null || echo "Request failed") + die "$error_msg (HTTP $http_code)" $EXIT_API + ;; + esac + done + + die "Request failed after $FIZZY_MAX_RETRIES retries" $EXIT_API +} + + +# Multipart Upload Helper +# Executes curl with retry logic for 429/5xx errors +# Usage: api_multipart_request http_code_var response_var curl_args... + +api_multipart_request() { + local -n _http_code_ref=$1 + local -n _response_ref=$2 + shift 2 + + local attempt=1 + local delay=$FIZZY_BASE_DELAY + local max_retries=3 # Fewer retries for uploads + + while (( attempt <= max_retries )); do + debug "Multipart upload (attempt $attempt)" + + local output curl_exit + output=$(curl "$@") || curl_exit=$? + + if [[ -n "${curl_exit:-}" ]]; then + case "$curl_exit" in + 6) die "Could not resolve host" $EXIT_NETWORK ;; + 7) die "Connection refused" $EXIT_NETWORK ;; + 28) die "Connection timed out" $EXIT_NETWORK ;; + 35) die "SSL/TLS handshake failed" $EXIT_NETWORK ;; + *) die "Network error (curl exit $curl_exit)" $EXIT_NETWORK ;; + esac + fi + + _http_code_ref=$(echo "$output" | tail -n1) + _response_ref=$(echo "$output" | sed '$d') + + debug "HTTP $_http_code_ref" + + case "$_http_code_ref" in + 200|201|202|204) + return 0 + ;; + 429) + delay=$((FIZZY_BASE_DELAY * 2 ** (attempt - 1))) + info "Rate limited, waiting ${delay}s..." + sleep "$delay" + ((attempt++)) + ;; + 502|503|504) + delay=$((FIZZY_BASE_DELAY * 2 ** (attempt - 1))) + info "Gateway error ($_http_code_ref), retrying in ${delay}s..." + sleep "$delay" + ((attempt++)) + ;; + *) + # Non-retryable error - return and let caller handle + return 0 + ;; + esac + done + + # Exhausted retries - return last response for caller to handle + return 0 +} + + +# Token Refresh + +refresh_token() { + local creds + creds=$(load_credentials) + + local refresh_tok + refresh_tok=$(echo "$creds" | jq -r '.refresh_token // empty') + + if [[ -z "$refresh_tok" ]]; then + debug "No refresh token available" + return 1 + fi + + local client_id client_secret + client_id=$(get_client_id) + client_secret=$(get_client_secret) + + if [[ -z "$client_id" ]]; then + debug "No client credentials found" + return 1 + fi + + # Preserve original scope for new credentials + local original_scope + original_scope=$(echo "$creds" | jq -r '.scope // empty') + + debug "Refreshing token..." + + # Use discovered token endpoint instead of hardcoded path + local token_endpoint + token_endpoint=$(_token_endpoint) + + if [[ -z "$token_endpoint" ]]; then + debug "Could not discover token endpoint" + return 1 + fi + + # Build curl command with --data-urlencode for proper URL encoding + # Include client_secret only if present (for confidential clients) + local response curl_exit + if [[ -n "$client_secret" ]]; then + response=$(curl -s -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=refresh_token" \ + --data-urlencode "refresh_token=$refresh_tok" \ + --data-urlencode "client_id=$client_id" \ + --data-urlencode "client_secret=$client_secret" \ + "$token_endpoint") || curl_exit=$? + else + response=$(curl -s -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=refresh_token" \ + --data-urlencode "refresh_token=$refresh_tok" \ + --data-urlencode "client_id=$client_id" \ + "$token_endpoint") || curl_exit=$? + fi + + if [[ -n "${curl_exit:-}" ]]; then + debug "Token refresh network error (curl exit $curl_exit)" + return 1 + fi + + local new_access_token new_refresh_token expires_in new_scope + new_access_token=$(echo "$response" | jq -r '.access_token // empty') + new_refresh_token=$(echo "$response" | jq -r '.refresh_token // empty') + expires_in=$(echo "$response" | jq -r '.expires_in // empty') + new_scope=$(echo "$response" | jq -r '.scope // empty') + + if [[ -z "$new_access_token" ]]; then + debug "Token refresh failed: $response" + return 1 + fi + + # Use scope from response if provided, otherwise preserve original scope + local final_scope="${new_scope:-$original_scope}" + + # Build credentials - only include expires_at if server provided expires_in + local new_creds + if [[ -n "$expires_in" ]]; then + local expires_at + expires_at=$(($(date +%s) + expires_in)) + new_creds=$(jq -n \ + --arg access_token "$new_access_token" \ + --arg refresh_token "${new_refresh_token:-$refresh_tok}" \ + --argjson expires_at "$expires_at" \ + --arg scope "$final_scope" \ + '{access_token: $access_token, refresh_token: $refresh_token, expires_at: $expires_at, scope: $scope}') + else + # No expiration - token is long-lived + new_creds=$(jq -n \ + --arg access_token "$new_access_token" \ + --arg refresh_token "${new_refresh_token:-$refresh_tok}" \ + --arg scope "$final_scope" \ + '{access_token: $access_token, refresh_token: $refresh_token, expires_at: null, scope: $scope}') + fi + + save_credentials "$new_creds" + debug "Token refreshed successfully" + return 0 +} + + +# Pagination + +api_get_all() { + local path="$1" + local max_pages="${2:-100}" + + local token account_slug + token=$(ensure_auth) || exit $? + account_slug=$(ensure_account_slug) || exit $? + + # Strip leading slash from path and add it explicitly to avoid double slashes + path="${path#/}" + + local all_results="[]" + local page=1 + local base_url="$FIZZY_BASE_URL/$account_slug/$path" + local url="$base_url" + local use_link_headers=true + + local headers_file + headers_file=$(mktemp) + trap "rm -f '$headers_file'" RETURN + + while (( page <= max_pages )); do + debug "Fetching page $page: $url" + + local output http_code response curl_exit + output=$(curl -s \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -D "$headers_file" \ + -w '\n%{http_code}' \ + "$url") || curl_exit=$? + + if [[ -n "${curl_exit:-}" ]]; then + case "$curl_exit" in + 6) die "Could not resolve host" $EXIT_NETWORK ;; + 7) die "Connection refused" $EXIT_NETWORK ;; + 28) die "Connection timed out" $EXIT_NETWORK ;; + *) die "Network error (curl exit $curl_exit)" $EXIT_NETWORK ;; + esac + fi + + http_code=$(echo "$output" | tail -n1) + response=$(echo "$output" | sed '$d') + + if [[ "$http_code" != "200" ]]; then + die "API request failed (HTTP $http_code)" $EXIT_API + fi + + # Check for empty response (end of pagination) + local count + count=$(echo "$response" | jq 'if type == "array" then length else 1 end' 2>/dev/null || echo "1") + if [[ "$count" -eq 0 ]]; then + debug "Empty response, pagination complete" + break + fi + + if [[ "$all_results" == "[]" ]]; then + all_results="$response" + else + all_results=$(echo "$all_results" "$response" | jq -s '.[0] + .[1]') + fi + + # Parse Link header for next page (RFC 5988) + local next_url + next_url=$(grep -i '^Link:' "$headers_file" | sed -n 's/.*<\([^>]*\)>; rel="next".*/\1/p' | tr -d '\r') + + if [[ -n "$next_url" ]]; then + url="$next_url" + ((page++)) + elif [[ "$use_link_headers" == "true" ]] && [[ "$page" -eq 1 ]]; then + # No Link header on page 1 - fall back to ?page=N pagination + debug "No Link header, falling back to ?page=N" + use_link_headers=false + ((page++)) + if [[ "$base_url" == *"?"* ]]; then + url="${base_url}&page=$page" + else + url="${base_url}?page=$page" + fi + elif [[ "$use_link_headers" == "false" ]]; then + # Using ?page=N fallback, try next page + ((page++)) + if [[ "$base_url" == *"?"* ]]; then + url="${base_url}&page=$page" + else + url="${base_url}?page=$page" + fi + else + # No more pages + break + fi + done + + echo "$all_results" +} + + +# URL helpers + +board_path() { + local resource="$1" + local board_id="${2:-$(get_board_id)}" + + if [[ -z "$board_id" ]]; then + die "No board specified. Use --board or set in .fizzy/config.json" $EXIT_USAGE + fi + + echo "/boards/$board_id$resource" +} + +card_path() { + local card_number="$1" + local resource="${2:-}" + + echo "/cards/$card_number$resource" +} diff --git a/cli/lib/auth.sh b/cli/lib/auth.sh new file mode 100644 index 0000000000..b877a99206 --- /dev/null +++ b/cli/lib/auth.sh @@ -0,0 +1,814 @@ +#!/usr/bin/env bash +# auth.sh - OAuth 2.1 authentication with Dynamic Client Registration +# Uses .well-known/oauth-authorization-server discovery (RFC 8414) + + +# OAuth Configuration + +FIZZY_REDIRECT_PORT="${FIZZY_REDIRECT_PORT:-8976}" +FIZZY_REDIRECT_URI="http://127.0.0.1:$FIZZY_REDIRECT_PORT/callback" + +FIZZY_CLIENT_NAME="fizzy-cli" +FIZZY_CLIENT_URI="https://github.com/basecamp/fizzy" + +# Cached OAuth server metadata (populated by _ensure_oauth_config) +declare -g _FIZZY_OAUTH_CONFIG="" + + +# OAuth Discovery (RFC 8414) + +_discover_oauth_config() { + # Fetch OAuth 2.1 server metadata from .well-known endpoint + local discovery_url="$FIZZY_BASE_URL/.well-known/oauth-authorization-server" + + debug "Discovering OAuth config from: $discovery_url" + + local response + response=$(curl -s -f "$discovery_url") || { + die "Failed to discover OAuth configuration from $discovery_url" $EXIT_AUTH \ + "Ensure FIZZY_BASE_URL points to a valid Fizzy instance" + } + + # Validate required fields + local authorization_endpoint token_endpoint + authorization_endpoint=$(echo "$response" | jq -r '.authorization_endpoint // empty') + token_endpoint=$(echo "$response" | jq -r '.token_endpoint // empty') + + if [[ -z "$authorization_endpoint" ]] || [[ -z "$token_endpoint" ]]; then + debug "Discovery response: $response" + die "Invalid OAuth discovery response - missing required endpoints" $EXIT_AUTH + fi + + _FIZZY_OAUTH_CONFIG="$response" + debug "OAuth config discovered successfully" +} + +_ensure_oauth_config() { + # Lazily fetch and cache OAuth config + if [[ -z "$_FIZZY_OAUTH_CONFIG" ]]; then + _discover_oauth_config + fi +} + +_get_oauth_endpoint() { + local key="$1" + _ensure_oauth_config + echo "$_FIZZY_OAUTH_CONFIG" | jq -r ".$key // empty" +} + +# Convenience accessors for OAuth endpoints +_authorization_endpoint() { _get_oauth_endpoint "authorization_endpoint"; } +_token_endpoint() { _get_oauth_endpoint "token_endpoint"; } +_registration_endpoint() { _get_oauth_endpoint "registration_endpoint"; } +_revocation_endpoint() { _get_oauth_endpoint "revocation_endpoint"; } + + +# Auth Commands + +cmd_auth() { + local action="${1:-status}" + shift || true + + case "$action" in + login) _auth_login "$@" ;; + logout) _auth_logout "$@" ;; + status) _auth_status "$@" ;; + refresh) _auth_refresh "$@" ;; + --help|-h) _help_auth ;; + *) + die "Unknown auth action: $action" $EXIT_USAGE "Run: fizzy auth --help" + ;; + esac +} + + +# Login Flow + +_auth_login() { + local no_browser=false + local scope="write" # Default to write (read+write) scope + + # Parse login-specific flags + while [[ $# -gt 0 ]]; do + case "$1" in + --no-browser) + no_browser=true + shift + ;; + --scope) + shift + case "${1:-}" in + write|read) + scope="$1" + shift + ;; + *) + die "Invalid scope: ${1:-}. Use 'write' or 'read'" $EXIT_USAGE + ;; + esac + ;; + *) + shift + ;; + esac + done + + info "Starting authentication..." + + # Pre-fetch OAuth config (avoids repeated discovery in subshells) + _ensure_oauth_config + + # Get or register client + local client_id client_secret + if ! _load_client; then + info "Registering OAuth client..." + _register_client || die "Failed to register OAuth client" $EXIT_AUTH + fi + _load_client + + # Generate PKCE challenge + local code_verifier code_challenge + code_verifier=$(_generate_code_verifier) + code_challenge=$(_generate_code_challenge "$code_verifier") + debug "Generated code_verifier: $code_verifier" + debug "Generated code_challenge: $code_challenge" + + # Generate state for CSRF protection + local state + state=$(_generate_state) + + # Build authorization URL (using discovered endpoint) + local auth_endpoint auth_url + auth_endpoint=$(_authorization_endpoint) + auth_url="$auth_endpoint?response_type=code" + auth_url+="&client_id=$client_id" + auth_url+="&redirect_uri=$(urlencode "$FIZZY_REDIRECT_URI")" + auth_url+="&code_challenge=$code_challenge" + auth_url+="&code_challenge_method=S256" + auth_url+="&scope=$scope" + auth_url+="&state=$state" + + local auth_code + + if [[ "$no_browser" == "true" ]]; then + # Headless mode: user manually visits URL and enters code + echo "Visit this URL to authorize:" + echo + echo " $auth_url" + echo + read -rp "Enter the authorization code: " auth_code + if [[ -z "$auth_code" ]]; then + die "No authorization code provided" $EXIT_AUTH + fi + else + # Browser mode: open browser and wait for callback + info "Opening browser for authorization..." + info "If browser doesn't open, visit: $auth_url" + + # Open browser + _open_browser "$auth_url" + + # Start local server to receive callback + auth_code=$(_wait_for_callback "$state") || die "Authorization failed" $EXIT_AUTH + fi + + # Exchange code for tokens + info "Exchanging authorization code..." + _exchange_code "$auth_code" "$code_verifier" "$client_id" "$client_secret" || \ + die "Token exchange failed" $EXIT_AUTH + + # Discover accounts + info "Discovering accounts..." + _discover_accounts || warn "Could not discover accounts" + + # Select account if multiple + _select_account + + info "Authentication successful!" + _auth_status +} + +_auth_logout() { + local creds + creds=$(load_credentials) + if [[ -n "$(echo "$creds" | jq -r '.access_token // empty')" ]]; then + clear_credentials + info "Logged out from $FIZZY_BASE_URL" + else + info "Not logged in to $FIZZY_BASE_URL" + fi + + # Warn if env var will still override + if [[ -n "${FIZZY_TOKEN:-}" ]]; then + warn "FIZZY_TOKEN environment variable is still set and will be used for authentication" + fi +} + +_auth_status() { + local format + format=$(get_format) + + local auth_status="unauthenticated" + local auth_type="none" + local account_slug="" + local account_name="" + local expires_at="" + local token_status="none" + local scope="" + local has_stored_creds=false + + if get_access_token &>/dev/null; then + auth_status="authenticated" + auth_type=$(get_auth_type) + + local creds + creds=$(load_credentials) + local stored_token + stored_token=$(echo "$creds" | jq -r '.access_token // empty') + [[ -n "$stored_token" ]] && has_stored_creds=true + + # Only use stored creds metadata when not using env token + if [[ "$auth_type" != "token_env" ]]; then + expires_at=$(echo "$creds" | jq -r '.expires_at // "null"') + scope=$(echo "$creds" | jq -r '.scope // empty') + fi + + # Env var tokens are always long-lived + if [[ "$auth_type" == "token_env" ]]; then + token_status="env" + elif [[ "$expires_at" == "null" ]] || [[ "$expires_at" == "0" ]]; then + token_status="long-lived" + elif is_token_expired; then + token_status="expired" + else + token_status="valid" + fi + + local accounts + accounts=$(load_accounts) + account_slug=$(get_account_slug) + + if [[ -n "$account_slug" ]] && [[ "$accounts" != "[]" ]]; then + account_name=$(echo "$accounts" | jq -r --arg slug "$account_slug" \ + '.[] | select(.slug == ("/"+$slug)) | .name // empty') + fi + fi + + if [[ "$format" == "json" ]]; then + jq -n \ + --arg status "$auth_status" \ + --arg auth "$auth_type" \ + --arg token_status "$token_status" \ + --arg account_slug "$account_slug" \ + --arg account_name "$account_name" \ + --arg expires_at "$expires_at" \ + --arg scope "$scope" \ + --argjson has_stored "$has_stored_creds" \ + '{ + status: $status, + auth: (if $auth == "token_env" then "env" else (if $auth == "oauth" then "oauth" else null end) end), + token: $token_status, + scope: (if $scope != "" then $scope else null end), + has_stored_credentials: (if $auth == "token_env" then $has_stored else null end), + account: { + slug: (if $account_slug != "" then $account_slug else null end), + name: (if $account_name != "" then $account_name else null end) + }, + expires_at: (if $expires_at != "" and $expires_at != "0" and $expires_at != "null" then ($expires_at | tonumber) else null end) + }' + else + echo "## Authentication Status" + echo + if [[ "$auth_status" == "authenticated" ]]; then + if [[ "$auth_type" == "token_env" ]]; then + echo "Status: ✓ Authenticated (FIZZY_TOKEN)" + echo "Source: Environment variable (takes precedence)" + if [[ "$has_stored_creds" == "true" ]]; then + echo "Note: Stored OAuth credentials also exist (ignored while FIZZY_TOKEN is set)" + fi + else + echo "Status: ✓ Authenticated" + fi + [[ -n "$account_name" ]] && echo "Account: $account_name ($account_slug)" || true + if [[ -n "$scope" ]]; then + if [[ "$scope" == "read" ]]; then + echo "Scope: $scope (read-only)" + else + echo "Scope: $scope (read+write)" + fi + fi + if [[ "$token_status" == "long-lived" ]]; then + echo "Token: ✓ Long-lived (no expiration)" + elif [[ "$token_status" == "expired" ]]; then + echo "Token: ⚠ Expired (run: fizzy auth login)" + fi + else + echo "Status: ✗ Not authenticated" + echo + echo "Run: fizzy auth login" + echo " or export FIZZY_TOKEN=" + fi + fi +} + +_auth_refresh() { + # First check if we're authenticated at all + if ! get_access_token &>/dev/null; then + die "Not authenticated. Run: fizzy auth login" $EXIT_AUTH + fi + + # Env var tokens cannot be refreshed + local auth_type + auth_type=$(get_auth_type) + if [[ "$auth_type" == "token_env" ]]; then + die "FIZZY_TOKEN does not support refresh" $EXIT_AUTH \ + "Environment variable tokens are managed externally" + fi + + # Check if we have a refresh token before attempting + local creds + creds=$(load_credentials) + local refresh_tok + refresh_tok=$(echo "$creds" | jq -r '.refresh_token // empty') + + if [[ -z "$refresh_tok" ]]; then + # Fizzy issues long-lived tokens without refresh capability + local expires_at + expires_at=$(echo "$creds" | jq -r '.expires_at // "null"') + # Treat null and 0 as long-lived (consistent with is_token_expired and auth status) + if [[ "$expires_at" == "null" ]] || [[ "$expires_at" == "0" ]]; then + info "Your token is long-lived and doesn't require refresh." + _auth_status + return 0 + else + die "No refresh token available. Run: fizzy auth login" $EXIT_AUTH + fi + fi + + if refresh_token; then + info "Token refreshed successfully" + _auth_status + else + die "Token refresh failed. Run: fizzy auth login" $EXIT_AUTH + fi +} + + +# OAuth Helpers + +_register_client() { + # Dynamic Client Registration (DCR) using discovered endpoint + local registration_endpoint + registration_endpoint=$(_registration_endpoint) + + if [[ -z "$registration_endpoint" ]]; then + die "OAuth server does not support Dynamic Client Registration" $EXIT_AUTH \ + "The server's .well-known/oauth-authorization-server does not include registration_endpoint" + fi + + debug "Registering client at: $registration_endpoint" + + # DCR clients typically only get authorization_code grant + local grant_types='["authorization_code"]' + + # Fizzy DCR only supports public clients (no client_secret) + # Request both read and write scopes so CLI can perform all operations + local response + response=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg name "$FIZZY_CLIENT_NAME" \ + --arg uri "$FIZZY_CLIENT_URI" \ + --arg redirect "$FIZZY_REDIRECT_URI" \ + --argjson grants "$grant_types" \ + '{ + client_name: $name, + client_uri: $uri, + redirect_uris: [$redirect], + grant_types: $grants, + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "read write" + }')" \ + "$registration_endpoint") + + local client_id client_secret + client_id=$(echo "$response" | jq -r '.client_id // empty') + client_secret=$(echo "$response" | jq -r '.client_secret // empty') + + if [[ -z "$client_id" ]]; then + debug "DCR response: $response" + return 1 + fi + + debug "Registered client_id: $client_id" + + # Save using multi-origin aware save_client from config.sh + local client_json + client_json=$(jq -n \ + --arg client_id "$client_id" \ + --arg client_secret "${client_secret:-}" \ + '{client_id: $client_id, client_secret: $client_secret}') + save_client "$client_json" +} + +_load_client() { + # Use multi-origin aware load_client from config.sh + local client_data + client_data=$(load_client) + + client_id=$(echo "$client_data" | jq -r '.client_id // empty') + client_secret=$(echo "$client_data" | jq -r '.client_secret // ""') + + # Only client_id is required (public clients may not have secret) + [[ -n "$client_id" ]] +} + +_generate_code_verifier() { + # Generate random 43-128 character string for PKCE (RFC 7636) + # Use extra bytes to ensure we have enough after removing invalid chars + # Valid chars: [A-Za-z0-9._~-] + local verifier + while true; do + verifier=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=' | cut -c1-43) + if [[ ${#verifier} -ge 43 ]]; then + echo "$verifier" + return + fi + done +} + +_generate_code_challenge() { + local verifier="$1" + # S256: BASE64URL(SHA256(verifier)) + echo -n "$verifier" | openssl dgst -sha256 -binary | base64 | tr '+/' '-_' | tr -d '=' +} + +_generate_state() { + openssl rand -hex 16 +} + +_open_browser() { + local url="$1" + + case "$(uname -s)" in + Darwin) open "$url" ;; + Linux) + if command -v xdg-open &>/dev/null; then + xdg-open "$url" + elif command -v gnome-open &>/dev/null; then + gnome-open "$url" + else + warn "Could not open browser automatically" + fi + ;; + MINGW*|CYGWIN*) start "$url" ;; + *) warn "Could not open browser automatically" ;; + esac +} + +_wait_for_callback() { + local expected_state="$1" + local timeout_secs=120 + + # Check dependencies + if ! command -v nc &>/dev/null; then + die "netcat (nc) is required for OAuth callback" $EXIT_USAGE \ + "Install: brew install netcat (macOS) or apt install netcat (Linux)" + fi + + if ! command -v timeout &>/dev/null && ! command -v gtimeout &>/dev/null; then + die "timeout is required for OAuth callback" $EXIT_USAGE \ + "Install: brew install coreutils (macOS, provides gtimeout)" + fi + + local timeout_cmd="timeout" + command -v timeout &>/dev/null || timeout_cmd="gtimeout" + + info "Waiting for authorization (timeout: ${timeout_secs}s)..." + + # Create temp file for HTTP response (piping to nc doesn't block on macOS BSD nc) + local http_response_file + http_response_file=$(mktemp) + printf 'HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n

Authorization successful!

You can close this window.

' > "$http_response_file" + + local response exit_code=0 + response=$("$timeout_cmd" "$timeout_secs" bash -c ' + response_file="'"$http_response_file"'" + port="'"$FIZZY_REDIRECT_PORT"'" + request_file=$(mktemp) + trap "rm -f $request_file" EXIT + + while true; do + # Open response file for reading on fd3 + exec 3<"$response_file" || exit 1 + + # nc -l PORT: listen for one connection + # <&3 redirects stdin from fd3 (response file) - nc sends this to client + # nc outputs what client sends (the HTTP request) to stdout + # Capture to file to avoid SIGPIPE from head -1 killing nc before response is sent + nc -l "$port" <&3 > "$request_file" 2>/dev/null + + # Close the file descriptor + exec 3<&- + + # Read the first line (HTTP request line) from captured output + request=$(head -1 "$request_file") + + if [[ "$request" == *"GET /callback"* ]]; then + echo "$request" + break + fi + + # Small delay before retry to avoid tight loop + sleep 0.1 + done + ') || exit_code=$? + + rm -f "$http_response_file" + + if [[ -z "$response" ]] || [[ $exit_code -ne 0 ]]; then + die "Authorization timed out" $EXIT_AUTH + fi + + # Parse callback URL + local query_string + query_string=$(echo "$response" | sed -n 's/.*GET \/callback?\([^ ]*\).*/\1/p') + + local code state + code=$(echo "$query_string" | tr '&' '\n' | grep '^code=' | cut -d= -f2) + state=$(echo "$query_string" | tr '&' '\n' | grep '^state=' | cut -d= -f2) + + # URL decode the code (may contain encoded characters) + code=$(printf '%b' "${code//%/\\x}") + + debug "Received auth code: $code" + debug "Received state: $state" + + if [[ "$state" != "$expected_state" ]]; then + die "State mismatch - possible CSRF attack" $EXIT_AUTH + fi + + if [[ -z "$code" ]]; then + local error + error=$(echo "$query_string" | tr '&' '\n' | grep '^error=' | cut -d= -f2) + die "Authorization failed: ${error:-unknown error}" $EXIT_AUTH + fi + + echo "$code" +} + +_exchange_code() { + local code="$1" + local code_verifier="$2" + local client_id="$3" + local client_secret="$4" + + local token_endpoint + token_endpoint=$(_token_endpoint) + + debug "Exchanging code at: $token_endpoint" + debug "Code verifier: $code_verifier" + debug "Code verifier length: ${#code_verifier}" + + # Build curl args - only include client_secret for confidential clients + local curl_args=( + -s -X POST + -H "Content-Type: application/x-www-form-urlencoded" + --data-urlencode "grant_type=authorization_code" + --data-urlencode "code=$code" + --data-urlencode "redirect_uri=$FIZZY_REDIRECT_URI" + --data-urlencode "client_id=$client_id" + --data-urlencode "code_verifier=$code_verifier" + ) + + # Only include client_secret for confidential clients (non-empty secret) + if [[ -n "$client_secret" ]]; then + curl_args+=(--data-urlencode "client_secret=$client_secret") + fi + + local response + response=$(curl "${curl_args[@]}" "$token_endpoint") + + local access_token refresh_token expires_in scope + access_token=$(echo "$response" | jq -r '.access_token // empty') + refresh_token=$(echo "$response" | jq -r '.refresh_token // empty') + expires_in=$(echo "$response" | jq -r '.expires_in // empty') + scope=$(echo "$response" | jq -r '.scope // empty') + + if [[ -z "$access_token" ]]; then + debug "Token response: $response" + return 1 + fi + + # Build credentials - only include expires_at if server provided expires_in + # Fizzy issues long-lived tokens without expiration + local creds + if [[ -n "$expires_in" ]]; then + local expires_at + expires_at=$(($(date +%s) + expires_in)) + creds=$(jq -n \ + --arg access_token "$access_token" \ + --arg refresh_token "$refresh_token" \ + --argjson expires_at "$expires_at" \ + --arg scope "$scope" \ + '{access_token: $access_token, refresh_token: $refresh_token, expires_at: $expires_at, scope: $scope}') + else + # No expiration - token is long-lived + creds=$(jq -n \ + --arg access_token "$access_token" \ + --arg refresh_token "$refresh_token" \ + --arg scope "$scope" \ + '{access_token: $access_token, refresh_token: $refresh_token, expires_at: null, scope: $scope}') + fi + + save_credentials "$creds" +} + +_discover_accounts() { + local token + token=$(get_access_token) || return 1 + + # Fizzy uses /my/identity to return accounts the user has access to + local identity_endpoint="$FIZZY_BASE_URL/my/identity" + + debug "Fetching identity from: $identity_endpoint" + + local response http_code + response=$(curl -s -w '\n%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Accept: application/json" \ + "$identity_endpoint") + + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + if [[ "$http_code" != "200" ]]; then + debug "Identity fetch failed (HTTP $http_code): $response" + return 1 + fi + + local accounts + # Extract accounts from identity response; slug is the URL prefix (e.g., "/897362094") + accounts=$(echo "$response" | jq '[.accounts[] | {id: .id, name: .name, slug: .slug, user: .user}]') + + if [[ "$accounts" != "[]" ]] && [[ "$accounts" != "null" ]]; then + save_accounts "$accounts" + return 0 + fi + + return 1 +} + +_select_account() { + local accounts + accounts=$(load_accounts) + + local count + count=$(echo "$accounts" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + warn "No Fizzy accounts found" + return + fi + + local account_slug="" + + if [[ "$count" -eq 1 ]]; then + local account_name + # Extract slug without leading slash + account_slug=$(echo "$accounts" | jq -r '.[0].slug | ltrimstr("/")') + account_name=$(echo "$accounts" | jq -r '.[0].name') + info "Selected account: $account_name ($account_slug)" + elif [[ "$count" -gt 1 ]]; then + # Multiple accounts - let user choose + echo "Multiple Fizzy accounts found:" + echo + echo "$accounts" | jq -r 'to_entries | .[] | " \(.key + 1). \(.value.name) (\(.value.slug | ltrimstr("/")))"' + echo + + local choice + read -rp "Select account (1-$count): " choice + + if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= count )); then + local account_name + account_slug=$(echo "$accounts" | jq -r ".[$((choice - 1))].slug | ltrimstr(\"/\")") + account_name=$(echo "$accounts" | jq -r ".[$((choice - 1))].name") + info "Selected account: $account_name ($account_slug)" + else + warn "Invalid choice, using first account" + account_slug=$(echo "$accounts" | jq -r '.[0].slug | ltrimstr("/")') + fi + fi + + if [[ -n "$account_slug" ]]; then + # Store in per-origin credentials (primary) - ensures switching origins works + set_credential_account_slug "$account_slug" + # Also store in global config for backwards compatibility + set_global_config "account_slug" "$account_slug" + fi + + # Save the base URL so fizzy knows which server to talk to + # This ensures tokens obtained from dev servers talk to dev servers + set_global_config "base_url" "$FIZZY_BASE_URL" + debug "Saved base URL: $FIZZY_BASE_URL" +} + + +# Help + +_help_auth() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy auth", + description: "Manage authentication", + subcommands: [ + {name: "login", description: "Authenticate with Fizzy via OAuth 2.1"}, + {name: "logout", description: "Remove stored credentials"}, + {name: "status", description: "Show current authentication status"}, + {name: "refresh", description: "Check token status (Fizzy uses long-lived tokens)"} + ], + login_options: [ + {flag: "--scope", values: ["write", "read"], description: "Token scope (default: write)"}, + {flag: "--no-browser", description: "Manual auth code entry mode"} + ], + environment: [ + {name: "FIZZY_TOKEN", description: "Access token (takes precedence over stored credentials)"}, + {name: "FIZZY_ACCOUNT_SLUG", description: "Account slug from URL (e.g., 897362094)"} + ], + precedence: { + description: "When both FIZZY_TOKEN and stored OAuth credentials exist", + rules: [ + "FIZZY_TOKEN is used for all API requests", + "Stored credentials are ignored (not deleted)", + "logout clears stored creds but warns about FIZZY_TOKEN", + "refresh errors (env tokens are externally managed)", + "Unset FIZZY_TOKEN to fall back to stored credentials" + ] + }, + notes: ["Fizzy issues long-lived access tokens that do not expire"] + }' + else + cat <<'EOF' +## fizzy auth + +Manage authentication. + +### Subcommands + +- `login` - Authenticate with Fizzy via OAuth 2.1 +- `logout` - Remove stored credentials +- `status` - Show current authentication status +- `refresh` - Check token status (Fizzy uses long-lived tokens) + +### Login Options + +- `--scope write|read` - Token scope (default: write) +- `--no-browser` - Manual auth code entry mode + +### Environment Variables + + FIZZY_TOKEN Access token (takes precedence over stored credentials) + FIZZY_ACCOUNT_SLUG Account slug from URL (e.g., 897362094) + +### Precedence + +When both `FIZZY_TOKEN` and stored OAuth credentials exist: + +1. `FIZZY_TOKEN` is used for all API requests +2. Stored credentials are ignored (not deleted) +3. `fizzy auth logout` clears stored credentials but warns about FIZZY_TOKEN +4. `fizzy auth refresh` errors (env tokens are externally managed) +5. Unset `FIZZY_TOKEN` to fall back to stored credentials + +### Notes + +Fizzy issues long-lived access tokens that do not expire. You only need +to re-authenticate if you explicitly logout or revoke your token. + +### Examples + +```bash +# Interactive login (opens browser) +fizzy auth login + +# Headless/SSH login +fizzy auth login --no-browser + +# Read-only access +fizzy auth login --scope read + +# For CI/scripts: use env vars +export FIZZY_TOKEN=fzt_... +export FIZZY_ACCOUNT_SLUG=897362094 + +# Check status (shows which auth method is active) +fizzy auth status +``` +EOF + fi +} diff --git a/cli/lib/commands/account.sh b/cli/lib/commands/account.sh new file mode 100644 index 0000000000..32b27ec309 --- /dev/null +++ b/cli/lib/commands/account.sh @@ -0,0 +1,937 @@ +#!/usr/bin/env bash +# account.sh - Account management commands + + +# fizzy account +# Manage account settings + +cmd_account() { + case "${1:-}" in + show) + shift + _account_show "$@" + ;; + update) + shift + _account_update "$@" + ;; + entropy) + shift + _account_entropy "$@" + ;; + join-code) + shift + _account_join_code "$@" + ;; + export) + shift + _account_export "$@" + ;; + --help|-h|"") + _account_help + ;; + *) + die "Unknown subcommand: account ${1:-}" $EXIT_USAGE \ + "Available: fizzy account show|update|entropy|join-code|export" + ;; + esac +} + +_account_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy account", + description: "Manage account settings", + subcommands: [ + {name: "show", description: "Show account details"}, + {name: "update", description: "Update account name"}, + {name: "entropy", description: "Set auto-postpone period"}, + {name: "join-code", description: "Manage team join code"}, + {name: "export", description: "Request data export"} + ], + examples: [ + "fizzy account show", + "fizzy account update --name \"New Name\"", + "fizzy account entropy --period 14", + "fizzy account join-code show", + "fizzy account export" + ] + }' + else + cat <<'EOF' +## fizzy account + +Manage account settings. + +### Subcommands + + show Show account details + update --name NAME Update account name + entropy --period DAYS Set auto-postpone period (days) + join-code Manage team join code + export Request data export + +### Examples + + fizzy account show + fizzy account update --name "New Name" + fizzy account entropy --period 14 + fizzy account join-code show + fizzy account join-code reset + fizzy account export +EOF + fi +} + + +# fizzy account show + +_account_show() { + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _account_show_help + return 0 + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + # Fetch account settings - returns HTML but we can get account from identity + local token + token=$(ensure_auth) + + # Get identity which includes account info + local response + response=$(curl -s -w '\n%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Accept: application/json" \ + "$FIZZY_BASE_URL/my/identity.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + case "$http_code" in + 200) ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + # Find current account in the list + local account_data + account_data=$(echo "$response" | jq --arg slug "/$account_slug" '.accounts[] | select(.slug == $slug) // empty') + + if [[ -z "$account_data" ]]; then + die "Account not found: $account_slug" $EXIT_NOT_FOUND + fi + + local account_name + account_name=$(echo "$account_data" | jq -r '.name // "unknown"') + + local summary="Account: $account_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "update" "fizzy account update --name \"name\"" "Update name")" \ + "$(breadcrumb "entropy" "fizzy account entropy --period 14" "Set entropy")" \ + "$(breadcrumb "join-code" "fizzy account join-code show" "View join code")" + ) + + output "$account_data" "$summary" "$breadcrumbs" "_account_show_md" +} + +_account_show_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local name slug + name=$(echo "$data" | jq -r '.name // "unknown"') + slug=$(echo "$data" | jq -r '.slug // "unknown" | ltrimstr("/")') + + local user_role + user_role=$(echo "$data" | jq -r '.user.role // "member"') + + md_heading 2 "Account: $name" + echo + md_kv "Slug" "$slug" \ + "Your Role" "$user_role" + + md_breadcrumbs "$breadcrumbs" +} + +_account_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy account show", + description: "Show account details", + usage: "fizzy account show" + }' + else + cat <<'EOF' +## fizzy account show + +Show account details. + +### Usage + + fizzy account show +EOF + fi +} + + +# fizzy account update --name NAME + +_account_update() { + local show_help=false + local new_name="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --name|-n) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + new_name="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy account update --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _account_update_help + return 0 + fi + + if [[ -z "$new_name" ]]; then + die "Nothing to update" $EXIT_USAGE "Provide --name" + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + local token + token=$(ensure_auth) + + local body + body=$(jq -n --arg name "$new_name" '{account: {name: $name}}') + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X PATCH \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$body" \ + "$FIZZY_BASE_URL/$account_slug/account/settings.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + + case "$http_code" in + 200|204|302) ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN "Only admins can update account" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local result + result=$(jq -n --arg name "$new_name" --arg slug "$account_slug" \ + '{name: $name, slug: $slug, updated: true}') + + local summary="Updated account name to $new_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy account show" "View account")" + ) + + output "$result" "$summary" "$breadcrumbs" "_account_updated_md" +} + +_account_updated_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local name + name=$(echo "$data" | jq -r '.name // "unknown"') + + md_heading 2 "Account Updated" + echo + echo "Account name changed to **$name**." + + md_breadcrumbs "$breadcrumbs" +} + +_account_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy account update", + description: "Update account name", + usage: "fizzy account update --name NAME", + options: [ + {flag: "--name, -n", description: "New account name"} + ], + examples: [ + "fizzy account update --name \"My Team\"" + ] + }' + else + cat <<'EOF' +## fizzy account update + +Update account name. + +### Usage + + fizzy account update --name NAME + +### Options + + --name, -n New account name + +### Examples + + fizzy account update --name "My Team" +EOF + fi +} + + +# fizzy account entropy --period DAYS + +_account_entropy() { + local show_help=false + local period="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --period|-p) + if [[ -z "${2:-}" ]]; then + die "--period requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + die "--period must be a positive integer (days)" $EXIT_USAGE + fi + period="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy account entropy --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _account_entropy_help + return 0 + fi + + if [[ -z "$period" ]]; then + die "Period required" $EXIT_USAGE "Usage: fizzy account entropy --period DAYS" + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + local token + token=$(ensure_auth) + + local body + body=$(jq -n --argjson period "$period" '{entropy: {auto_postpone_period: $period}}') + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X PATCH \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$body" \ + "$FIZZY_BASE_URL/$account_slug/account/entropy.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + + case "$http_code" in + 200|204|302) ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN "Only admins can update entropy" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local result + result=$(jq -n --argjson period "$period" '{auto_postpone_period: $period, updated: true}') + + local summary="Set auto-postpone period to $period days" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy account show" "View account")" + ) + + output "$result" "$summary" "$breadcrumbs" "_account_entropy_md" +} + +_account_entropy_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local period + period=$(echo "$data" | jq -r '.auto_postpone_period // "unknown"') + + md_heading 2 "Entropy Updated" + echo + echo "Cards will auto-postpone after **$period days** of inactivity." + + md_breadcrumbs "$breadcrumbs" +} + +_account_entropy_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy account entropy", + description: "Set account auto-postpone period", + usage: "fizzy account entropy --period DAYS", + options: [ + {flag: "--period, -p", description: "Days before cards auto-postpone"} + ], + notes: [ + "Cards without activity will automatically move to Not Now", + "Set to 0 to disable auto-postponement" + ], + examples: [ + "fizzy account entropy --period 14", + "fizzy account entropy --period 30" + ] + }' + else + cat <<'EOF' +## fizzy account entropy + +Set account auto-postpone period. + +### Usage + + fizzy account entropy --period DAYS + +### Options + + --period, -p Days before cards auto-postpone + +### Notes + +Cards without activity will automatically move to "Not Now" after +the specified number of days. Set to 0 to disable. + +### Examples + + fizzy account entropy --period 14 + fizzy account entropy --period 30 +EOF + fi +} + + +# fizzy account join-code + +_account_join_code() { + local action="${1:-show}" + shift || true + + case "$action" in + show) + _account_join_code_show "$@" + ;; + update) + _account_join_code_update "$@" + ;; + reset) + _account_join_code_reset "$@" + ;; + --help|-h) + _account_join_code_help + ;; + *) + die "Unknown action: $action" $EXIT_USAGE \ + "Available: show, update, reset" + ;; + esac +} + +_account_join_code_show() { + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _account_join_code_help + return 0 + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + local token + token=$(ensure_auth) + + local response + response=$(curl -s -w '\n%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Accept: application/json" \ + "$FIZZY_BASE_URL/$account_slug/account/join_code.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + case "$http_code" in + 200) ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local summary="Join code" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "reset" "fizzy account join-code reset" "Reset code")" \ + "$(breadcrumb "update" "fizzy account join-code update --limit 10" "Set limit")" + ) + + output "$response" "$summary" "$breadcrumbs" "_account_join_code_md" +} + +_account_join_code_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local code usage_limit usage_count + code=$(echo "$data" | jq -r '.code // "unknown"') + usage_limit=$(echo "$data" | jq -r '.usage_limit // "unlimited"') + usage_count=$(echo "$data" | jq -r '.usage_count // 0') + + md_heading 2 "Join Code" + echo + md_kv "Code" "$code" \ + "Usage Limit" "$usage_limit" \ + "Times Used" "$usage_count" + echo + echo "Share this code to invite people to join your account." + + md_breadcrumbs "$breadcrumbs" +} + +_account_join_code_update() { + local show_help=false + local limit="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --limit|-l) + if [[ -z "${2:-}" ]]; then + die "--limit requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + die "--limit must be a positive integer" $EXIT_USAGE + fi + limit="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _account_join_code_help + return 0 + fi + + if [[ -z "$limit" ]]; then + die "Limit required" $EXIT_USAGE "Usage: fizzy account join-code update --limit NUMBER" + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE + fi + + local token + token=$(ensure_auth) + + local body + body=$(jq -n --argjson limit "$limit" '{account_join_code: {usage_limit: $limit}}') + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X PATCH \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$body" \ + "$FIZZY_BASE_URL/$account_slug/account/join_code.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + + case "$http_code" in + 200|204|302) ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN "Only admins can update join code" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local result + result=$(jq -n --argjson limit "$limit" '{usage_limit: $limit, updated: true}') + + local summary="Updated join code usage limit to $limit" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy account join-code show" "View code")" + ) + + output "$result" "$summary" "$breadcrumbs" "_account_join_code_updated_md" +} + +_account_join_code_updated_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local limit + limit=$(echo "$data" | jq -r '.usage_limit // "unknown"') + + md_heading 2 "Join Code Updated" + echo + echo "Usage limit set to **$limit**." + + md_breadcrumbs "$breadcrumbs" +} + +_account_join_code_reset() { + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _account_join_code_help + return 0 + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE + fi + + local token + token=$(ensure_auth) + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X DELETE \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Accept: application/json" \ + "$FIZZY_BASE_URL/$account_slug/account/join_code.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + + case "$http_code" in + 200|204|302) ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN "Only admins can reset join code" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local result='{"reset": true}' + + local summary="Join code reset" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy account join-code show" "View new code")" + ) + + output "$result" "$summary" "$breadcrumbs" "_account_join_code_reset_md" +} + +_account_join_code_reset_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Join Code Reset" + echo + echo "The join code has been reset. Previous code is no longer valid." + + md_breadcrumbs "$breadcrumbs" +} + +_account_join_code_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy account join-code", + description: "Manage team join code", + usage: "fizzy account join-code ", + actions: [ + {name: "show", description: "Show current join code"}, + {name: "update --limit N", description: "Set usage limit"}, + {name: "reset", description: "Generate new code (invalidates old)"} + ], + examples: [ + "fizzy account join-code show", + "fizzy account join-code update --limit 10", + "fizzy account join-code reset" + ] + }' + else + cat <<'EOF' +## fizzy account join-code + +Manage team join code. + +### Usage + + fizzy account join-code + +### Actions + + show Show current join code + update --limit N Set usage limit + reset Generate new code (invalidates old) + +### Examples + + fizzy account join-code show + fizzy account join-code update --limit 10 + fizzy account join-code reset +EOF + fi +} + + +# fizzy account export + +_account_export() { + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _account_export_help + return 0 + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + local token + token=$(ensure_auth) + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X POST \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Accept: application/json" \ + "$FIZZY_BASE_URL/$account_slug/account/exports.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + + case "$http_code" in + 200|201|202|302) ;; + 429) + die "Export limit exceeded" $EXIT_API \ + "You have reached the maximum number of exports" + ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local result='{"requested": true}' + + local summary="Export requested" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy account show" "View account")" + ) + + output "$result" "$summary" "$breadcrumbs" "_account_export_md" +} + +_account_export_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Export Requested" + echo + echo "Your data export has been queued. You will receive an email" + echo "when the export is ready for download." + + md_breadcrumbs "$breadcrumbs" +} + +_account_export_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy account export", + description: "Request account data export", + usage: "fizzy account export", + notes: [ + "Creates a background job to export all account data", + "You will receive an email when export is ready", + "Limited to 10 concurrent exports per user" + ] + }' + else + cat <<'EOF' +## fizzy account export + +Request account data export. + +### Usage + + fizzy account export + +### Notes + +- Creates a background job to export all account data +- You will receive an email when export is ready +- Limited to 10 concurrent exports per user +EOF + fi +} diff --git a/cli/lib/commands/actions.sh b/cli/lib/commands/actions.sh new file mode 100644 index 0000000000..4b5f6dda50 --- /dev/null +++ b/cli/lib/commands/actions.sh @@ -0,0 +1,3512 @@ +#!/usr/bin/env bash +# actions.sh - Card action commands (Phase 3) + + +# fizzy card "title" [options] - create card +# fizzy card update [options] - update card +# fizzy card delete [nums...] - delete card(s) +# fizzy card image delete - remove header image +# fizzy card publish - publish draft card +# fizzy card move --to - move card to different board + +cmd_card() { + case "${1:-}" in + update) + shift + _card_update "$@" + ;; + delete) + shift + _card_delete "$@" + ;; + image) + shift + _card_image "$@" + ;; + publish) + shift + _card_publish "$@" + ;; + move) + shift + _card_move "$@" + ;; + *) + # Default: create card + _card_create "$@" + ;; + esac +} + +_card_image() { + case "${1:-}" in + delete) + shift + _card_image_delete "$@" + ;; + --help|-h|"") + _card_image_help + ;; + *) + die "Unknown subcommand: card image $1" $EXIT_USAGE \ + "Available: fizzy card image delete " + ;; + esac +} + +_card_image_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy card image", + description: "Manage card header images", + subcommands: [ + {name: "delete", description: "Remove header image from a card"} + ] + }' + else + cat <<'EOF' +## fizzy card image + +Manage card header images. + +### Subcommands + + delete Remove header image from a card + +### Examples + + fizzy card image delete 123 Remove image from card #123 +EOF + fi +} + +_card_create() { + local title="" + local description="" + local board_id="" + local column_id="" + local image_path="" + local tag_names=() + local assignee_ids=() + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --column|-c) + if [[ -z "${2:-}" ]]; then + die "--column requires a column name or ID" $EXIT_USAGE + fi + column_id="$2" + shift 2 + ;; + --description|-d) + if [[ -z "${2:-}" ]]; then + die "--description requires text" $EXIT_USAGE + fi + description="$2" + shift 2 + ;; + --image|-i) + if [[ -z "${2:-}" ]]; then + die "--image requires a file path" $EXIT_USAGE + fi + image_path="$2" + shift 2 + ;; + --tag) + if [[ -z "${2:-}" ]]; then + die "--tag requires a tag name" $EXIT_USAGE + fi + # Store tag name (strip leading # if present) for taggings API + tag_names+=("${2#\#}") + shift 2 + ;; + --assign) + if [[ -z "${2:-}" ]]; then + die "--assign requires a user name or ID" $EXIT_USAGE + fi + assignee_ids+=("$2") + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card --help" + ;; + *) + # First positional arg is title + if [[ -z "$title" ]]; then + title="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _card_create_help + return 0 + fi + + if [[ -z "$title" ]]; then + die "Card title required" $EXIT_USAGE "Usage: fizzy card \"title\"" + fi + + # Validate image file exists + if [[ -n "$image_path" ]] && [[ ! -f "$image_path" ]]; then + die "File not found: $image_path" $EXIT_USAGE + fi + + # Use board from config if not specified + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + + if [[ -z "$board_id" ]]; then + die "No board specified. Use --board or set in .fizzy/config.json" $EXIT_USAGE + fi + + # Resolve board name to ID + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + # Resolve column name to ID if provided (for triage follow-up) + if [[ -n "$column_id" ]]; then + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" + fi + fi + + # Resolve assignee names to IDs (for assignment follow-up) + local resolved_assignee_ids=() + for assignee in "${assignee_ids[@]}"; do + local resolved_user + if resolved_user=$(resolve_user_id "$assignee"); then + resolved_assignee_ids+=("$resolved_user") + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people" + fi + done + assignee_ids=("${resolved_assignee_ids[@]}") + + local response + + if [[ -n "$image_path" ]]; then + # Multipart POST for card with image + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + local token + token=$(ensure_auth) + + local curl_args=( + -s -w '\n%{http_code}' + -X POST + -H "Authorization: Bearer $token" + -H "User-Agent: $FIZZY_USER_AGENT" + -H "Accept: application/json" + --form-string "card[title]=$title" + ) + + if [[ -n "$description" ]]; then + curl_args+=(--form-string "card[description]=$description") + fi + curl_args+=(-F "card[image]=@$image_path") + curl_args+=("$FIZZY_BASE_URL/$account_slug/boards/$board_id/cards.json") + + local http_code + api_multipart_request http_code response "${curl_args[@]}" + + case "$http_code" in + 200|201) ;; + 401|403) + die "Not authorized to create card" $EXIT_FORBIDDEN + ;; + 404) + die "Board not found" $EXIT_NOT_FOUND + ;; + 422) + die "Validation error" $EXIT_API "Check the provided values" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + else + # JSON POST for card without image (uses api_post with retry/auth) + local body + body=$(jq -n \ + --arg title "$title" \ + --arg description "${description:-}" \ + '{title: $title} + + (if $description != "" then {description: $description} else {} end)') + + response=$(api_post "/boards/$board_id/cards" "$body") + fi + + local number + number=$(echo "$response" | jq -r '.number') + + # Chain follow-up actions after card creation + local actions_taken=() + + # Triage to column if specified + if [[ -n "$column_id" ]]; then + local triage_body + triage_body=$(jq -n --arg column_id "$column_id" '{column_id: $column_id}') + api_post "/cards/$number/triage" "$triage_body" > /dev/null + actions_taken+=("triaged") + fi + + # Add tags if specified + for tag_name in "${tag_names[@]}"; do + local tag_body + tag_body=$(jq -n --arg tag_title "$tag_name" '{tag_title: $tag_title}') + api_post "/cards/$number/taggings" "$tag_body" > /dev/null + actions_taken+=("tagged #$tag_name") + done + + # Add assignments if specified + for assignee_id in "${assignee_ids[@]}"; do + local assign_body + assign_body=$(jq -n --arg assignee_id "$assignee_id" '{assignee_id: $assignee_id}') + api_post "/cards/$number/assignments" "$assign_body" > /dev/null + actions_taken+=("assigned") + done + + # Fetch updated card if we made any follow-up changes + if [[ ${#actions_taken[@]} -gt 0 ]]; then + response=$(api_get "/cards/$number") + fi + + local summary="Created card #$number" + if [[ ${#actions_taken[@]} -gt 0 ]]; then + summary="Created card #$number (${actions_taken[*]})" + fi + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $number" "View card details")" \ + "$(breadcrumb "triage" "fizzy triage $number --to " "Move to column")" \ + "$(breadcrumb "comment" "fizzy comment \"text\" --on $number" "Add comment")" + ) + + output "$response" "$summary" "$breadcrumbs" "_card_created_md" +} + +_card_created_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title board_name + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:50]') + board_name=$(echo "$data" | jq -r '.board.name') + + md_heading 2 "Card Created" + echo + md_kv "Number" "#$number" \ + "Title" "$title" \ + "Board" "$board_name" + + md_breadcrumbs "$breadcrumbs" +} + +_card_create_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy card", + description: "Create a new card with optional follow-up actions", + usage: "fizzy card \"title\" [options]", + options: [ + {flag: "--board, -b, --in", description: "Board name or ID (required if not in config)"}, + {flag: "--column, -c", description: "Column name or ID (chains triage after create)"}, + {flag: "--description, -d", description: "Card description"}, + {flag: "--image, -i", description: "Path to header image file"}, + {flag: "--tag", description: "Tag name (chains tagging after create, repeatable)"}, + {flag: "--assign", description: "Assignee name/email/ID (chains assignment after create, repeatable)"} + ], + notes: "--column, --tag, and --assign execute follow-up API calls after card creation", + examples: [ + "fizzy card \"Fix login bug\"", + "fizzy card \"New feature\" --board \"My Board\" --column \"In Progress\"", + "fizzy card \"Task\" --tag bug --assign \"Jane Doe\"" + ] + }' + else + cat <<'EOF' +## fizzy card + +Create a new card with optional follow-up actions. + +### Usage + + fizzy card "title" [options] + +### Options + + --board, -b, --in Board name or ID (required if not in config) + --column, -c Column name or ID (chains triage after create) + --description, -d Card description + --image, -i Path to header image file + --tag Tag name (chains tagging, repeatable) + --assign Assignee name/email/ID (chains assignment, repeatable) + --help, -h Show this help + +Note: --column, --tag, and --assign trigger follow-up API calls after the +card is created, since the create endpoint only accepts title and description. + +### Examples + + fizzy card "Fix login bug" + fizzy card "New feature" --board "My Board" --column "In Progress" + fizzy card "Task" --tag bug --tag urgent --assign "Jane Doe" +EOF + fi +} + + +# fizzy card update [options] +# Update a card's title, description, or image + +_card_update() { + local card_number="" + local title="" + local description="" + local description_file="" + local image_path="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --title|-t) + if [[ -z "${2:-}" ]]; then + die "--title requires a value" $EXIT_USAGE + fi + title="$2" + shift 2 + ;; + --description|-d) + if [[ -z "${2:-}" ]]; then + die "--description requires text" $EXIT_USAGE + fi + description="$2" + shift 2 + ;; + --description-file) + if [[ -z "${2:-}" ]]; then + die "--description-file requires a file path" $EXIT_USAGE + fi + description_file="$2" + shift 2 + ;; + --image|-i) + if [[ -z "${2:-}" ]]; then + die "--image requires a file path" $EXIT_USAGE + fi + image_path="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card update --help" + ;; + *) + # First positional arg is card number + if [[ -z "$card_number" ]]; then + card_number="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _card_update_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy card update [options]" + fi + + # Read description from file if specified + if [[ -n "$description_file" ]]; then + if [[ ! -f "$description_file" ]]; then + die "File not found: $description_file" $EXIT_USAGE + fi + description=$(cat "$description_file") + fi + + # Validate image file exists + if [[ -n "$image_path" ]] && [[ ! -f "$image_path" ]]; then + die "File not found: $image_path" $EXIT_USAGE + fi + + # Must specify at least one thing to update + if [[ -z "$title" && -z "$description" && -z "$image_path" ]]; then + die "Nothing to update. Specify --title, --description, or --image" $EXIT_USAGE + fi + + local response http_code + + if [[ -n "$image_path" ]]; then + # Multipart upload for image requires account_slug + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + local token + token=$(ensure_auth) + + local curl_args=( + -s -w '\n%{http_code}' + -X PATCH + -H "Authorization: Bearer $token" + -H "User-Agent: $FIZZY_USER_AGENT" + -H "Accept: application/json" + ) + + if [[ -n "$title" ]]; then + curl_args+=(--form-string "card[title]=$title") + fi + if [[ -n "$description" ]]; then + curl_args+=(--form-string "card[description]=$description") + fi + curl_args+=(-F "card[image]=@$image_path") + curl_args+=("$FIZZY_BASE_URL/$account_slug/cards/$card_number.json") + + api_multipart_request http_code response "${curl_args[@]}" + + case "$http_code" in + 200) ;; + 401|403) + die "Not authorized to update this card" $EXIT_FORBIDDEN + ;; + 404) + die "Card not found: #$card_number" $EXIT_NOT_FOUND + ;; + 422) + die "Validation error" $EXIT_API "Check the provided values" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + else + # JSON update for title/description only (api_patch handles auth) + local body + body=$(jq -n \ + --arg title "$title" \ + --arg description "$description" \ + '{card: ((if $title != "" then {title: $title} else {} end) + + (if $description != "" then {description: $description} else {} end))}') + + response=$(api_patch "/cards/$card_number" "$body") + fi + + local summary="Card #$card_number updated" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card details")" \ + "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" \ + "$(breadcrumb "comment" "fizzy comment \"text\" --on $card_number" "Add comment")" + ) + + output "$response" "$summary" "$breadcrumbs" "_card_updated_md" +} + +_card_updated_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title board_name + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:50]') + board_name=$(echo "$data" | jq -r '.board.name') + + md_heading 2 "Card Updated" + echo + md_kv "Number" "#$number" \ + "Title" "$title" \ + "Board" "$board_name" + + md_breadcrumbs "$breadcrumbs" +} + +_card_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy card update", + description: "Update a card'\''s title, description, or image", + usage: "fizzy card update [options]", + options: [ + {flag: "--title, -t", description: "New card title"}, + {flag: "--description, -d", description: "New card description (HTML)"}, + {flag: "--description-file", description: "Read description from file"}, + {flag: "--image, -i", description: "Path to header image file"} + ], + examples: [ + "fizzy card update 123 --title \"New title\"", + "fizzy card update 123 --description \"

Updated content

\"", + "fizzy card update 123 --image ~/header.png" + ] + }' + else + cat <<'EOF' +## fizzy card update + +Update a card's title, description, or header image. + +### Usage + + fizzy card update [options] + +### Options + + --title, -t New card title + --description, -d New card description (HTML) + --description-file Read description from file + --image, -i Path to header image file + --help, -h Show this help + +### Examples + + fizzy card update 123 --title "New title" + fizzy card update 123 --description "

Updated content

" + fizzy card update 123 --image ~/header.png + fizzy card update 123 --title "New" --image cover.jpg +EOF + fi +} + + +# fizzy card publish +# Publish a draft card + +_card_publish() { + local show_help=false + local card_number="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card publish --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _card_publish_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy card publish " + fi + + local response + response=$(api_post "/cards/$card_number/publish" "") + + # Fetch updated card + response=$(api_get "/cards/$card_number") + + local summary="Card #$card_number published" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_card_published_md" +} + +_card_published_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:50]') + + md_heading 2 "Card Published" + echo + md_kv "Number" "#$number" \ + "Title" "$title" \ + "Status" "published" + + md_breadcrumbs "$breadcrumbs" +} + +_card_publish_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy card publish", + description: "Publish a draft card", + usage: "fizzy card publish ", + examples: [ + "fizzy card publish 123" + ] + }' + else + cat <<'EOF' +## fizzy card publish + +Publish a draft card, making it visible to all board members. + +### Usage + + fizzy card publish + +### Examples + + fizzy card publish 123 +EOF + fi +} + + +# fizzy card move --to +# Move a card to a different board + +_card_move() { + local show_help=false + local card_number="" + local target_board="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --to|--board|-b) + if [[ -z "${2:-}" ]]; then + die "--to requires a board name or ID" $EXIT_USAGE + fi + target_board="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card move --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _card_move_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy card move --to " + fi + + if [[ -z "$target_board" ]]; then + die "--to required" $EXIT_USAGE "Usage: fizzy card move --to " + fi + + # Resolve board name to ID + local board_id + board_id=$(resolve_board_id "$target_board") || exit $? + + # Move card to new board + local body + body=$(jq -n --arg board_id "$board_id" '{board_id: $board_id}') + api_patch "/cards/$card_number/board" "$body" > /dev/null + + # Fetch updated card + local response + response=$(api_get "/cards/$card_number") + + local board_name + board_name=$(echo "$response" | jq -r '.board.name') + + local summary="Card #$card_number moved to $board_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_card_moved_md" +} + +_card_moved_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title board_name + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:50]') + board_name=$(echo "$data" | jq -r '.board.name') + + md_heading 2 "Card Moved" + echo + md_kv "Number" "#$number" \ + "Title" "$title" \ + "Board" "$board_name" + + md_breadcrumbs "$breadcrumbs" +} + +_card_move_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy card move", + description: "Move a card to a different board", + usage: "fizzy card move --to ", + options: [ + {flag: "--to, --board, -b", description: "Target board name or ID"} + ], + examples: [ + "fizzy card move 123 --to \"Other Board\"", + "fizzy card move 123 -b abc123" + ] + }' + else + cat <<'EOF' +## fizzy card move + +Move a card to a different board. + +### Usage + + fizzy card move --to + +### Options + + --to, --board, -b Target board name or ID + +### Examples + + fizzy card move 123 --to "Other Board" + fizzy card move 123 -b abc123 +EOF + fi +} + + +# fizzy close +# Close a card + +cmd_close() { + local show_help=false + local card_numbers=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy close --help" + ;; + *) + card_numbers+=("$1") + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _close_help + return 0 + fi + + if [[ ${#card_numbers[@]} -eq 0 ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy close [numbers...]" + fi + + local results=() + local num + for num in "${card_numbers[@]}"; do + # POST closure returns 204 No Content, so fetch card after + api_post "/cards/$num/closure" > /dev/null + local response + response=$(api_get "/cards/$num") + results+=("$(echo "$response" | jq '{number: .number, title: .title, closed: .closed}')") + done + + local response_data + if [[ ${#results[@]} -eq 1 ]]; then + response_data="${results[0]}" + local summary="Closed card #${card_numbers[0]}" + else + response_data=$(printf '%s\n' "${results[@]}" | jq -s '.') + local summary="Closed ${#card_numbers[@]} cards" + fi + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "reopen" "fizzy reopen ${card_numbers[0]}" "Reopen card")" \ + "$(breadcrumb "show" "fizzy show ${card_numbers[0]}" "View card")" + ) + + output "$response_data" "$summary" "$breadcrumbs" "_close_md" +} + +_close_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Card Closed" + echo "*$summary*" + echo + + # Check if single card or array + local is_array + is_array=$(echo "$data" | jq 'if type == "array" then "yes" else "no" end' -r) + + if [[ "$is_array" == "yes" ]]; then + echo "| # | Title | Status |" + echo "|---|-------|--------|" + echo "$data" | jq -r '.[] | "| #\(.number) | \(.title // "-")[0:40] | Closed |"' + else + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // "-"') + md_kv "Card" "#$number" "Title" "$title" "Status" "Closed" + fi + + echo + md_breadcrumbs "$breadcrumbs" +} + +_close_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy close", + description: "Close card(s)", + usage: "fizzy close [numbers...]", + examples: ["fizzy close 123", "fizzy close 123 124 125"] + }' + else + cat <<'EOF' +## fizzy close + +Close card(s). + +### Usage + + fizzy close [numbers...] + +### Examples + + fizzy close 123 Close card #123 + fizzy close 123 124 125 Close multiple cards +EOF + fi +} + + +# fizzy reopen +# Reopen a closed card + +cmd_reopen() { + local show_help=false + local card_numbers=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy reopen --help" + ;; + *) + card_numbers+=("$1") + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _reopen_help + return 0 + fi + + if [[ ${#card_numbers[@]} -eq 0 ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy reopen " + fi + + local results=() + local num + for num in "${card_numbers[@]}"; do + # DELETE closure returns 204 No Content, so fetch card after + api_delete "/cards/$num/closure" > /dev/null + local response + response=$(api_get "/cards/$num") + results+=("$(echo "$response" | jq '{number: .number, title: .title, closed: .closed}')") + done + + local response_data + if [[ ${#results[@]} -eq 1 ]]; then + response_data="${results[0]}" + local summary="Reopened card #${card_numbers[0]}" + else + response_data=$(printf '%s\n' "${results[@]}" | jq -s '.') + local summary="Reopened ${#card_numbers[@]} cards" + fi + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "close" "fizzy close ${card_numbers[0]}" "Close card")" \ + "$(breadcrumb "show" "fizzy show ${card_numbers[0]}" "View card")" \ + "$(breadcrumb "triage" "fizzy triage ${card_numbers[0]} --to " "Move to column")" + ) + + output "$response_data" "$summary" "$breadcrumbs" "_reopen_md" +} + +_reopen_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Card Reopened" + echo "*$summary*" + echo + + local is_array + is_array=$(echo "$data" | jq 'if type == "array" then "yes" else "no" end' -r) + + if [[ "$is_array" == "yes" ]]; then + echo "| # | Title | Status |" + echo "|---|-------|--------|" + echo "$data" | jq -r '.[] | "| #\(.number) | \(.title // "-")[0:40] | Active |"' + else + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // "-"') + md_kv "Card" "#$number" "Title" "$title" "Status" "Active" + fi + + echo + md_breadcrumbs "$breadcrumbs" +} + +_reopen_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy reopen", + description: "Reopen closed card(s)", + usage: "fizzy reopen [numbers...]", + examples: ["fizzy reopen 123", "fizzy reopen 123 124"] + }' + else + cat <<'EOF' +## fizzy reopen + +Reopen closed card(s). + +### Usage + + fizzy reopen [numbers...] + +### Examples + + fizzy reopen 123 Reopen card #123 + fizzy reopen 123 124 Reopen multiple cards +EOF + fi +} + + +# fizzy card delete +# Permanently delete a card + +_card_delete() { + local show_help=false + local card_numbers=() + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card delete --help" + ;; + *) + card_numbers+=("$1") + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _card_delete_help + return 0 + fi + + if [[ ${#card_numbers[@]} -eq 0 ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy card delete " + fi + + # Validate all card numbers are positive integers (1+) + local num + for num in "${card_numbers[@]}"; do + if ! [[ "$num" =~ ^[1-9][0-9]*$ ]]; then + die "Invalid card number: $num" $EXIT_USAGE "Card numbers must be positive integers" + fi + done + + local results=() + for num in "${card_numbers[@]}"; do + # DELETE returns 204 No Content + api_delete "/cards/$num" > /dev/null + results+=("$(jq -n --arg num "$num" '{number: ($num | tonumber), deleted: true}')") + done + + local response_data + if [[ ${#results[@]} -eq 1 ]]; then + response_data="${results[0]}" + else + response_data=$(printf '%s\n' "${results[@]}" | jq -s '.') + fi + + local summary + if [[ ${#card_numbers[@]} -eq 1 ]]; then + summary="Deleted card #${card_numbers[0]}" + else + summary="Deleted ${#card_numbers[@]} cards" + fi + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "cards" "fizzy cards" "List cards")" \ + "$(breadcrumb "card" "fizzy card \"title\" --in " "Create new card")" + ) + + output "$response_data" "$summary" "$breadcrumbs" "_card_delete_md" +} + +_card_delete_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Card Deleted" + echo + + # Single or multiple? + if echo "$data" | jq -e 'type == "array"' > /dev/null 2>&1; then + echo "| # | Status |" + echo "|---|--------|" + echo "$data" | jq -r '.[] | "| #\(.number) | Deleted |"' + else + local card_number + card_number=$(echo "$data" | jq -r '.number') + md_kv "Card" "#$card_number" \ + "Status" "Deleted" + fi + + md_breadcrumbs "$breadcrumbs" +} + +_card_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy card delete", + description: "Permanently delete card(s)", + usage: "fizzy card delete [numbers...]", + warning: "This action cannot be undone", + examples: ["fizzy card delete 123", "fizzy card delete 123 124"] + }' + else + cat <<'EOF' +## fizzy card delete + +Permanently delete card(s). + +**Warning:** This action cannot be undone. + +### Usage + + fizzy card delete [numbers...] + +### Examples + + fizzy card delete 123 Delete card #123 + fizzy card delete 123 124 Delete multiple cards +EOF + fi +} + + +# fizzy card image delete +# Remove header image from card + +_card_image_delete() { + local show_help=false + local card_number="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy card image delete --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _card_image_delete_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy card image delete " + fi + + # DELETE returns 204 No Content, fetch card after for response + api_delete "/cards/$card_number/image" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Removed image from card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "update" "fizzy card update $card_number" "Update card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_card_image_delete_md" +} + +_card_image_delete_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local card_number title image_url + card_number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title') + image_url=$(echo "$data" | jq -r '.image_url // "none"') + + md_heading 2 "Image Removed" + echo + md_kv "Card" "#$card_number" \ + "Title" "$title" \ + "Image" "$image_url" + + md_breadcrumbs "$breadcrumbs" +} + +_card_image_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy card image delete", + description: "Remove header image from a card", + usage: "fizzy card image delete ", + examples: ["fizzy card image delete 123"] + }' + else + cat <<'EOF' +## fizzy card image delete + +Remove header image from a card. + +### Usage + + fizzy card image delete + +### Examples + + fizzy card image delete 123 Remove header image from card #123 +EOF + fi +} + + +# fizzy triage --to +# Move card to a column + +cmd_triage() { + local card_number="" + local column_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --to) + if [[ -z "${2:-}" ]]; then + die "--to requires a column ID" $EXIT_USAGE + fi + column_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy triage --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _triage_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy triage --to " + fi + + if [[ -z "$column_id" ]]; then + # Try to get default column from config + column_id=$(get_column_id 2>/dev/null || true) + if [[ -z "$column_id" ]]; then + die "--to column ID required" $EXIT_USAGE "Usage: fizzy triage --to " + fi + fi + + # Resolve column name to ID if needed + # First, try to get board_id from config + local board_id + board_id=$(get_board_id 2>/dev/null || true) + if [[ -n "$board_id" ]]; then + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + fi + fi + + # If no board context, fetch the card to get its board + if [[ -z "$board_id" ]]; then + local card_data + if card_data=$(api_get "/cards/$card_number" 2>/dev/null); then + board_id=$(echo "$card_data" | jq -r '.board.id // empty') + fi + fi + + # If we have a board context, try to resolve column name + if [[ -n "$board_id" ]]; then + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" + else + # Only fail if it looks like a name (not UUID) + if [[ ! "$column_id" =~ ^[a-z0-9]{20,}$ ]]; then + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" + fi + fi + else + # No board context and column looks like a name - warn user + if [[ ! "$column_id" =~ ^[a-z0-9]{20,}$ ]]; then + die "Cannot resolve column name without board context" $EXIT_USAGE \ + "Use column ID directly, or set board context: fizzy config set board_id " + fi + fi + + local body + body=$(jq -n --arg column_id "$column_id" '{column_id: $column_id}') + + # POST triage returns 204 No Content, so fetch card after + api_post "/cards/$card_number/triage" "$body" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local column_name + column_name=$(echo "$response" | jq -r '.column.name // "column"') + local summary="Card #$card_number triaged to $column_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "untriage" "fizzy untriage $card_number" "Send back to triage")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "close" "fizzy close $card_number" "Close card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_triage_md" +} + +_triage_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title column_name board_name + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + column_name=$(echo "$data" | jq -r '.column.name // "Triage"') + board_name=$(echo "$data" | jq -r '.board.name') + + md_heading 2 "Card Triaged" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Board" "$board_name" \ + "Column" "$column_name" + + md_breadcrumbs "$breadcrumbs" +} + +_triage_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy triage", + description: "Move card to a column", + usage: "fizzy triage --to ", + options: [{flag: "--to", description: "Column name or ID to triage to"}], + examples: ["fizzy triage 123 --to \"In Progress\"", "fizzy triage 123 --to abc456"] + }' + else + cat <<'EOF' +## fizzy triage + +Move card to a column. + +### Usage + + fizzy triage --to + +### Options + + --to Column name or ID to triage to (required) + --help, -h Show this help + +### Examples + + fizzy triage 123 --to "In Progress" Move by column name + fizzy triage 123 --to abc456 Move by column ID +EOF + fi +} + + +# fizzy untriage +# Send card back to triage + +cmd_untriage() { + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy untriage --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _untriage_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy untriage " + fi + + # DELETE triage returns 204 No Content, so fetch card after + api_delete "/cards/$card_number/triage" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Card #$card_number sent back to triage" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_untriage_md" +} + +_untriage_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title board_name + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + board_name=$(echo "$data" | jq -r '.board.name') + + md_heading 2 "Card Untriaged" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Board" "$board_name" \ + "Status" "Back in Triage" + + md_breadcrumbs "$breadcrumbs" +} + +_untriage_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy untriage", + description: "Send card back to triage", + usage: "fizzy untriage ", + examples: ["fizzy untriage 123"] + }' + else + cat <<'EOF' +## fizzy untriage + +Send card back to triage. + +### Usage + + fizzy untriage + +### Examples + + fizzy untriage 123 Send card #123 back to triage +EOF + fi +} + + +# fizzy postpone +# Move card to "Not Now" + +cmd_postpone() { + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy postpone --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _postpone_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy postpone " + fi + + # POST not_now returns 204 No Content, so fetch card after + api_post "/cards/$card_number/not_now" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Card #$card_number moved to Not Now" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_postpone_md" +} + +_postpone_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title board_name + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + board_name=$(echo "$data" | jq -r '.board.name') + + md_heading 2 "Card Postponed" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Board" "$board_name" \ + "Status" "Not Now" + + md_breadcrumbs "$breadcrumbs" +} + +_postpone_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy postpone", + description: "Move card to Not Now", + usage: "fizzy postpone ", + examples: ["fizzy postpone 123"] + }' + else + cat <<'EOF' +## fizzy postpone + +Move card to "Not Now". + +### Usage + + fizzy postpone + +### Examples + + fizzy postpone 123 Move card #123 to Not Now +EOF + fi +} + + +# fizzy comment "text" --on +# fizzy comment edit --on "new text" +# fizzy comment delete --on + +cmd_comment() { + # Check for subcommand + case "${1:-}" in + edit) + shift + _comment_edit "$@" + return + ;; + delete) + shift + _comment_delete "$@" + return + ;; + esac + + # Default: create comment + local content="" + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comment --help" + ;; + *) + if [[ -z "$content" ]]; then + content="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _comment_create_help + return 0 + fi + + if [[ -z "$content" ]]; then + die "Comment content required" $EXIT_USAGE "Usage: fizzy comment \"text\" --on " + fi + + if [[ -z "$card_number" ]]; then + die "--on card number required" $EXIT_USAGE "Usage: fizzy comment \"text\" --on " + fi + + local body + body=$(jq -n --arg body "$content" '{comment: {body: $body}}') + + local response + response=$(api_post "/cards/$card_number/comments" "$body") + + local comment_id + comment_id=$(echo "$response" | jq -r '.id') + local summary="Comment added to card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "comments" "fizzy comments --on $card_number" "View all comments")" \ + "$(breadcrumb "react" "fizzy react \"👍\" --comment $comment_id" "Add reaction")" + ) + + output "$response" "$summary" "$breadcrumbs" "_comment_created_md" +} + +_comment_edit() { + local comment_id="" + local card_number="" + local content="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comment edit --help" + ;; + *) + if [[ -z "$comment_id" ]]; then + comment_id="$1" + elif [[ -z "$content" ]]; then + content="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _comment_edit_help + return 0 + fi + + if [[ -z "$comment_id" ]]; then + die "Comment ID required" $EXIT_USAGE "Usage: fizzy comment edit --on \"new text\"" + fi + + if [[ -z "$card_number" ]]; then + die "--on card number required" $EXIT_USAGE "Usage: fizzy comment edit --on \"new text\"" + fi + + if [[ -z "$content" ]]; then + die "New comment text required" $EXIT_USAGE "Usage: fizzy comment edit --on \"new text\"" + fi + + local body + body=$(jq -n --arg body "$content" '{comment: {body: $body}}') + + # PATCH returns 204 No Content, so fetch the comment after to show updated state + api_patch "/cards/$card_number/comments/$comment_id" "$body" > /dev/null + local response + response=$(api_get "/cards/$card_number/comments/$comment_id") + + local summary="Comment updated on card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "comments" "fizzy comments --on $card_number" "View all comments")" + ) + + output "$response" "$summary" "$breadcrumbs" "_comment_edited_md" +} + +_comment_edited_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local comment_id creator_name updated_at + comment_id=$(echo "$data" | jq -r '.id') + creator_name=$(echo "$data" | jq -r '.creator.name // "You"') + updated_at=$(echo "$data" | jq -r '.updated_at | split("T")[0]') + + md_heading 2 "Comment Updated" + echo + md_kv "ID" "$comment_id" \ + "Author" "$creator_name" \ + "Updated" "$updated_at" + + md_breadcrumbs "$breadcrumbs" +} + +_comment_edit_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy comment edit", + description: "Update a comment", + usage: "fizzy comment edit --on \"new text\"", + options: [{flag: "--on", description: "Card number the comment belongs to"}], + examples: ["fizzy comment edit abc123 --on 123 \"Updated text\""] + }' + else + cat <<'EOF' +## fizzy comment edit + +Update a comment. + +### Usage + + fizzy comment edit --on "new text" + +### Options + + --on Card number the comment belongs to (required) + --help, -h Show this help + +### Examples + + fizzy comment edit abc123 --on 123 "Updated comment text" +EOF + fi +} + +_comment_delete() { + local comment_id="" + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comment delete --help" + ;; + *) + if [[ -z "$comment_id" ]]; then + comment_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _comment_delete_help + return 0 + fi + + if [[ -z "$comment_id" ]]; then + die "Comment ID required" $EXIT_USAGE "Usage: fizzy comment delete --on " + fi + + if [[ -z "$card_number" ]]; then + die "--on card number required" $EXIT_USAGE "Usage: fizzy comment delete --on " + fi + + # DELETE returns 204 No Content + api_delete "/cards/$card_number/comments/$comment_id" > /dev/null + + local response + response=$(jq -n --arg comment_id "$comment_id" --arg card_number "$card_number" \ + '{deleted: true, comment_id: $comment_id, card_number: $card_number}') + + local summary="Comment deleted from card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "comments" "fizzy comments --on $card_number" "View all comments")" \ + "$(breadcrumb "comment" "fizzy comment \"text\" --on $card_number" "Add new comment")" + ) + + output "$response" "$summary" "$breadcrumbs" "_comment_deleted_md" +} + +_comment_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local comment_id card_number + comment_id=$(echo "$data" | jq -r '.comment_id') + card_number=$(echo "$data" | jq -r '.card_number') + + md_heading 2 "Comment Deleted" + echo + md_kv "Comment ID" "$comment_id" \ + "Card" "#$card_number" \ + "Status" "Deleted" + + md_breadcrumbs "$breadcrumbs" +} + +_comment_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy comment delete", + description: "Delete a comment", + usage: "fizzy comment delete --on ", + options: [{flag: "--on", description: "Card number the comment belongs to"}], + examples: ["fizzy comment delete abc123 --on 123"] + }' + else + cat <<'EOF' +## fizzy comment delete + +Delete a comment. + +### Usage + + fizzy comment delete --on + +### Options + + --on Card number the comment belongs to (required) + --help, -h Show this help + +### Examples + + fizzy comment delete abc123 --on 123 +EOF + fi +} + +_comment_created_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local comment_id creator_name created_at + comment_id=$(echo "$data" | jq -r '.id') + creator_name=$(echo "$data" | jq -r '.creator.name // "You"') + created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]') + + md_heading 2 "Comment Added" + echo + md_kv "ID" "$comment_id" \ + "Author" "$creator_name" \ + "Date" "$created_at" + + md_breadcrumbs "$breadcrumbs" +} + +_comment_create_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy comment", + description: "Add comment to a card", + usage: "fizzy comment \"text\" --on ", + options: [{flag: "--on", description: "Card number to comment on"}], + examples: ["fizzy comment \"LGTM!\" --on 123"] + }' + else + cat <<'EOF' +## fizzy comment + +Add comment to a card. + +### Usage + + fizzy comment "text" --on + +### Options + + --on Card number to comment on (required) + --help, -h Show this help + +### Examples + + fizzy comment "LGTM!" --on 123 Add comment to card #123 +EOF + fi +} + + +# fizzy assign --to +# Toggle assignment + +cmd_assign() { + local card_number="" + local user_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --to) + if [[ -z "${2:-}" ]]; then + die "--to requires a user ID" $EXIT_USAGE + fi + user_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy assign --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _assign_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy assign --to " + fi + + if [[ -z "$user_id" ]]; then + die "--to user ID required" $EXIT_USAGE "Usage: fizzy assign --to " + fi + + # Resolve user name/email to ID + local resolved_user + if resolved_user=$(resolve_user_id "$user_id"); then + user_id="$resolved_user" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people" + fi + + local body + body=$(jq -n --arg assignee_id "$user_id" '{assignee_id: $assignee_id}') + + # POST assignments returns 204 No Content, so fetch card after + api_post "/cards/$card_number/assignments" "$body" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Assignment toggled on card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "people" "fizzy people" "List users")" + ) + + output "$response" "$summary" "$breadcrumbs" "_assign_md" +} + +_assign_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + + md_heading 2 "Assignment Toggled" + echo + md_kv "Card" "#$number" \ + "Title" "$title" + + md_breadcrumbs "$breadcrumbs" +} + +_assign_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy assign", + description: "Toggle assignment on a card", + usage: "fizzy assign --to ", + options: [{flag: "--to", description: "User name, email, or ID to toggle assignment"}], + examples: ["fizzy assign 123 --to \"Jane Doe\"", "fizzy assign 123 --to jane@example.com"] + }' + else + cat <<'EOF' +## fizzy assign + +Toggle assignment on a card (adds if not assigned, removes if assigned). + +### Usage + + fizzy assign --to + +### Options + + --to User name, email, or ID to toggle assignment (required) + --help, -h Show this help + +### Examples + + fizzy assign 123 --to "Jane Doe" Assign by name + fizzy assign 123 --to jane@example.com Assign by email +EOF + fi +} + + +# fizzy tag --with "name" +# Toggle tag on card + +cmd_tag() { + local card_number="" + local tag_name="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --with) + if [[ -z "${2:-}" ]]; then + die "--with requires a tag name" $EXIT_USAGE + fi + tag_name="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy tag --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _tag_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy tag --with " + fi + + if [[ -z "$tag_name" ]]; then + die "--with tag name required" $EXIT_USAGE "Usage: fizzy tag --with " + fi + + # Strip leading # if present (users may type #bug or bug) + tag_name="${tag_name#\#}" + + # API expects tag_title (the tag name), not tag_id + local body + body=$(jq -n --arg tag_title "$tag_name" '{tag_title: $tag_title}') + + # POST taggings returns 204 No Content, so fetch card after + api_post "/cards/$card_number/taggings" "$body" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Tag toggled on card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "tags" "fizzy tags" "List tags")" + ) + + output "$response" "$summary" "$breadcrumbs" "_tag_md" +} + +_tag_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title tags + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + tags=$(echo "$data" | jq -r '.tags | join(", ")') + + md_heading 2 "Tag Toggled" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Tags" "${tags:-None}" + + md_breadcrumbs "$breadcrumbs" +} + +_tag_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy tag", + description: "Toggle tag on a card", + usage: "fizzy tag --with ", + options: [{flag: "--with", description: "Tag name to toggle"}], + examples: ["fizzy tag 123 --with \"bug\"", "fizzy tag 123 --with \"feature\""] + }' + else + cat <<'EOF' +## fizzy tag + +Toggle tag on a card (adds if not tagged, removes if tagged). + +### Usage + + fizzy tag --with + +### Options + + --with Tag name to toggle (required) + --help, -h Show this help + +### Examples + + fizzy tag 123 --with "bug" Toggle "bug" tag + fizzy tag 123 --with "feature" Toggle "feature" tag +EOF + fi +} + + +# fizzy watch +# Subscribe to card notifications + +cmd_watch() { + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy watch --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _watch_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy watch " + fi + + # POST watch returns 204 No Content, so fetch card after + api_post "/cards/$card_number/watch" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Subscribed to card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "unwatch" "fizzy unwatch $card_number" "Unsubscribe")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_watch_md" +} + +_watch_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + + md_heading 2 "Subscribed" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Watching" "Yes" + + md_breadcrumbs "$breadcrumbs" +} + +_watch_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy watch", + description: "Subscribe to card notifications", + usage: "fizzy watch ", + examples: ["fizzy watch 123"] + }' + else + cat <<'EOF' +## fizzy watch + +Subscribe to card notifications. + +### Usage + + fizzy watch + +### Examples + + fizzy watch 123 Subscribe to card #123 +EOF + fi +} + + +# fizzy unwatch +# Unsubscribe from card + +cmd_unwatch() { + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy unwatch --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _unwatch_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy unwatch " + fi + + # DELETE watch returns 204 No Content, so fetch card after + api_delete "/cards/$card_number/watch" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Unsubscribed from card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "watch" "fizzy watch $card_number" "Subscribe")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_unwatch_md" +} + +_unwatch_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + + md_heading 2 "Unsubscribed" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Watching" "No" + + md_breadcrumbs "$breadcrumbs" +} + +_unwatch_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy unwatch", + description: "Unsubscribe from card notifications", + usage: "fizzy unwatch ", + examples: ["fizzy unwatch 123"] + }' + else + cat <<'EOF' +## fizzy unwatch + +Unsubscribe from card notifications. + +### Usage + + fizzy unwatch + +### Examples + + fizzy unwatch 123 Unsubscribe from card #123 +EOF + fi +} + + +# fizzy gild +# Mark card as golden + +cmd_gild() { + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy gild --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _gild_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy gild " + fi + + # POST goldness returns 204 No Content, so fetch card after + api_post "/cards/$card_number/goldness" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Card #$card_number marked as golden" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "ungild" "fizzy ungild $card_number" "Remove golden")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_gild_md" +} + +_gild_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + + md_heading 2 "Card Gilded" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Golden" "Yes ✨" + + md_breadcrumbs "$breadcrumbs" +} + +_gild_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy gild", + description: "Mark card as golden", + usage: "fizzy gild ", + examples: ["fizzy gild 123"] + }' + else + cat <<'EOF' +## fizzy gild + +Mark card as golden (protects from auto-postponement). + +### Usage + + fizzy gild + +### Examples + + fizzy gild 123 Mark card #123 as golden +EOF + fi +} + + +# fizzy ungild +# Remove golden status + +cmd_ungild() { + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy ungild --help" + ;; + *) + if [[ -z "$card_number" ]]; then + card_number="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _ungild_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy ungild " + fi + + # DELETE goldness returns 204 No Content, so fetch card after + api_delete "/cards/$card_number/goldness" > /dev/null + local response + response=$(api_get "/cards/$card_number") + + local summary="Golden status removed from card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "gild" "fizzy gild $card_number" "Mark as golden")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_ungild_md" +} + +_ungild_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // .description[0:40]') + + md_heading 2 "Card Ungilded" + echo + md_kv "Card" "#$number" \ + "Title" "$title" \ + "Golden" "No" + + md_breadcrumbs "$breadcrumbs" +} + +_ungild_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy ungild", + description: "Remove golden status from card", + usage: "fizzy ungild ", + examples: ["fizzy ungild 123"] + }' + else + cat <<'EOF' +## fizzy ungild + +Remove golden status from card. + +### Usage + + fizzy ungild + +### Examples + + fizzy ungild 123 Remove golden from card #123 +EOF + fi +} + + +# fizzy step "text" --on +# Add step to card + +# fizzy step "text" --on [--completed] +# fizzy step show --on +# fizzy step update --on [--content "text"] [--completed|--uncompleted] +# fizzy step delete --on + +cmd_step() { + if [[ "${1:-}" == "show" ]]; then + shift + _step_show "$@" + return + elif [[ "${1:-}" == "update" ]]; then + shift + _step_update "$@" + return + elif [[ "${1:-}" == "delete" ]]; then + shift + _step_delete "$@" + return + fi + + # Default: create step + _step_create "$@" +} + +_step_create() { + local content="" + local card_number="" + local completed="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --completed) + completed="true" + shift + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step --help" + ;; + *) + if [[ -z "$content" ]]; then + content="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _step_help + return 0 + fi + + if [[ -z "$content" ]]; then + die "Step content required" $EXIT_USAGE "Usage: fizzy step \"text\" --on " + fi + + if [[ -z "$card_number" ]]; then + die "--on card number required" $EXIT_USAGE "Usage: fizzy step \"text\" --on " + fi + + # Build request body - Rails expects params[:step] + local body + if [[ "$completed" == "true" ]]; then + body=$(jq -n --arg content "$content" '{step: {content: $content, completed: true}}') + else + body=$(jq -n --arg content "$content" '{step: {content: $content}}') + fi + + # POST returns 201 with Location header - api_post follows it automatically + local response + response=$(api_post "/cards/$card_number/steps" "$body") + + local summary="Step added to card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "step" "fizzy step \"text\" --on $card_number" "Add another step")" + ) + + output "$response" "$summary" "$breadcrumbs" "_step_md" +} + +_step_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local step_id content completed + step_id=$(echo "$data" | jq -r '.id') + content=$(echo "$data" | jq -r '.content') + completed=$(echo "$data" | jq -r 'if .completed then "Yes" else "No" end') + + md_heading 2 "Step Added" + echo + md_kv "ID" "$step_id" \ + "Content" "$content" \ + "Completed" "$completed" + + md_breadcrumbs "$breadcrumbs" +} + +_step_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy step", + description: "Manage steps (checklist items) on a card", + usage: "fizzy step \"text\" --on [--completed]", + subcommands: [ + {name: "show", description: "Show step details"}, + {name: "update", description: "Update a step"}, + {name: "delete", description: "Delete a step"} + ], + options: [ + {flag: "--on", description: "Card number (required)"}, + {flag: "--completed", description: "Mark step as completed on creation"} + ], + examples: [ + "fizzy step \"Review PR\" --on 123", + "fizzy step \"Done task\" --on 123 --completed", + "fizzy step show abc123 --on 123", + "fizzy step update abc123 --on 123 --completed", + "fizzy step delete abc123 --on 123" + ] + }' + else + cat <<'EOF' +## fizzy step + +Manage steps (checklist items) on a card. + +### Usage + + fizzy step "text" --on [--completed] Create step + fizzy step show --on View step + fizzy step update --on [opts] Update step + fizzy step delete --on Delete step + +### Options (create) + + --on Card number (required) + --completed Mark step as completed + --help, -h Show this help + +### Options (update) + + --content New step text + --completed Mark as completed + --uncompleted Mark as not completed + +### Examples + + fizzy step "Review PR" --on 123 Add uncompleted step + fizzy step "Done" --on 123 --completed Add completed step + fizzy step show abc123 --on 123 View step details + fizzy step update abc123 --on 123 --completed Mark step done + fizzy step delete abc123 --on 123 Remove step +EOF + fi +} + +# fizzy step show --on +_step_show() { + local step_id="" + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step show --help" + ;; + *) + if [[ -z "$step_id" ]]; then + step_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _step_show_help + return 0 + fi + + if [[ -z "$step_id" ]]; then + die "Step ID required" $EXIT_USAGE "Usage: fizzy step show --on " + fi + + if [[ -z "$card_number" ]]; then + die "--on card number required" $EXIT_USAGE "Usage: fizzy step show --on " + fi + + local response + response=$(api_get "/cards/$card_number/steps/$step_id") + + local summary="Step on card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "update" "fizzy step update $step_id --on $card_number" "Update step")" \ + "$(breadcrumb "delete" "fizzy step delete $step_id --on $card_number" "Delete step")" + ) + + output "$response" "$summary" "$breadcrumbs" "_step_show_md" +} + +_step_show_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local step_id content completed + step_id=$(echo "$data" | jq -r '.id') + content=$(echo "$data" | jq -r '.content') + completed=$(echo "$data" | jq -r 'if .completed then "Yes" else "No" end') + + md_heading 2 "Step" + echo + md_kv "ID" "$step_id" \ + "Content" "$content" \ + "Completed" "$completed" + + md_breadcrumbs "$breadcrumbs" +} + +_step_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy step show", + description: "Show step details", + usage: "fizzy step show --on ", + examples: ["fizzy step show abc123 --on 123"] + }' + else + cat <<'EOF' +## fizzy step show + +Show step details. + +### Usage + + fizzy step show --on + +### Examples + + fizzy step show abc123 --on 123 View step abc123 on card #123 +EOF + fi +} + +# fizzy step update --on [--content "text"] [--completed|--uncompleted] +_step_update() { + local step_id="" + local card_number="" + local content="" + local completed="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --content) + if [[ -z "${2:-}" ]]; then + die "--content requires text" $EXIT_USAGE + fi + content="$2" + shift 2 + ;; + --completed) + completed="true" + shift + ;; + --uncompleted) + completed="false" + shift + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step update --help" + ;; + *) + if [[ -z "$step_id" ]]; then + step_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _step_update_help + return 0 + fi + + if [[ -z "$step_id" ]]; then + die "Step ID required" $EXIT_USAGE "Usage: fizzy step update --on [options]" + fi + + if [[ -z "$card_number" ]]; then + die "--on card number required" $EXIT_USAGE "Usage: fizzy step update --on [options]" + fi + + if [[ -z "$content" && -z "$completed" ]]; then + die "Nothing to update. Specify --content, --completed, or --uncompleted" $EXIT_USAGE + fi + + # Build request body - Rails expects params[:step] + local body + body=$(jq -n \ + --arg content "$content" \ + --arg completed "$completed" \ + '{step: ((if $content != "" then {content: $content} else {} end) + + (if $completed != "" then {completed: ($completed == "true")} else {} end))}') + + local response + response=$(api_patch "/cards/$card_number/steps/$step_id" "$body") + + local summary="Step updated on card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy step show $step_id --on $card_number" "View step")" \ + "$(breadcrumb "card" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_step_updated_md" +} + +_step_updated_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local step_id content completed + step_id=$(echo "$data" | jq -r '.id') + content=$(echo "$data" | jq -r '.content') + completed=$(echo "$data" | jq -r 'if .completed then "Yes" else "No" end') + + md_heading 2 "Step Updated" + echo + md_kv "ID" "$step_id" \ + "Content" "$content" \ + "Completed" "$completed" + + md_breadcrumbs "$breadcrumbs" +} + +_step_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy step update", + description: "Update a step", + usage: "fizzy step update --on [options]", + options: [ + {flag: "--content", description: "New step text"}, + {flag: "--completed", description: "Mark as completed"}, + {flag: "--uncompleted", description: "Mark as not completed"} + ], + examples: [ + "fizzy step update abc123 --on 123 --completed", + "fizzy step update abc123 --on 123 --content \"Updated text\"" + ] + }' + else + cat <<'EOF' +## fizzy step update + +Update a step. + +### Usage + + fizzy step update --on [options] + +### Options + + --content New step text + --completed Mark step as completed + --uncompleted Mark step as not completed + --help, -h Show this help + +### Examples + + fizzy step update abc123 --on 123 --completed Mark done + fizzy step update abc123 --on 123 --content "New text" Update text +EOF + fi +} + +# fizzy step delete --on +_step_delete() { + local step_id="" + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy step delete --help" + ;; + *) + if [[ -z "$step_id" ]]; then + step_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _step_delete_help + return 0 + fi + + if [[ -z "$step_id" ]]; then + die "Step ID required" $EXIT_USAGE "Usage: fizzy step delete --on " + fi + + if [[ -z "$card_number" ]]; then + die "--on card number required" $EXIT_USAGE "Usage: fizzy step delete --on " + fi + + # DELETE returns 204 No Content + api_delete "/cards/$card_number/steps/$step_id" > /dev/null + + local response + response=$(jq -n --arg id "$step_id" '{id: $id, deleted: true}') + + local summary="Step deleted from card #$card_number" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" \ + "$(breadcrumb "step" "fizzy step \"text\" --on $card_number" "Add step")" + ) + + output "$response" "$summary" "$breadcrumbs" "_step_deleted_md" +} + +_step_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local step_id + step_id=$(echo "$data" | jq -r '.id') + + md_heading 2 "Step Deleted" + echo + md_kv "ID" "$step_id" \ + "Status" "Deleted" + + md_breadcrumbs "$breadcrumbs" +} + +_step_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy step delete", + description: "Delete a step from a card", + usage: "fizzy step delete --on ", + examples: ["fizzy step delete abc123 --on 123"] + }' + else + cat <<'EOF' +## fizzy step delete + +Delete a step from a card. + +### Usage + + fizzy step delete --on + +### Examples + + fizzy step delete abc123 --on 123 Delete step from card #123 +EOF + fi +} + + +# fizzy react "emoji" --card --comment +# fizzy react delete --card --comment + +cmd_react() { + if [[ "${1:-}" == "delete" ]]; then + shift + _react_delete "$@" + return + fi + + # Default: add reaction + _react_add "$@" +} + +_react_add() { + local emoji="" + local comment_id="" + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --comment) + if [[ -z "${2:-}" ]]; then + die "--comment requires a comment ID" $EXIT_USAGE + fi + comment_id="$2" + shift 2 + ;; + --card) + if [[ -z "${2:-}" ]]; then + die "--card requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy react --help" + ;; + *) + if [[ -z "$emoji" ]]; then + emoji="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _react_help + return 0 + fi + + if [[ -z "$emoji" ]]; then + die "Emoji required" $EXIT_USAGE "Usage: fizzy react \"👍\" --card --comment " + fi + + if [[ -z "$card_number" ]]; then + die "--card number required" $EXIT_USAGE "Usage: fizzy react \"👍\" --card --comment " + fi + + if [[ -z "$comment_id" ]]; then + die "--comment ID required" $EXIT_USAGE "Usage: fizzy react \"👍\" --card --comment " + fi + + local body + body=$(jq -n --arg content "$emoji" '{reaction: {content: $content}}') + + # POST reactions returns 201 with no body/Location, construct response + api_post "/cards/$card_number/comments/$comment_id/reactions" "$body" > /dev/null + local response + response=$(jq -n --arg emoji "$emoji" --arg card_number "$card_number" --arg comment_id "$comment_id" \ + '{emoji: $emoji, card_number: $card_number, comment_id: $comment_id}') + + local summary="Reaction added to comment" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "reactions" "fizzy reactions --card $card_number --comment $comment_id" "View reactions")" \ + "$(breadcrumb "comments" "fizzy comments --on $card_number" "View comments")" + ) + + output "$response" "$summary" "$breadcrumbs" "_react_md" +} + +_react_delete() { + local reaction_id="" + local comment_id="" + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --comment) + if [[ -z "${2:-}" ]]; then + die "--comment requires a comment ID" $EXIT_USAGE + fi + comment_id="$2" + shift 2 + ;; + --card) + if [[ -z "${2:-}" ]]; then + die "--card requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy react delete --help" + ;; + *) + if [[ -z "$reaction_id" ]]; then + reaction_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _react_delete_help + return 0 + fi + + if [[ -z "$reaction_id" ]]; then + die "Reaction ID required" $EXIT_USAGE "Usage: fizzy react delete --card --comment " + fi + + if [[ -z "$card_number" ]]; then + die "--card number required" $EXIT_USAGE "Usage: fizzy react delete --card --comment " + fi + + if [[ -z "$comment_id" ]]; then + die "--comment ID required" $EXIT_USAGE "Usage: fizzy react delete --card --comment " + fi + + # DELETE returns 204 No Content + api_delete "/cards/$card_number/comments/$comment_id/reactions/$reaction_id" > /dev/null + + local response + response=$(jq -n --arg id "$reaction_id" '{id: $id, deleted: true}') + + local summary="Reaction deleted" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "reactions" "fizzy reactions --card $card_number --comment $comment_id" "View reactions")" \ + "$(breadcrumb "react" "fizzy react \"👍\" --card $card_number --comment $comment_id" "Add reaction")" + ) + + output "$response" "$summary" "$breadcrumbs" "_react_deleted_md" +} + +_react_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local reaction_id + reaction_id=$(echo "$data" | jq -r '.id') + + md_heading 2 "Reaction Deleted" + echo + md_kv "ID" "$reaction_id" \ + "Status" "Deleted" + + md_breadcrumbs "$breadcrumbs" +} + +_react_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy react delete", + description: "Delete a reaction from a comment", + usage: "fizzy react delete --card --comment ", + options: [ + {flag: "--card", description: "Card number"}, + {flag: "--comment", description: "Comment ID"} + ], + examples: ["fizzy react delete xyz789 --card 123 --comment abc456"] + }' + else + cat <<'EOF' +## fizzy react delete + +Delete a reaction from a comment. + +### Usage + + fizzy react delete --card --comment + +### Options + + --card Card number (required) + --comment Comment ID (required) + --help, -h Show this help + +### Examples + + fizzy react delete xyz789 --card 123 --comment abc456 +EOF + fi +} + +_react_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local emoji card_number comment_id + emoji=$(echo "$data" | jq -r '.emoji') + card_number=$(echo "$data" | jq -r '.card_number') + comment_id=$(echo "$data" | jq -r '.comment_id') + + md_heading 2 "Reaction Added" + echo + md_kv "Emoji" "$emoji" \ + "Card" "#$card_number" \ + "Comment" "$comment_id" + + md_breadcrumbs "$breadcrumbs" +} + +_react_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy react", + description: "Manage reactions on comments", + usage: "fizzy react \"emoji\" --card --comment ", + subcommands: [ + {name: "delete", description: "Delete a reaction"} + ], + options: [ + {flag: "--card", description: "Card number"}, + {flag: "--comment", description: "Comment ID to react to"} + ], + examples: [ + "fizzy react \"👍\" --card 123 --comment abc456", + "fizzy react delete xyz789 --card 123 --comment abc456" + ] + }' + else + cat <<'EOF' +## fizzy react + +Manage reactions on comments. + +### Usage + + fizzy react "emoji" --card --comment Add reaction + fizzy react delete --card --comment Delete reaction + +### Options + + --card Card number (required) + --comment Comment ID (required) + --help, -h Show this help + +### Examples + + fizzy react "👍" --card 123 --comment abc456 Add thumbs up + fizzy react delete xyz789 --card 123 --comment abc456 Remove reaction +EOF + fi +} diff --git a/cli/lib/commands/boards.sh b/cli/lib/commands/boards.sh new file mode 100644 index 0000000000..ed2b4aab0f --- /dev/null +++ b/cli/lib/commands/boards.sh @@ -0,0 +1,2077 @@ +#!/usr/bin/env bash +# boards.sh - Board and column query commands + + +# fizzy boards [options] +# List boards in the account + +cmd_boards() { + local show_help=false + local page="" + local fetch_all=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --all|-a) + fetch_all=true + shift + ;; + --page|-p) + if [[ -z "${2:-}" ]]; then + die "--page requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -lt 1 ]]; then + die "--page must be a positive integer" $EXIT_USAGE + fi + page="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy boards --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _boards_help + return 0 + fi + + local response + if [[ "$fetch_all" == "true" ]]; then + response=$(api_get_all "/boards") + else + local path="/boards" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + response=$(api_get "$path") + fi + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count boards" + [[ -n "$page" ]] && summary="$count boards (page $page)" + [[ "$fetch_all" == "true" ]] && summary="$count boards (all)" + + local next_page=$((${page:-1} + 1)) + local breadcrumbs + if [[ "$fetch_all" == "true" ]]; then + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show board " "View board details")" \ + "$(breadcrumb "cards" "fizzy cards --board " "List cards on board")" \ + "$(breadcrumb "columns" "fizzy columns --board " "List board columns")" + ) + else + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show board " "View board details")" \ + "$(breadcrumb "cards" "fizzy cards --board " "List cards on board")" \ + "$(breadcrumb "columns" "fizzy columns --board " "List board columns")" \ + "$(breadcrumb "next" "fizzy boards --page $next_page" "Next page")" + ) + fi + + output "$response" "$summary" "$breadcrumbs" "_boards_md" +} + +_boards_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Boards ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No boards found." + echo + else + echo "| ID | Name | Access | Created |" + echo "|----|------|--------|---------|" + echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(if .all_access then "All" else "Selective" end) | \(.created_at | split("T")[0]) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_boards_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy boards", + description: "List boards in the account", + options: [ + {flag: "--all, -a", description: "Fetch all pages"}, + {flag: "--page, -p", description: "Page number for pagination"} + ], + examples: [ + "fizzy boards", + "fizzy boards --all", + "fizzy boards --page 2" + ] + }' + else + cat <<'EOF' +## fizzy boards + +List boards in the account. + +### Usage + + fizzy boards [options] + +### Options + + --all, -a Fetch all pages + --page, -p Page number for pagination + --help, -h Show this help + +### Examples + + fizzy boards List boards (first page) + fizzy boards --all Fetch all boards + fizzy boards --page 2 Get second page +EOF + fi +} + + +# fizzy board +# Manage boards (create, update, delete, show) + +cmd_board() { + case "${1:-}" in + create) + shift + _board_create "$@" + ;; + update) + shift + _board_update "$@" + ;; + delete) + shift + _board_delete "$@" + ;; + show) + shift + _board_show "$@" + ;; + publish) + shift + _board_publish "$@" + ;; + unpublish) + shift + _board_unpublish "$@" + ;; + entropy) + shift + _board_entropy "$@" + ;; + --help|-h|"") + _board_help + ;; + *) + die "Unknown subcommand: board ${1:-}" $EXIT_USAGE \ + "Available: fizzy board create|update|delete|show|publish|unpublish|entropy" + ;; + esac +} + +_board_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board", + description: "Manage boards", + subcommands: [ + {name: "create", description: "Create a new board"}, + {name: "update", description: "Update a board"}, + {name: "delete", description: "Delete a board"}, + {name: "show", description: "Show board details"}, + {name: "publish", description: "Publish board publicly"}, + {name: "unpublish", description: "Unpublish board"}, + {name: "entropy", description: "Set auto-postpone period"} + ], + examples: [ + "fizzy board create \"New Board\"", + "fizzy board update abc123 --name \"Renamed\"", + "fizzy board delete abc123", + "fizzy board show abc123", + "fizzy board publish abc123", + "fizzy board entropy abc123 --period 14" + ] + }' + else + cat <<'EOF' +## fizzy board + +Manage boards. + +### Subcommands + + create Create a new board + update Update a board + delete Delete a board + show Show board details + publish Publish board publicly (shareable link) + unpublish Unpublish board + entropy Set auto-postpone period + +### Examples + + fizzy board create "New Board" + fizzy board update abc123 --name "Renamed" + fizzy board delete abc123 + fizzy board show abc123 + fizzy board publish abc123 + fizzy board entropy abc123 --period 14 +EOF + fi +} + +_board_create() { + local name="" + local all_access="" + local all_access_flag=false + local selective_flag=false + local auto_postpone="" + local public_description="" + local public_description_file="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --all-access) + all_access="true" + all_access_flag=true + shift + ;; + --selective) + all_access="false" + selective_flag=true + shift + ;; + --auto-postpone|--auto-postpone-period) + if [[ -z "${2:-}" ]]; then + die "--auto-postpone requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + die "--auto-postpone must be a non-negative integer" $EXIT_USAGE + fi + auto_postpone="$2" + shift 2 + ;; + --public-description) + if [[ -z "${2:-}" ]]; then + die "--public-description requires text" $EXIT_USAGE + fi + public_description="$2" + shift 2 + ;; + --public-description-file) + if [[ -z "${2:-}" ]]; then + die "--public-description-file requires a file path" $EXIT_USAGE + fi + public_description_file="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board create --help" + ;; + *) + if [[ -z "$name" ]]; then + name="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_create_help + return 0 + fi + + if [[ -n "$public_description_file" ]]; then + if [[ ! -f "$public_description_file" ]]; then + die "File not found: $public_description_file" $EXIT_USAGE + fi + public_description=$(cat "$public_description_file") + fi + + if [[ -z "$name" ]]; then + die "Board name required" $EXIT_USAGE "Usage: fizzy board create \"name\"" + fi + + if [[ "$all_access_flag" == "true" && "$selective_flag" == "true" ]]; then + die "Cannot use --all-access and --selective together" $EXIT_USAGE + fi + + local board_payload + board_payload=$(jq -n \ + --arg name "$name" \ + --arg auto_postpone "$auto_postpone" \ + --arg public_description "$public_description" \ + --arg all_access "$all_access" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $auto_postpone != "" then {auto_postpone_period: ($auto_postpone | tonumber)} else {} end) + + (if $public_description != "" then {public_description: $public_description} else {} end) + + (if $all_access != "" then {all_access: ($all_access == "true")} else {} end) + )') + + local body + body=$(jq -n --argjson board "$board_payload" '{board: $board}') + + local response + response=$(api_post "/boards" "$body") + + local summary="Created board \"$name\"" + + local board_id + board_id=$(echo "$response" | jq -r '.id') + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show board $board_id" "View board details")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards")" \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_board_md" +} + +_board_create_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board create", + description: "Create a new board", + usage: "fizzy board create \"name\" [options]", + options: [ + {flag: "--name", description: "Board name (positional allowed)"}, + {flag: "--all-access", description: "All users can access (default)"}, + {flag: "--selective", description: "Restrict board access to selected users"}, + {flag: "--auto-postpone", description: "Days before cards auto-postpone"}, + {flag: "--public-description", description: "Public description (HTML)"}, + {flag: "--public-description-file", description: "Read public description from file"} + ], + examples: [ + "fizzy board create \"New Board\"", + "fizzy board create \"Client\" --selective", + "fizzy board create \"Launch\" --auto-postpone 14" + ] + }' + else + cat <<'EOF' +## fizzy board create + +Create a new board. + +### Usage + + fizzy board create "name" [options] + +### Options + + --name Board name (positional allowed) + --all-access All users can access (default) + --selective Restrict access to selected users + --auto-postpone Days before cards auto-postpone + --public-description Public description (HTML) + --public-description-file Read public description from file + --help, -h Show this help + +### Examples + + fizzy board create "New Board" + fizzy board create "Client" --selective + fizzy board create "Launch" --auto-postpone 14 +EOF + fi +} + +_board_update() { + local board_id="" + local name="" + local all_access="" + local all_access_flag=false + local selective_flag=false + local auto_postpone="" + local public_description="" + local public_description_file="" + local user_inputs=() + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --all-access) + all_access="true" + all_access_flag=true + shift + ;; + --selective) + all_access="false" + selective_flag=true + shift + ;; + --auto-postpone|--auto-postpone-period) + if [[ -z "${2:-}" ]]; then + die "--auto-postpone requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]]; then + die "--auto-postpone must be a non-negative integer" $EXIT_USAGE + fi + auto_postpone="$2" + shift 2 + ;; + --public-description) + if [[ -z "${2:-}" ]]; then + die "--public-description requires text" $EXIT_USAGE + fi + public_description="$2" + shift 2 + ;; + --public-description-file) + if [[ -z "${2:-}" ]]; then + die "--public-description-file requires a file path" $EXIT_USAGE + fi + public_description_file="$2" + shift 2 + ;; + --user) + if [[ -z "${2:-}" ]]; then + die "--user requires a name, email, or ID" $EXIT_USAGE + fi + user_inputs+=("$2") + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board update --help" + ;; + *) + if [[ -z "$board_id" ]]; then + board_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_update_help + return 0 + fi + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy board update [options]" + fi + + if [[ -n "$public_description_file" ]]; then + if [[ ! -f "$public_description_file" ]]; then + die "File not found: $public_description_file" $EXIT_USAGE + fi + public_description=$(cat "$public_description_file") + fi + + if [[ "$all_access_flag" == "true" && "$selective_flag" == "true" ]]; then + die "Cannot use --all-access and --selective together" $EXIT_USAGE + fi + + if [[ ${#user_inputs[@]} -gt 0 ]]; then + if [[ "$all_access" == "true" ]]; then + die "--user cannot be combined with --all-access" $EXIT_USAGE + fi + if [[ -z "$all_access" ]]; then + all_access="false" + fi + fi + + # Resolve board ID (accepts name) + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + # Resolve user IDs + local user_ids=() + local input + for input in "${user_inputs[@]}"; do + local resolved_user + if resolved_user=$(resolve_user_id "$input"); then + user_ids+=("$resolved_user") + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people" + fi + done + + local board_payload + board_payload=$(jq -n \ + --arg name "$name" \ + --arg auto_postpone "$auto_postpone" \ + --arg public_description "$public_description" \ + --arg all_access "$all_access" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $auto_postpone != "" then {auto_postpone_period: ($auto_postpone | tonumber)} else {} end) + + (if $public_description != "" then {public_description: $public_description} else {} end) + + (if $all_access != "" then {all_access: ($all_access == "true")} else {} end) + )') + + local user_ids_json="null" + if [[ ${#user_ids[@]} -gt 0 ]]; then + user_ids_json=$(printf '%s\n' "${user_ids[@]}" | jq -R . | jq -s '.') + fi + + if [[ "$board_payload" == "{}" && "$user_ids_json" == "null" ]]; then + die "Nothing to update. Specify changes" $EXIT_USAGE + fi + + local body + body=$(jq -n \ + --argjson board "$board_payload" \ + --argjson user_ids "$user_ids_json" \ + '( + (if ($board | length) > 0 then {board: $board} else {} end) + + (if $user_ids != null then {user_ids: $user_ids} else {} end) + )') + + # PATCH returns 204 No Content + api_patch "/boards/$board_id" "$body" > /dev/null + local response + response=$(api_get "/boards/$board_id") + + local summary="Board updated" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show board $board_id" "View board details")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_board_md" +} + +_board_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board update", + description: "Update a board", + usage: "fizzy board update [options]", + options: [ + {flag: "--name", description: "New board name"}, + {flag: "--all-access", description: "All users can access"}, + {flag: "--selective", description: "Restrict access to selected users"}, + {flag: "--auto-postpone", description: "Days before cards auto-postpone"}, + {flag: "--public-description", description: "Public description (HTML)"}, + {flag: "--public-description-file", description: "Read public description from file"}, + {flag: "--user", description: "User name/email/ID (repeatable)"} + ], + examples: [ + "fizzy board update abc123 --name \"Renamed\"", + "fizzy board update abc123 --auto-postpone 14", + "fizzy board update abc123 --selective --user \"Jane Doe\"" + ] + }' + else + cat <<'EOF' +## fizzy board update + +Update a board. + +### Usage + + fizzy board update [options] + +### Options + + --name New board name + --all-access All users can access + --selective Restrict access to selected users + --auto-postpone Days before cards auto-postpone + --public-description Public description (HTML) + --public-description-file Read public description from file + --user User name/email/ID (repeatable) + --help, -h Show this help + +### Examples + + fizzy board update abc123 --name "Renamed" + fizzy board update abc123 --auto-postpone 14 + fizzy board update abc123 --selective --user "Jane Doe" +EOF + fi +} + +_board_delete() { + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board delete --help" + ;; + *) + if [[ -z "$board_id" ]]; then + board_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_delete_help + return 0 + fi + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy board delete " + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + # DELETE returns 204 No Content + api_delete "/boards/$board_id" > /dev/null + + local response + response=$(jq -n --arg id "$board_id" '{id: $id, deleted: true}') + + local summary="Board deleted" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "boards" "fizzy boards" "List boards")" \ + "$(breadcrumb "board" "fizzy board create \"name\"" "Create board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_board_deleted_md" +} + +_board_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local board_id + board_id=$(echo "$data" | jq -r '.id') + + md_heading 2 "Board Deleted" + echo + md_kv "ID" "$board_id" \ + "Status" "Deleted" + + md_breadcrumbs "$breadcrumbs" +} + +_board_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board delete", + description: "Delete a board", + usage: "fizzy board delete ", + warning: "This action cannot be undone", + examples: ["fizzy board delete abc123"] + }' + else + cat <<'EOF' +## fizzy board delete + +Delete a board. + +**Warning:** This action cannot be undone. + +### Usage + + fizzy board delete + +### Examples + + fizzy board delete abc123 +EOF + fi +} + +_board_show() { + local board_id="${1:-}" + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy board show " + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + show_board "$board_id" +} + + +# fizzy board publish +# Publish board publicly (creates shareable link) + +_board_publish() { + local board_ref="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board publish --help" + ;; + *) + if [[ -z "$board_ref" ]]; then + board_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_publish_help + return 0 + fi + + if [[ -z "$board_ref" ]]; then + die "Board name or ID required" $EXIT_USAGE "Usage: fizzy board publish " + fi + + local board_id + board_id=$(resolve_board_id "$board_ref") || exit $? + + local publish_response + publish_response=$(api_post "/boards/$board_id/publication") + + local board_response + board_response=$(api_get "/boards/$board_id") + + local board_name published_url + board_name=$(echo "$board_response" | jq -r '.name') + published_url=$(echo "$publish_response" | jq -r '.url') + + # Merge the published_url into the board response for output + local response + response=$(echo "$board_response" | jq --arg url "$published_url" '. + {published_url: $url}') + + local summary="Board \"$board_name\" published" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "unpublish" "fizzy board unpublish $board_id" "Unpublish board")" \ + "$(breadcrumb "show" "fizzy board show $board_id" "View board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_board_publish_md" +} + +_board_publish_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local name published_url + name=$(echo "$data" | jq -r '.name') + published_url=$(echo "$data" | jq -r '.published_url // "pending"') + + md_heading 2 "Board Published" + echo + md_kv "Board" "$name" \ + "Public URL" "$published_url" + + md_breadcrumbs "$breadcrumbs" +} + +_board_publish_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board publish", + description: "Publish board publicly", + usage: "fizzy board publish ", + examples: ["fizzy board publish \"My Board\""] + }' + else + cat <<'EOF' +## fizzy board publish + +Publish board publicly, creating a shareable link. + +### Usage + + fizzy board publish + +### Examples + + fizzy board publish "My Board" + fizzy board publish abc123 +EOF + fi +} + + +# fizzy board unpublish +# Unpublish board (remove public access) + +_board_unpublish() { + local board_ref="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board unpublish --help" + ;; + *) + if [[ -z "$board_ref" ]]; then + board_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_unpublish_help + return 0 + fi + + if [[ -z "$board_ref" ]]; then + die "Board name or ID required" $EXIT_USAGE "Usage: fizzy board unpublish " + fi + + local board_id + board_id=$(resolve_board_id "$board_ref") || exit $? + + api_delete "/boards/$board_id/publication" > /dev/null + + local response + response=$(api_get "/boards/$board_id") + + local board_name + board_name=$(echo "$response" | jq -r '.name') + + local summary="Board \"$board_name\" unpublished" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "publish" "fizzy board publish $board_id" "Publish board")" \ + "$(breadcrumb "show" "fizzy board show $board_id" "View board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_board_unpublish_md" +} + +_board_unpublish_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local name + name=$(echo "$data" | jq -r '.name') + + md_heading 2 "Board Unpublished" + echo + md_kv "Board" "$name" \ + "Status" "Private" + + md_breadcrumbs "$breadcrumbs" +} + +_board_unpublish_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board unpublish", + description: "Unpublish board", + usage: "fizzy board unpublish ", + examples: ["fizzy board unpublish \"My Board\""] + }' + else + cat <<'EOF' +## fizzy board unpublish + +Unpublish board, removing public access. + +### Usage + + fizzy board unpublish + +### Examples + + fizzy board unpublish "My Board" + fizzy board unpublish abc123 +EOF + fi +} + + +# fizzy board entropy --period +# Set auto-postpone period for board + +_board_entropy() { + local board_ref="" + local period="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --period|-p) + if [[ -z "${2:-}" ]]; then + die "--period requires a value (days)" $EXIT_USAGE + fi + period="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy board entropy --help" + ;; + *) + if [[ -z "$board_ref" ]]; then + board_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _board_entropy_help + return 0 + fi + + if [[ -z "$board_ref" ]]; then + die "Board name or ID required" $EXIT_USAGE "Usage: fizzy board entropy --period " + fi + + if [[ -z "$period" ]]; then + die "--period required" $EXIT_USAGE "Usage: fizzy board entropy --period " + fi + + local board_id + board_id=$(resolve_board_id "$board_ref") || exit $? + + local body + body=$(jq -n --argjson period "$period" '{board: {auto_postpone_period: $period}}') + api_patch "/boards/$board_id/entropy" "$body" > /dev/null + + local response + response=$(api_get "/boards/$board_id") + + local board_name + board_name=$(echo "$response" | jq -r '.name') + + local summary="Board \"$board_name\" entropy set to $period days" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy board show $board_id" "View board")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards")" + ) + + output "$response" "$summary" "$breadcrumbs" "_board_entropy_md" +} + +_board_entropy_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local name period + name=$(echo "$data" | jq -r '.name') + period=$(echo "$data" | jq -r '.auto_postpone_period // "default"') + + md_heading 2 "Board Entropy Updated" + echo + md_kv "Board" "$name" \ + "Auto-postpone period" "$period days" + + md_breadcrumbs "$breadcrumbs" +} + +_board_entropy_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy board entropy", + description: "Set auto-postpone period for board", + usage: "fizzy board entropy --period ", + options: [ + {flag: "--period, -p", description: "Number of days before cards auto-postpone"} + ], + examples: [ + "fizzy board entropy \"My Board\" --period 14", + "fizzy board entropy abc123 --period 7" + ] + }' + else + cat <<'EOF' +## fizzy board entropy + +Set auto-postpone period for board. Cards without activity will +automatically move to "Not Now" after this many days. + +### Usage + + fizzy board entropy --period + +### Options + + --period, -p Number of days before cards auto-postpone + +### Examples + + fizzy board entropy "My Board" --period 14 + fizzy board entropy abc123 --period 7 +EOF + fi +} + + +# fizzy columns [options] +# List columns on a board + +cmd_columns() { + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy columns --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _columns_help + return 0 + fi + + # Use provided board_id or fall back to config + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id) + fi + + if [[ -z "$board_id" ]]; then + die "No board specified" $EXIT_USAGE \ + "Use: fizzy columns --board " + fi + + # Resolve board name to ID if needed + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + local response + response=$(api_get "/boards/$board_id/columns") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count columns" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "triage" "fizzy triage --to " "Move card to column")" \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards on board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_columns_md" +} + +_columns_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Columns ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No columns found." + echo + else + echo "| ID | Name | Color |" + echo "|----|------|-------|" + echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(.color // "default") |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_columns_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy columns", + description: "List columns on a board", + options: [ + {flag: "--board, -b, --in", description: "Board name or ID (required unless set in config)"} + ], + examples: [ + "fizzy columns --board \"My Board\"", + "fizzy columns -b abc123 --json" + ] + }' + else + cat <<'EOF' +## fizzy columns + +List columns on a board. + +### Usage + + fizzy columns [options] + +### Options + + --board, -b, --in Board name or ID (required unless set in config) + --help, -h Show this help + +### Examples + + fizzy columns --board "My Board" List columns on board + fizzy columns List columns on default board +EOF + fi +} + + +# fizzy column +# Manage columns (create, update, delete, show) + +cmd_column() { + case "${1:-}" in + create) + shift + _column_create "$@" + ;; + update) + shift + _column_update "$@" + ;; + delete) + shift + _column_delete "$@" + ;; + show) + shift + _column_show "$@" + ;; + left) + shift + _column_left "$@" + ;; + right) + shift + _column_right "$@" + ;; + --help|-h|"") + _column_help + ;; + *) + die "Unknown subcommand: column ${1:-}" $EXIT_USAGE \ + "Available: fizzy column create|update|delete|show|left|right" + ;; + esac +} + +_column_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column", + description: "Manage columns on a board", + subcommands: [ + {name: "create", description: "Create a column"}, + {name: "update", description: "Update a column"}, + {name: "delete", description: "Delete a column"}, + {name: "show", description: "Show column details"}, + {name: "left", description: "Move column left"}, + {name: "right", description: "Move column right"} + ], + examples: [ + "fizzy column create \"In Progress\" --board \"My Board\"", + "fizzy column update abc123 --board \"My Board\" --name \"Done\"", + "fizzy column delete abc123 --board \"My Board\"", + "fizzy column show abc123 --board \"My Board\"", + "fizzy column left abc123 --board \"My Board\"", + "fizzy column right abc123 --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy column + +Manage columns on a board. + +### Subcommands + + create Create a column + update Update a column + delete Delete a column + show Show column details + left Move column left (decrease position) + right Move column right (increase position) + +### Examples + + fizzy column create "In Progress" --board "My Board" + fizzy column update abc123 --board "My Board" --name "Done" + fizzy column delete abc123 --board "My Board" + fizzy column show abc123 --board "My Board" + fizzy column left "In Progress" --board "My Board" + fizzy column right "In Progress" --board "My Board" +EOF + fi +} + +_column_resolve_board() { + local board_input="$1" + + if [[ -z "$board_input" ]]; then + board_input=$(get_board_id 2>/dev/null || true) + fi + + if [[ -z "$board_input" ]]; then + die "No board specified" $EXIT_USAGE "Use: fizzy column --board " + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_input"); then + echo "$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi +} + +_column_create() { + local name="" + local color="" + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --color) + if [[ -z "${2:-}" ]]; then + die "--color requires a value" $EXIT_USAGE + fi + color="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column create --help" + ;; + *) + if [[ -z "$name" ]]; then + name="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_create_help + return 0 + fi + + if [[ -z "$name" ]]; then + die "Column name required" $EXIT_USAGE "Usage: fizzy column create \"name\" --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + local column_payload + column_payload=$(jq -n \ + --arg name "$name" \ + --arg color "$color" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $color != "" then {color: $color} else {} end) + )') + + local body + body=$(jq -n --argjson column "$column_payload" '{column: $column}') + + local response + response=$(api_post "/boards/$board_id/columns" "$body") + + local summary="Created column \"$name\"" + + local column_id + column_id=$(echo "$response" | jq -r '.id') + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "show" "fizzy column show $column_id --board $board_id" "View column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_md" +} + +_column_create_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column create", + description: "Create a column on a board", + usage: "fizzy column create \"name\" --board [options]", + options: [ + {flag: "--board, -b, --in", description: "Board name or ID"}, + {flag: "--name", description: "Column name (positional allowed)"}, + {flag: "--color", description: "Column color CSS variable"} + ], + examples: [ + "fizzy column create \"In Progress\" --board \"My Board\"", + "fizzy column create \"Done\" --board abc123 --color \"var(--color-card-4)\"" + ] + }' + else + cat <<'EOF' +## fizzy column create + +Create a column on a board. + +### Usage + + fizzy column create "name" --board [options] + +### Options + + --board, -b, --in Board name or ID + --name Column name (positional allowed) + --color Column color CSS variable + --help, -h Show this help + +### Examples + + fizzy column create "In Progress" --board "My Board" + fizzy column create "Done" --board abc123 --color "var(--color-card-4)" +EOF + fi +} + +_column_update() { + local column_id="" + local board_id="" + local name="" + local color="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --color) + if [[ -z "${2:-}" ]]; then + die "--color requires a value" $EXIT_USAGE + fi + color="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column update --help" + ;; + *) + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_update_help + return 0 + fi + + if [[ -z "$column_id" ]]; then + die "Column ID required" $EXIT_USAGE "Usage: fizzy column update --board [options]" + fi + + board_id=$(_column_resolve_board "$board_id") + + if [[ -z "$name" && -z "$color" ]]; then + die "Nothing to update. Specify --name or --color" $EXIT_USAGE + fi + + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" + fi + + local column_payload + column_payload=$(jq -n \ + --arg name "$name" \ + --arg color "$color" \ + '( + (if $name != "" then {name: $name} else {} end) + + (if $color != "" then {color: $color} else {} end) + )') + + local body + body=$(jq -n --argjson column "$column_payload" '{column: $column}') + + # PATCH returns 204 No Content + api_patch "/boards/$board_id/columns/$column_id" "$body" > /dev/null + local response + response=$(api_get "/boards/$board_id/columns/$column_id") + + local summary="Column updated" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "show" "fizzy column show $column_id --board $board_id" "View column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_md" +} + +_column_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column update", + description: "Update a column", + usage: "fizzy column update --board [options]", + options: [ + {flag: "--board, -b, --in", description: "Board name or ID"}, + {flag: "--name", description: "New column name"}, + {flag: "--color", description: "New column color"} + ], + examples: [ + "fizzy column update abc123 --board \"My Board\" --name \"Done\"", + "fizzy column update abc123 --board \"My Board\" --color \"var(--color-card-4)\"" + ] + }' + else + cat <<'EOF' +## fizzy column update + +Update a column. + +### Usage + + fizzy column update --board [options] + +### Options + + --board, -b, --in Board name or ID + --name New column name + --color New column color + --help, -h Show this help + +### Examples + + fizzy column update abc123 --board "My Board" --name "Done" + fizzy column update abc123 --board "My Board" --color "var(--color-card-4)" +EOF + fi +} + +_column_delete() { + local column_id="" + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column delete --help" + ;; + *) + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_delete_help + return 0 + fi + + if [[ -z "$column_id" ]]; then + die "Column ID required" $EXIT_USAGE "Usage: fizzy column delete --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" + fi + + # DELETE returns 204 No Content + api_delete "/boards/$board_id/columns/$column_id" > /dev/null + + local response + response=$(jq -n --arg id "$column_id" '{id: $id, deleted: true}') + + local summary="Column deleted" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "column" "fizzy column create \"name\" --board $board_id" "Create column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_deleted_md" +} + +_column_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local column_id + column_id=$(echo "$data" | jq -r '.id') + + md_heading 2 "Column Deleted" + echo + md_kv "ID" "$column_id" \ + "Status" "Deleted" + + md_breadcrumbs "$breadcrumbs" +} + +_column_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column delete", + description: "Delete a column", + usage: "fizzy column delete --board ", + examples: ["fizzy column delete abc123 --board \"My Board\""] + }' + else + cat <<'EOF' +## fizzy column delete + +Delete a column. + +### Usage + + fizzy column delete --board + +### Examples + + fizzy column delete abc123 --board "My Board" +EOF + fi +} + +_column_show() { + local column_id="" + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column show --help" + ;; + *) + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_show_help + return 0 + fi + + if [[ -z "$column_id" ]]; then + die "Column ID required" $EXIT_USAGE "Usage: fizzy column show --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" + fi + + local response + response=$(api_get "/boards/$board_id/columns/$column_id") + + local summary="Column on board" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "update" "fizzy column update $column_id --board $board_id" "Update column")" \ + "$(breadcrumb "delete" "fizzy column delete $column_id --board $board_id" "Delete column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_md" +} + +_column_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column show", + description: "Show column details", + usage: "fizzy column show --board ", + examples: ["fizzy column show abc123 --board \"My Board\""] + }' + else + cat <<'EOF' +## fizzy column show + +Show column details. + +### Usage + + fizzy column show --board + +### Examples + + fizzy column show abc123 --board "My Board" +EOF + fi +} + +_column_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local column_id name color created_at + column_id=$(echo "$data" | jq -r '.id') + name=$(echo "$data" | jq -r '.name') + color=$(echo "$data" | jq -r '.color // empty') + created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]') + + md_heading 2 "Column: $name" + echo + md_kv "ID" "$column_id" \ + "Color" "${color:-"(default)"}" \ + "Created" "$created_at" + + md_breadcrumbs "$breadcrumbs" +} + + +# fizzy column left --board +# Move a column left (decrease position) + +_column_left() { + local column_id="" + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column left --help" + ;; + *) + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_left_help + return 0 + fi + + if [[ -z "$column_id" ]]; then + die "Column ID or name required" $EXIT_USAGE "Usage: fizzy column left --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" + fi + + # POST to left_position endpoint + api_post "/columns/$column_id/left_position" > /dev/null + + # Fetch updated column + local response + response=$(api_get "/boards/$board_id/columns/$column_id") + + local column_name + column_name=$(echo "$response" | jq -r '.name // "unknown"') + + local summary="Moved column $column_name left" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "show" "fizzy column show $column_id --board $board_id" "View column")" \ + "$(breadcrumb "right" "fizzy column right $column_id --board $board_id" "Move right")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_md" +} + +_column_left_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column left", + description: "Move a column left (decrease position)", + usage: "fizzy column left --board ", + options: [ + {flag: "--board, -b", description: "Board containing the column (required)"} + ], + examples: [ + "fizzy column left abc123 --board \"My Board\"", + "fizzy column left \"In Progress\" --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy column left + +Move a column left (decrease position). + +### Usage + + fizzy column left --board + +### Options + + --board, -b Board containing the column (required) + +### Examples + + fizzy column left abc123 --board "My Board" + fizzy column left "In Progress" --board "My Board" +EOF + fi +} + + +# fizzy column right --board +# Move a column right (increase position) + +_column_right() { + local column_id="" + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy column right --help" + ;; + *) + if [[ -z "$column_id" ]]; then + column_id="$1" + shift + else + die "Unexpected argument: $1" $EXIT_USAGE + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _column_right_help + return 0 + fi + + if [[ -z "$column_id" ]]; then + die "Column ID or name required" $EXIT_USAGE "Usage: fizzy column right --board " + fi + + board_id=$(_column_resolve_board "$board_id") + + local resolved_column + if resolved_column=$(resolve_column_id "$column_id" "$board_id"); then + column_id="$resolved_column" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy columns --board $board_id" + fi + + # POST to right_position endpoint + api_post "/columns/$column_id/right_position" > /dev/null + + # Fetch updated column + local response + response=$(api_get "/boards/$board_id/columns/$column_id") + + local column_name + column_name=$(echo "$response" | jq -r '.name // "unknown"') + + local summary="Moved column $column_name right" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "show" "fizzy column show $column_id --board $board_id" "View column")" \ + "$(breadcrumb "left" "fizzy column left $column_id --board $board_id" "Move left")" + ) + + output "$response" "$summary" "$breadcrumbs" "_column_md" +} + +_column_right_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy column right", + description: "Move a column right (increase position)", + usage: "fizzy column right --board ", + options: [ + {flag: "--board, -b", description: "Board containing the column (required)"} + ], + examples: [ + "fizzy column right abc123 --board \"My Board\"", + "fizzy column right \"In Progress\" --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy column right + +Move a column right (increase position). + +### Usage + + fizzy column right --board + +### Options + + --board, -b Board containing the column (required) + +### Examples + + fizzy column right abc123 --board "My Board" + fizzy column right "In Progress" --board "My Board" +EOF + fi +} diff --git a/cli/lib/commands/cards.sh b/cli/lib/commands/cards.sh new file mode 100644 index 0000000000..63742ba211 --- /dev/null +++ b/cli/lib/commands/cards.sh @@ -0,0 +1,342 @@ +#!/usr/bin/env bash +# cards.sh - Card query commands + + +# fizzy cards [options] +# List and filter cards + +cmd_cards() { + local board_id="" + local tag_id="" + local assignee_id="" + local status="" + local search_terms="" + local sort_by="latest" + local page="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --tag) + if [[ -z "${2:-}" ]]; then + die "--tag requires a value" $EXIT_USAGE + fi + tag_id="$2" + shift 2 + ;; + --assignee) + if [[ -z "${2:-}" ]]; then + die "--assignee requires a value" $EXIT_USAGE + fi + assignee_id="$2" + shift 2 + ;; + --status) + if [[ -z "${2:-}" ]]; then + die "--status requires a value" $EXIT_USAGE + fi + status="$2" + shift 2 + ;; + --search|-s) + if [[ -z "${2:-}" ]]; then + die "--search requires a value" $EXIT_USAGE + fi + search_terms="$2" + shift 2 + ;; + --sort) + if [[ -z "${2:-}" ]]; then + die "--sort requires a value" $EXIT_USAGE + fi + sort_by="$2" + shift 2 + ;; + --page|-p) + if [[ -z "${2:-}" ]]; then + die "--page requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[1-9][0-9]*$ ]]; then + die "--page must be a positive integer" $EXIT_USAGE + fi + page="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy cards --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _cards_help + return 0 + fi + + # Build query parameters + local path="/cards" + local params=() + + # Use provided board_id or fall back to config + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + + # Resolve board name to ID if needed + if [[ -n "$board_id" ]]; then + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + params+=("board_ids[]=$board_id") + fi + + # Resolve tag name to ID if needed + if [[ -n "$tag_id" ]]; then + local resolved_tag + if resolved_tag=$(resolve_tag_id "$tag_id"); then + tag_id="$resolved_tag" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy tags" + fi + params+=("tag_ids[]=$tag_id") + fi + + # Resolve assignee name/email to ID if needed + if [[ -n "$assignee_id" ]]; then + local resolved_user + if resolved_user=$(resolve_user_id "$assignee_id"); then + assignee_id="$resolved_user" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy people" + fi + params+=("assignee_ids[]=$assignee_id") + fi + + if [[ -n "$status" ]]; then + params+=("indexed_by=$status") + fi + + if [[ -n "$search_terms" ]]; then + # Split search terms by space and add each as a separate terms[] param + local term + for term in $search_terms; do + local encoded_term + encoded_term=$(urlencode "$term") + params+=("terms[]=$encoded_term") + done + fi + + if [[ -n "$sort_by" ]]; then + params+=("sorted_by=$sort_by") + fi + + if [[ -n "$page" ]]; then + params+=("page=$page") + fi + + # Build query string + if [[ ${#params[@]} -gt 0 ]]; then + local query_string + query_string=$(IFS='&'; echo "${params[*]}") + path="$path?$query_string" + fi + + local response + response=$(api_get "$path") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count cards" + [[ -n "$page" ]] && summary="$count cards (page $page)" + + # Build next page command for breadcrumbs (preserve all filters) + local next_page_cmd="fizzy cards" + local next_page=$((${page:-1} + 1)) + [[ -n "$board_id" ]] && next_page_cmd="$next_page_cmd --board $board_id" + [[ -n "$tag_id" ]] && next_page_cmd="$next_page_cmd --tag $tag_id" + [[ -n "$assignee_id" ]] && next_page_cmd="$next_page_cmd --assignee $assignee_id" + [[ -n "$status" ]] && next_page_cmd="$next_page_cmd --status $status" + [[ -n "$search_terms" ]] && next_page_cmd="$next_page_cmd --search \"$search_terms\"" + [[ "$sort_by" != "latest" ]] && next_page_cmd="$next_page_cmd --sort $sort_by" + next_page_cmd="$next_page_cmd --page $next_page" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show " "View card details")" \ + "$(breadcrumb "create" "fizzy card \"title\"" "Create new card")" \ + "$(breadcrumb "next" "$next_page_cmd" "Next page")" \ + "$(breadcrumb "search" "fizzy search \"query\"" "Search cards")" + ) + + output "$response" "$summary" "$breadcrumbs" "_cards_md" +} + +_cards_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Cards ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No cards found." + echo + else + echo "| # | Title | Board | Status | Tags |" + echo "|---|-------|-------|--------|------|" + echo "$data" | jq -r '.[] | "| \(.number) | \(.title // .description[0:40]) | \(.board.name) | \(if .closed then "Closed" elif .column then .column.name else "Triage" end) | \(.tags | join(", ")) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_cards_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy cards", + description: "List and filter cards", + options: [ + {flag: "--board, -b, --in", description: "Filter by board name or ID"}, + {flag: "--tag", description: "Filter by tag name or ID"}, + {flag: "--assignee", description: "Filter by assignee name, email, or ID"}, + {flag: "--status", description: "Filter by status: all, closed, not_now, stalled, golden, postponing_soon"}, + {flag: "--search, -s", description: "Search terms"}, + {flag: "--sort", description: "Sort order: latest, newest, oldest"}, + {flag: "--page, -p", description: "Page number for pagination"} + ], + examples: [ + "fizzy cards", + "fizzy cards --board \"My Board\"", + "fizzy cards --assignee \"Jane Doe\"", + "fizzy cards --search \"bug fix\"", + "fizzy cards --status closed" + ] + }' + else + cat <<'EOF' +## fizzy cards + +List and filter cards. + +### Usage + + fizzy cards [options] + +### Options + + --board, -b, --in Filter by board name or ID + --tag Filter by tag name or ID + --assignee Filter by assignee name, email, or ID + --status Filter: all, closed, not_now, stalled, golden, postponing_soon + --search, -s Search terms + --sort Sort: latest (default), newest, oldest + --page, -p Page number for pagination + --help, -h Show this help + +### Examples + + fizzy cards List all cards + fizzy cards --board "My Board" Filter by board name + fizzy cards --assignee "Jane" Filter by assignee name + fizzy cards --search "bug" Search for cards + fizzy cards --status closed Show closed cards +EOF + fi +} + + +# Show card details (called by cmd_show) +show_card() { + local card_number="$1" + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy show " + fi + + local response + response=$(api_get "/cards/$card_number") + + local title + title=$(echo "$response" | jq -r '.title // .description[0:50]') + local summary="Card #$card_number: $title" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "close" "fizzy close $card_number" "Close this card")" \ + "$(breadcrumb "comment" "fizzy comment \"text\" --on $card_number" "Add comment")" \ + "$(breadcrumb "assign" "fizzy assign $card_number --to " "Assign user")" \ + "$(breadcrumb "triage" "fizzy triage $card_number --to " "Move to column")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_card_md" +} + +_show_card_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local number title board_name column_name status creator_name created_at + local description tags golden assignees + + number=$(echo "$data" | jq -r '.number') + title=$(echo "$data" | jq -r '.title // ""') + board_name=$(echo "$data" | jq -r '.board.name') + column_name=$(echo "$data" | jq -r '.column.name // "Triage"') + status=$(echo "$data" | jq -r 'if .closed then "Closed" else "Active" end') + creator_name=$(echo "$data" | jq -r '.creator.name') + created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]') + description=$(echo "$data" | jq -r '.description // ""') + tags=$(echo "$data" | jq -r '.tags | join(", ")') + golden=$(echo "$data" | jq -r 'if .golden then "Yes" else "No" end') + + md_heading 2 "Card #$number${title:+: $title}" + + md_kv "Board" "$board_name" \ + "Column" "$column_name" \ + "Status" "$status" \ + "Golden" "$golden" \ + "Tags" "${tags:-None}" \ + "Created" "$created_at by $creator_name" + + if [[ -n "$description" ]]; then + echo "### Description" + echo + echo "$description" + echo + fi + + # Show steps if present + local steps_count + steps_count=$(echo "$data" | jq '.steps | length // 0') + if [[ "$steps_count" -gt 0 ]]; then + echo "### Steps ($steps_count)" + echo + echo "$data" | jq -r '.steps[] | "- [\(if .completed then "x" else " " end)] \(.content)"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} diff --git a/cli/lib/commands/comments.sh b/cli/lib/commands/comments.sh new file mode 100644 index 0000000000..4c039f748d --- /dev/null +++ b/cli/lib/commands/comments.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# comments.sh - Comment query and action commands + + +# fizzy comments [options] +# List comments on a card + +cmd_comments() { + local card_number="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --on) + if [[ -z "${2:-}" ]]; then + die "--on requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + # First positional arg could be card number + if [[ -z "$card_number" ]] && [[ "$1" =~ ^[0-9]+$ ]]; then + card_number="$1" + shift + else + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy comments --help" + fi + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _comments_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "Card number required" $EXIT_USAGE "Usage: fizzy comments --on " + fi + + local response + response=$(api_get "/cards/$card_number/comments") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count comments on card #$card_number" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "add" "fizzy comment \"text\" --on $card_number" "Add comment")" \ + "$(breadcrumb "react" "fizzy react \"👍\" --on " "Add reaction")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_comments_md" +} + +_comments_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Comments" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No comments yet." + echo + else + echo "| ID | Author | Date | Comment |" + echo "|----|--------|------|---------|" + echo "$data" | jq -r '.[] | "| \(.id[0:12])... | \(.creator.name) | \(.created_at | split("T")[0]) | \((.body.plain_text // "(no text)")[0:40] | gsub("\n"; " "))... |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_comments_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy comments", + description: "List comments on a card", + usage: "fizzy comments --on ", + options: [ + {flag: "--on", description: "Card number to list comments for"} + ], + examples: [ + "fizzy comments --on 42", + "fizzy comments 42" + ] + }' + else + cat <<'EOF' +## fizzy comments + +List comments on a card. + +### Usage + + fizzy comments --on + fizzy comments + +### Options + + --on Card number to list comments for + --help, -h Show this help + +### Examples + + fizzy comments --on 42 List comments on card #42 + fizzy comments 42 List comments on card #42 +EOF + fi +} + + +# fizzy reactions --card --comment +# List reactions on a comment + +cmd_reactions() { + local card_number="" + local comment_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --card) + if [[ -z "${2:-}" ]]; then + die "--card requires a card number" $EXIT_USAGE + fi + card_number="$2" + shift 2 + ;; + --comment) + if [[ -z "${2:-}" ]]; then + die "--comment requires a comment ID" $EXIT_USAGE + fi + comment_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy reactions --help" + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _reactions_help + return 0 + fi + + if [[ -z "$card_number" ]]; then + die "--card number required" $EXIT_USAGE "Usage: fizzy reactions --card --comment " + fi + + if [[ -z "$comment_id" ]]; then + die "--comment ID required" $EXIT_USAGE "Usage: fizzy reactions --card --comment " + fi + + local response + response=$(api_get "/cards/$card_number/comments/$comment_id/reactions") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count reactions on comment" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "react" "fizzy react \"👍\" --card $card_number --comment $comment_id" "Add reaction")" \ + "$(breadcrumb "comments" "fizzy comments --on $card_number" "View comments")" \ + "$(breadcrumb "show" "fizzy show $card_number" "View card")" + ) + + output "$response" "$summary" "$breadcrumbs" "_reactions_md" +} + +_reactions_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Reactions" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No reactions yet." + echo + else + echo "| ID | Emoji | By |" + echo "|----|-------|----|" + echo "$data" | jq -r '.[] | "| \(.id[0:12])... | \(.content) | \(.reacter.name) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_reactions_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy reactions", + description: "List reactions on a comment", + usage: "fizzy reactions --card --comment ", + options: [ + {flag: "--card", description: "Card number"}, + {flag: "--comment", description: "Comment ID"} + ], + examples: [ + "fizzy reactions --card 123 --comment abc456" + ] + }' + else + cat <<'EOF' +## fizzy reactions + +List reactions on a comment. + +### Usage + + fizzy reactions --card --comment + +### Options + + --card Card number (required) + --comment Comment ID (required) + --help, -h Show this help + +### Examples + + fizzy reactions --card 123 --comment abc456 +EOF + fi +} diff --git a/cli/lib/commands/config.sh b/cli/lib/commands/config.sh new file mode 100644 index 0000000000..d85bce5cd0 --- /dev/null +++ b/cli/lib/commands/config.sh @@ -0,0 +1,407 @@ +#!/usr/bin/env bash +# config.sh - Configuration management commands + + +# fizzy config [subcommand] [options] +# Manage configuration settings + +cmd_config() { + local subcommand="" + local show_help=false + + if [[ $# -eq 0 ]]; then + _config_list + return + fi + + case "$1" in + --help|-h) + _config_help + return + ;; + list) + shift + _config_list "$@" + ;; + get) + shift + _config_get "$@" + ;; + set) + shift + _config_set "$@" + ;; + unset) + shift + _config_unset "$@" + ;; + path) + shift + _config_path "$@" + ;; + *) + die "Unknown subcommand: $1" $EXIT_USAGE "Run: fizzy config --help" + ;; + esac +} + + +_config_list() { + local show_sources=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --sources) + show_sources=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy config --help" + ;; + esac + done + + local config_data + config_data=$(get_effective_config) + + local summary="Configuration" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "get" "fizzy config get " "Get specific value")" \ + "$(breadcrumb "set" "fizzy config set " "Set a value")" \ + "$(breadcrumb "path" "fizzy config path" "Show config file paths")" + ) + + if [[ "$show_sources" == "true" ]]; then + # Build JSON with sources + local result='{}' + for key in $(echo "$config_data" | jq -r 'keys[]'); do + local value source + value=$(echo "$config_data" | jq -r --arg k "$key" '.[$k]') + source=$(get_config_source "$key") + result=$(echo "$result" | jq --arg k "$key" --arg v "$value" --arg s "$source" \ + '.[$k] = {value: $v, source: $s}') + done + config_data="$result" + fi + + # Build context with show_sources flag for markdown renderer + local context="{}" + if [[ "$show_sources" == "true" ]]; then + context='{"show_sources": true}' + fi + + output "$config_data" "$summary" "$breadcrumbs" "_config_list_md" "$context" +} + +_config_list_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + # Note: Use quoted default to avoid bash parsing issue with closing braces + local context="${4:-"{}"}" + + local show_sources + show_sources=$(echo "$context" | jq -r '.show_sources // false') + + md_heading 2 "Configuration" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No configuration set." + echo + elif [[ "$show_sources" == "true" ]]; then + echo "| Key | Value | Source |" + echo "|-----|-------|--------|" + echo "$data" | jq -r 'to_entries | .[] | "| \(.key) | \(.value.value) | \(.value.source) |"' + echo + else + echo "| Key | Value |" + echo "|-----|-------|" + echo "$data" | jq -r 'to_entries | .[] | "| \(.key) | \(.value) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + + +_config_get() { + if [[ $# -eq 0 ]]; then + die "Key required" $EXIT_USAGE "Usage: fizzy config get " + fi + + local key="$1" + local show_source=false + shift + + while [[ $# -gt 0 ]]; do + case "$1" in + --source) + show_source=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy config --help" + ;; + esac + done + + local value + value=$(get_config "$key") + + if [[ -z "$value" ]]; then + die "Key not found: $key" $EXIT_NOT_FOUND "Run: fizzy config list" + fi + + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + if [[ "$show_source" == "true" ]]; then + local source + source=$(get_config_source "$key") + jq -n --arg k "$key" --arg v "$value" --arg s "$source" \ + '{key: $k, value: $v, source: $s}' + else + jq -n --arg k "$key" --arg v "$value" '{key: $k, value: $v}' + fi + else + if [[ "$show_source" == "true" ]]; then + local source + source=$(get_config_source "$key") + echo "$value # from $source" + else + echo "$value" + fi + fi +} + + +_config_set() { + local scope="--local" + local key="" + local value="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --global|-g) + scope="--global" + shift + ;; + --local|-l) + scope="--local" + shift + ;; + *) + if [[ -z "$key" ]]; then + key="$1" + elif [[ -z "$value" ]]; then + value="$1" + else + die "Too many arguments" $EXIT_USAGE "Usage: fizzy config set [--global|--local] " + fi + shift + ;; + esac + done + + if [[ -z "$key" ]]; then + die "Key required" $EXIT_USAGE "Usage: fizzy config set [--global|--local] " + fi + + if [[ -z "$value" ]]; then + die "Value required" $EXIT_USAGE "Usage: fizzy config set [--global|--local] " + fi + + if [[ "$scope" == "--global" ]]; then + set_global_config "$key" "$value" + info "Set $key = $value (global)" + else + set_local_config "$key" "$value" + info "Set $key = $value (local)" + fi +} + + +_config_unset() { + local scope="--local" + local key="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --global|-g) + scope="--global" + shift + ;; + --local|-l) + scope="--local" + shift + ;; + *) + if [[ -z "$key" ]]; then + key="$1" + else + die "Too many arguments" $EXIT_USAGE "Usage: fizzy config unset [--global|--local] " + fi + shift + ;; + esac + done + + if [[ -z "$key" ]]; then + die "Key required" $EXIT_USAGE "Usage: fizzy config unset [--global|--local] " + fi + + unset_config "$key" "$scope" + + local scope_name="local" + [[ "$scope" == "--global" ]] && scope_name="global" + info "Unset $key ($scope_name)" +} + + +_config_path() { + local format + format=$(get_format) + + local git_root + git_root=$(get_git_root) + + local paths + paths=$(jq -n \ + --arg system "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" \ + --arg global "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" \ + --arg repo "${git_root:+$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE}" \ + --arg local "$PWD/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" \ + --arg credentials "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CREDENTIALS_FILE" \ + '{ + system: $system, + global: $global, + repo: (if $repo == "" then null else $repo end), + local: $local, + credentials: $credentials + }') + + if [[ "$format" == "json" ]]; then + echo "$paths" + else + md_heading 2 "Config Paths" + echo + echo "| Scope | Path | Exists |" + echo "|-------|------|--------|" + + local system_path global_path repo_path local_path creds_path + system_path="$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" + global_path="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + repo_path="${git_root:+$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE}" + local_path="$PWD/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + creds_path="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CREDENTIALS_FILE" + + _config_path_row "system" "$system_path" + _config_path_row "global" "$global_path" + [[ -n "$repo_path" ]] && _config_path_row "repo" "$repo_path" + _config_path_row "local" "$local_path" + _config_path_row "credentials" "$creds_path" + echo + fi +} + +_config_path_row() { + local scope="$1" + local path="$2" + local exists="No" + [[ -f "$path" ]] && exists="Yes" + echo "| $scope | $path | $exists |" +} + + +_config_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy config", + description: "Manage configuration settings", + subcommands: [ + {name: "list", description: "List all configuration (default)"}, + {name: "get", description: "Get a configuration value"}, + {name: "set", description: "Set a configuration value"}, + {name: "unset", description: "Remove a configuration value"}, + {name: "path", description: "Show configuration file paths"} + ], + options: [ + {flag: "--global, -g", description: "Use global (~/.config/fizzy/) config"}, + {flag: "--local, -l", description: "Use local (.fizzy/) config (default)"}, + {flag: "--sources", description: "Show where each value comes from (list)"}, + {flag: "--source", description: "Show source for value (get)"} + ], + keys: [ + {name: "account_slug", description: "Default account ID"}, + {name: "board_id", description: "Default board ID"}, + {name: "column_id", description: "Default column ID"}, + {name: "base_url", description: "Fizzy API base URL"} + ], + examples: [ + "fizzy config", + "fizzy config list --sources", + "fizzy config get account_slug", + "fizzy config set account_slug 897362094", + "fizzy config set --global account_slug 897362094", + "fizzy config unset board_id", + "fizzy config path" + ] + }' + else + cat <<'EOF' +## fizzy config + +Manage configuration settings. + +### Usage + + fizzy config List all configuration + fizzy config list [--sources] List config (optionally with sources) + fizzy config get Get a value + fizzy config set Set a value + fizzy config unset Remove a value + fizzy config path Show config file paths + +### Options + + --global, -g Use global (~/.config/fizzy/) config + --local, -l Use local (.fizzy/) config (default) + --sources Show where each value comes from + --source Show source for a specific value + +### Configuration Keys + + account_slug Default account ID + board_id Default board ID + column_id Default column ID + base_url Fizzy API base URL + +### Config Hierarchy (later overrides earlier) + + 1. /etc/fizzy/config.json System-wide + 2. ~/.config/fizzy/config.json User/global + 3. /.fizzy/config.json Repository + 4. /.fizzy/config.json Local + 5. Environment variables FIZZY_ACCOUNT_SLUG, etc. + 6. Command-line flags --account, --board + +### Examples + + fizzy config List all config + fizzy config list --sources Show value sources + fizzy config get account_slug Get account + fizzy config set account_slug 897362094 + fizzy config set --global base_url https://fizzy.example.com + fizzy config unset board_id +EOF + fi +} diff --git a/cli/lib/commands/help.sh b/cli/lib/commands/help.sh new file mode 100644 index 0000000000..dfbe8e648f --- /dev/null +++ b/cli/lib/commands/help.sh @@ -0,0 +1,390 @@ +#!/usr/bin/env bash +# help.sh - Help and quick-start commands + + +cmd_help() { + local topic="${1:-}" + local format + format=$(get_format) + + if [[ -n "$topic" ]]; then + _help_topic "$topic" + return + fi + + if [[ "$format" == "json" ]]; then + _help_json + else + _help_md + fi +} + +cmd_quick_start() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + _quick_start_json + else + _quick_start_md + fi +} + +_help_topic() { + local topic="$1" + local format + format=$(get_format) + + case "$topic" in + auth) _help_auth ;; + config) _help_config ;; + cards) _help_cards ;; + boards) _help_boards ;; + board) _board_help ;; + column) _column_help ;; + columns) _columns_help ;; + identity) _identity_help ;; + user) _user_help ;; + account) _account_help ;; + webhook) _webhook_help ;; + webhooks) _webhooks_list_help ;; + *) + if [[ "$format" == "json" ]]; then + json_error "Unknown help topic: $topic" "not_found" + else + echo "Unknown help topic: $topic" + echo + echo "Available topics: auth, config, cards, boards, board, column, columns, identity, user, account, webhook, webhooks" + fi + exit 1 + ;; + esac +} + +_help_json() { + jq -n \ + --arg version "$FIZZY_VERSION" \ + '{ + name: "fizzy", + version: $version, + description: "Agent-first CLI for Fizzy", + commands: { + query: ["boards", "cards", "columns", "comments", "reactions", "notifications", "people", "search", "show", "tags", "webhooks"], + actions: ["card", "board", "column", "close", "reopen", "triage", "untriage", "postpone", "comment", "assign", "tag", "watch", "unwatch", "gild", "ungild", "step", "react", "webhook"], + identity: ["identity", "user", "account"], + meta: ["auth", "config", "version", "help"] + }, + global_flags: [ + {"flag": "--json/-j", "description": "Force JSON output"}, + {"flag": "--md/-m", "description": "Force markdown output"}, + {"flag": "--quiet/-q", "description": "Suppress non-essential output"}, + {"flag": "--verbose/-v", "description": "Debug output"}, + {"flag": "--board/-b/--in", "description": "Board ID or name"}, + {"flag": "--account/-a", "description": "Account slug"} + ] + }' +} + +_help_md() { + cat <<'EOF' +# fizzy + +Agent-first CLI for Fizzy. + +## USAGE + + fizzy [global-flags] [command-flags] [arguments] + +## GLOBAL FLAGS + + --json, -j Force JSON output + --md, -m Force markdown output + --quiet, -q Suppress non-essential output (data only) + --verbose, -v Debug output + --board, -b, --in Board ID or name + --account, -a Account slug + +## COMMANDS + +### Query + boards List boards + cards List or filter cards + columns List columns on a board + comments List comments on a card + reactions List reactions on a comment + notifications List notifications + people List users in account + search Search cards + show Show card details + tags List tags + webhooks List webhooks for a board + +### Actions + card Manage cards (create/update/delete/image/move/publish) + board Manage boards (create/update/delete/show/publish/entropy) + column Manage columns (create/update/delete/show/left/right) + close Close a card + reopen Reopen a closed card + triage Move card into a column + untriage Send card back to triage + postpone Move card to "Not Now" + comment Manage comments (add/edit/delete) + assign Assign/unassign user to card + tag Toggle tag on card + watch Subscribe to card notifications + unwatch Unsubscribe from card + gild Mark card as golden + ungild Remove golden status + step Manage steps (add/show/update/delete) + react Manage reactions (add/delete) + webhook Manage webhooks (create/show/update/delete) + +### Identity & Account + identity Show current identity and accounts + user Manage users (show/update/delete/role) + account Manage account (show/update/entropy/join-code/export) + +### Meta + auth Authentication management + config Configuration management + version Show version + help Show this help + +## EXAMPLES + + fizzy boards List all boards + fizzy cards --board Fizzy List cards on Fizzy board + fizzy show 42 Show card #42 + fizzy card "New feature" Create a new card + fizzy close 42 Close card #42 + fizzy comment "LGTM" --on 42 Comment on card #42 + +## CONFIGURATION + + fizzy config list Show current configuration + fizzy config set key value Set a config value + fizzy config get key Get a config value + +Run `fizzy help ` for command-specific help. +EOF +} + +_quick_start_json() { + local auth_status="unauthenticated" + local account_slug="" + local board_id="" + + if get_access_token &>/dev/null; then + auth_status="authenticated" + account_slug=$(get_account_slug 2>/dev/null || true) + board_id=$(get_board_id 2>/dev/null || true) + fi + + jq -n \ + --arg version "$FIZZY_VERSION" \ + --arg auth "$auth_status" \ + --arg account "$account_slug" \ + --arg board "$board_id" \ + '{ + version: $version, + auth: $auth, + context: { + account: (if $account != "" then $account else null end), + board: (if $board != "" then $board else null end) + }, + next_steps: ( + if $auth == "unauthenticated" then + ["Run: fizzy auth login"] + elif $account == "" then + ["Run: fizzy config set account_slug "] + else + ["fizzy boards", "fizzy cards", "fizzy help"] + end + ) + }' +} + +_quick_start_md() { + echo "# fizzy $FIZZY_VERSION" + echo + + if ! get_access_token &>/dev/null; then + echo "## Getting Started" + echo + echo "Not authenticated. Run:" + echo + echo " fizzy auth login" + echo + return + fi + + local account_slug + account_slug=$(get_account_slug 2>/dev/null || true) + + if [[ -z "$account_slug" ]]; then + echo "## Setup Required" + echo + echo "Authenticated but no account configured." + echo + echo " fizzy config set account_slug " + echo + return + fi + + echo "## Ready" + echo + echo "Account: $account_slug" + + local board_id + board_id=$(get_board_id 2>/dev/null || true) + if [[ -n "$board_id" ]]; then + echo "Board: $board_id" + fi + echo + + echo "### Quick Commands" + echo + echo " fizzy boards List boards" + echo " fizzy cards List cards" + echo " fizzy show Show card details" + echo " fizzy help Full command list" + echo +} + + +_help_config() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy config", + description: "Manage configuration", + subcommands: [ + {name: "list", description: "Show current configuration"}, + {name: "get", args: ["key"], description: "Get a config value"}, + {name: "set", args: ["key", "value"], description: "Set a config value"}, + {name: "unset", args: ["key"], description: "Remove a config value"} + ], + options: [ + {flag: "--global", description: "Use global (user) config"}, + {flag: "--local", description: "Use local (.fizzy/) config"} + ], + config_keys: [ + "account_slug", + "board_id", + "column_id", + "base_url" + ] + }' + else + cat <<'EOF' +## fizzy config + +Manage configuration. + +### Subcommands + +- `list` - Show current configuration +- `get` - Get a config value +- `set` - Set a config value +- `unset` - Remove a config value + +### Options + +- `--global` - Use global (user) config +- `--local` - Use local (.fizzy/) config + +### Config Keys + +- `account_slug` - Fizzy account slug +- `board_id` - Default board ID +- `column_id` - Default column ID +- `base_url` - Fizzy server URL + +### Examples + +```bash +# Show current config +fizzy config list + +# Set account globally +fizzy config set --global account_slug 897362094 + +# Set board for this project +fizzy config set board_id abc123 +``` +EOF + fi +} + +_help_cards() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy cards", + description: "List and filter cards", + options: [ + {flag: "--board", description: "Filter by board ID or name"}, + {flag: "--search", description: "Search terms"}, + {flag: "--tag", description: "Filter by tag"}, + {flag: "--assignee", description: "Filter by assignee"}, + {flag: "--status", description: "Filter by status (all, closed, not_now, golden)"} + ] + }' + else + cat <<'EOF' +## fizzy cards + +List and filter cards. + +### Options + +- `--board` - Filter by board ID or name +- `--search` - Search terms +- `--tag` - Filter by tag +- `--assignee` - Filter by assignee +- `--status` - Filter by status (all, closed, not_now, golden) + +### Examples + +```bash +# List all cards +fizzy cards + +# Search cards +fizzy cards --search "bug fix" + +# Filter by board +fizzy cards --board Fizzy +``` +EOF + fi +} + +_help_boards() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy boards", + description: "List boards in the account" + }' + else + cat <<'EOF' +## fizzy boards + +List boards in the account. + +### Examples + +```bash +# List all boards +fizzy boards +``` +EOF + fi +} diff --git a/cli/lib/commands/notifications.sh b/cli/lib/commands/notifications.sh new file mode 100644 index 0000000000..3b08c2cc1f --- /dev/null +++ b/cli/lib/commands/notifications.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# notifications.sh - Notification query and action commands + + +# fizzy notifications [options] +# List notifications + +cmd_notifications() { + local action="" + local show_help=false + local page="" + local fetch_all=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --all|-a) + fetch_all=true + shift + ;; + read|unread) + action="$1" + shift + _notifications_action "$action" "$@" + return + ;; + --page|-p) + if [[ -z "${2:-}" ]]; then + die "--page requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -lt 1 ]]; then + die "--page must be a positive integer" $EXIT_USAGE + fi + page="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy notifications --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _notifications_help + return 0 + fi + + local response + if [[ "$fetch_all" == "true" ]]; then + response=$(api_get_all "/notifications") + else + local path="/notifications" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + response=$(api_get "$path") + fi + + local count unread_count + count=$(echo "$response" | jq 'length') + unread_count=$(echo "$response" | jq '[.[] | select(.read == false)] | length') + + local summary="$count notifications ($unread_count unread)" + [[ -n "$page" ]] && summary="$count notifications ($unread_count unread, page $page)" + [[ "$fetch_all" == "true" ]] && summary="$count notifications ($unread_count unread, all)" + + local next_page=$((${page:-1} + 1)) + local breadcrumbs + if [[ "$fetch_all" == "true" ]]; then + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "read" "fizzy notifications read " "Mark as read")" \ + "$(breadcrumb "read-all" "fizzy notifications read --all" "Mark all as read")" \ + "$(breadcrumb "show" "fizzy show " "View card")" + ) + else + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "read" "fizzy notifications read " "Mark as read")" \ + "$(breadcrumb "read-all" "fizzy notifications read --all" "Mark all as read")" \ + "$(breadcrumb "show" "fizzy show " "View card")" \ + "$(breadcrumb "next" "fizzy notifications --page $next_page" "Next page")" + ) + fi + + output "$response" "$summary" "$breadcrumbs" "_notifications_md" +} + +_notifications_action() { + local action="$1" + shift + + local notification_id="" + local all=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --all) + all=true + shift + ;; + *) + if [[ -z "$notification_id" ]]; then + notification_id="$1" + fi + shift + ;; + esac + done + + if [[ "$action" == "read" ]]; then + if [[ "$all" == "true" ]]; then + api_post "/notifications/bulk_reading" + info "All notifications marked as read" + elif [[ -n "$notification_id" ]]; then + api_post "/notifications/$notification_id/reading" + info "Notification marked as read" + else + die "Notification ID required" $EXIT_USAGE "Usage: fizzy notifications read or --all" + fi + elif [[ "$action" == "unread" ]]; then + if [[ -z "$notification_id" ]]; then + die "Notification ID required" $EXIT_USAGE "Usage: fizzy notifications unread " + fi + api_delete "/notifications/$notification_id/reading" + info "Notification marked as unread" + fi +} + +_notifications_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Notifications" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No notifications." + echo + else + echo "| Status | Card | Title | From | Date |" + echo "|--------|------|-------|------|------|" + echo "$data" | jq -r '.[] | "| \(if .read then "Read" else "**Unread**" end) | #\(.card.id[0:8]) | \(.title[0:30]) | \(.creator.name) | \(.created_at | split("T")[0]) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_notifications_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy notifications", + description: "List and manage notifications", + subcommands: [ + {name: "read", description: "Mark notification as read"}, + {name: "unread", description: "Mark notification as unread"} + ], + options: [ + {flag: "--all, -a", description: "Fetch all pages"}, + {flag: "--page, -p", description: "Page number for pagination"}, + {flag: "--all (with read)", description: "Mark all notifications as read"} + ], + examples: [ + "fizzy notifications", + "fizzy notifications --all", + "fizzy notifications --page 2", + "fizzy notifications read abc123", + "fizzy notifications read --all" + ] + }' + else + cat <<'EOF' +## fizzy notifications + +List and manage notifications. + +### Usage + + fizzy notifications List notifications + fizzy notifications --all Fetch all pages + fizzy notifications --page 2 Get second page + fizzy notifications read Mark as read + fizzy notifications read --all Mark all as read + fizzy notifications unread Mark as unread + +### Options + + --all, -a Fetch all pages + --page, -p Page number for pagination + +### Examples + + fizzy notifications List notifications (first page) + fizzy notifications --all Fetch all notifications + fizzy notifications --page 2 Get second page + fizzy notifications read abc123 Mark notification as read + fizzy notifications read --all Mark all as read +EOF + fi +} diff --git a/cli/lib/commands/people.sh b/cli/lib/commands/people.sh new file mode 100644 index 0000000000..f0413f3cb2 --- /dev/null +++ b/cli/lib/commands/people.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# people.sh - User query commands + + +# fizzy people [options] +# List users in the account + +cmd_people() { + local show_help=false + local page="" + local fetch_all=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --all|-a) + fetch_all=true + shift + ;; + --page|-p) + if [[ -z "${2:-}" ]]; then + die "--page requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -lt 1 ]]; then + die "--page must be a positive integer" $EXIT_USAGE + fi + page="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy people --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _people_help + return 0 + fi + + local response + if [[ "$fetch_all" == "true" ]]; then + response=$(api_get_all "/users") + else + local path="/users" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + response=$(api_get "$path") + fi + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count users" + [[ -n "$page" ]] && summary="$count users (page $page)" + [[ "$fetch_all" == "true" ]] && summary="$count users (all)" + + local next_page=$((${page:-1} + 1)) + local breadcrumbs + if [[ "$fetch_all" == "true" ]]; then + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "assign" "fizzy assign --to " "Assign user to card")" \ + "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" + ) + else + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "assign" "fizzy assign --to " "Assign user to card")" \ + "$(breadcrumb "cards" "fizzy cards --assignee " "Cards assigned to user")" \ + "$(breadcrumb "next" "fizzy people --page $next_page" "Next page")" + ) + fi + + output "$response" "$summary" "$breadcrumbs" "_people_md" +} + +_people_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "People ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No users found." + echo + else + echo "| ID | Name | Email | Role |" + echo "|----|------|-------|------|" + echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(.email_address) | \(.role) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_people_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy people", + description: "List users in the account", + options: [ + {flag: "--all, -a", description: "Fetch all pages"}, + {flag: "--page, -p", description: "Page number for pagination"} + ], + examples: [ + "fizzy people", + "fizzy people --all", + "fizzy people --page 2" + ] + }' + else + cat <<'EOF' +## fizzy people + +List users in the account. + +### Usage + + fizzy people [options] + +### Options + + --all, -a Fetch all pages + --page, -p Page number for pagination + --help, -h Show this help + +### Examples + + fizzy people List users (first page) + fizzy people --all Fetch all users + fizzy people --page 2 Get second page +EOF + fi +} diff --git a/cli/lib/commands/search.sh b/cli/lib/commands/search.sh new file mode 100644 index 0000000000..0b7281cc20 --- /dev/null +++ b/cli/lib/commands/search.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# search.sh - Search commands + + +# fizzy search [options] +# Search cards + +cmd_search() { + local query="" + local board_id="" + local show_help=false + + # First non-flag argument is the query + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy search --help" + ;; + *) + if [[ -z "$query" ]]; then + query="$1" + else + # Append additional words to query + query="$query $1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _search_help + return 0 + fi + + if [[ -z "$query" ]]; then + die "Search query required" $EXIT_USAGE "Usage: fizzy search \"your query\"" + fi + + # Build query parameters + local path="/cards" + local params=() + + # Split and URL encode each search term + local term + for term in $query; do + local encoded_term + encoded_term=$(urlencode "$term") + params+=("terms[]=$encoded_term") + done + + # Use provided board_id or fall back to config + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + + # Resolve board name to ID if provided + if [[ -n "$board_id" ]]; then + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + params+=("board_ids[]=$board_id") + fi + + # Build query string + local query_string + query_string=$(IFS='&'; echo "${params[*]}") + path="$path?$query_string" + + local response + response=$(api_get "$path") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count results for \"$query\"" + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy show " "View card details")" \ + "$(breadcrumb "narrow" "fizzy search \"$query\" --board " "Filter by board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_search_md" +} + +_search_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Search Results" + echo "*$summary*" + echo + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No cards found matching your search." + echo + else + echo "| # | Title | Board | Status |" + echo "|---|-------|-------|--------|" + echo "$data" | jq -r '.[] | "| \(.number) | \(.title // .description[0:40]) | \(.board.name) | \(if .closed then "Closed" elif .column then .column.name else "Triage" end) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_search_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy search", + description: "Search cards", + usage: "fizzy search [options]", + options: [ + {flag: "--board, -b, --in", description: "Filter results to a specific board"} + ], + examples: [ + "fizzy search \"bug fix\"", + "fizzy search login --board abc123" + ] + }' + else + cat <<'EOF' +## fizzy search + +Search cards by keywords. + +### Usage + + fizzy search [options] + +### Options + + --board, -b, --in Filter results to a specific board + --help, -h Show this help + +### Examples + + fizzy search "bug fix" Search all cards + fizzy search login --board abc123 Search within board +EOF + fi +} diff --git a/cli/lib/commands/self_update.sh b/cli/lib/commands/self_update.sh new file mode 100644 index 0000000000..642091e5e2 --- /dev/null +++ b/cli/lib/commands/self_update.sh @@ -0,0 +1,340 @@ +#!/usr/bin/env bash +# self_update.sh - Self-update and uninstall commands for fizzy CLI + +# Self-update constants +FIZZY_REPO="${FIZZY_REPO:-basecamp/fizzy}" +FIZZY_BRANCH="${FIZZY_BRANCH:-main}" +FIZZY_RAW_URL="https://raw.githubusercontent.com/$FIZZY_REPO/$FIZZY_BRANCH" +FIZZY_API_URL="https://api.github.com/repos/$FIZZY_REPO" + +# cmd_self_update - Update fizzy CLI to latest version +cmd_self_update() { + local force=false + local check_only=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --force|-f) + force=true + shift + ;; + --check) + check_only=true + shift + ;; + --help|-h) + _self_update_help + return 0 + ;; + *) + die "Unknown option: $1" $EXIT_USAGE + ;; + esac + done + + # Check if this is a git checkout + if [[ -d "$FIZZY_ROOT/.git" ]]; then + json_error "Cannot self-update a git checkout" "usage" \ + "Use 'git pull' to update instead" + return $EXIT_USAGE + fi + + # Check if FIZZY_ROOT is writable + if [[ ! -w "$FIZZY_ROOT" ]]; then + local hint="Check permissions or reinstall with: curl -fsSL $FIZZY_RAW_URL/cli/install.sh | bash" + # Detect Homebrew install + if [[ "$FIZZY_ROOT" == */Cellar/* ]] || [[ "$FIZZY_ROOT" == */homebrew/* ]]; then + hint="Use 'brew upgrade fizzy-cli' to update" + fi + json_error "Cannot update: $FIZZY_ROOT is not writable" "forbidden" "$hint" + return $EXIT_FORBIDDEN + fi + + # Get current commit (short SHA) + local current_commit="$FIZZY_COMMIT" + + # Fetch remote commit (may fail due to rate limits or non-GitHub forks) + local remote_commit + remote_commit=$(_fetch_remote_commit) || remote_commit="" + + # If we couldn't get remote commit and not forcing, fail gracefully + if [[ -z "$remote_commit" ]] && [[ "$force" != "true" ]]; then + json_error "Failed to check for updates" "network" \ + "GitHub API may be rate-limited. Use 'fizzy self-update --force' to update anyway" + return $EXIT_NETWORK + fi + + # Compare commits (skip if no remote commit available) + if [[ -n "$remote_commit" ]] && [[ "$current_commit" == "$remote_commit" ]] && [[ "$force" != "true" ]]; then + if [[ "$(get_format)" == "json" ]]; then + json_output "true" '{ + "current_commit": "'"$current_commit"'", + "remote_commit": "'"$remote_commit"'", + "up_to_date": true, + "updated": false + }' "fizzy is up to date ($FIZZY_BRANCH $current_commit)" + else + echo "fizzy is up to date ($FIZZY_BRANCH $current_commit)" + fi + return 0 + fi + + # Check only mode + if [[ "$check_only" == "true" ]]; then + if [[ -z "$remote_commit" ]]; then + json_error "Cannot check for updates" "network" \ + "GitHub API unavailable. Use 'fizzy self-update --force' to update anyway" + return $EXIT_NETWORK + fi + if [[ "$(get_format)" == "json" ]]; then + json_output "true" '{ + "current_commit": "'"$current_commit"'", + "remote_commit": "'"$remote_commit"'", + "up_to_date": false, + "updated": false + }' "Update available: $current_commit → $remote_commit" + else + echo "Update available: $current_commit → $remote_commit" + echo "Run 'fizzy self-update' to update" + fi + return 0 + fi + + # Perform update + local update_msg + if [[ -n "$remote_commit" ]]; then + update_msg="Updating fizzy $current_commit → $remote_commit..." + else + update_msg="Updating fizzy to latest $FIZZY_BRANCH..." + fi + + if [[ "$(get_format)" == "json" ]]; then + echo "$update_msg" >&2 + else + echo "$update_msg" + fi + + if _perform_update "$remote_commit"; then + # Re-read commit after update + local new_commit + new_commit="$(cat "$FIZZY_ROOT/.commit" 2>/dev/null || echo "unknown")" + + if [[ "$(get_format)" == "json" ]]; then + json_output "true" '{ + "current_commit": "'"$current_commit"'", + "remote_commit": "'"${remote_commit:-unknown}"'", + "new_commit": "'"$new_commit"'", + "up_to_date": true, + "updated": true + }' "Updated fizzy to $FIZZY_BRANCH ($new_commit)" + else + echo "Updated fizzy to $FIZZY_BRANCH ($new_commit)" + fi + return 0 + else + json_error "Update failed" "api_error" \ + "Try running the installer manually: curl -fsSL $FIZZY_RAW_URL/cli/install.sh | bash" + return $EXIT_API + fi +} + +# Fetch latest commit SHA from GitHub API (short form) +# Returns empty string on failure (caller should handle) +_fetch_remote_commit() { + local response sha + + # Only works with GitHub API - non-GitHub forks need manual update + response=$(curl -fsSL --max-time 10 "$FIZZY_API_URL/commits/$FIZZY_BRANCH" 2>/dev/null) || return 1 + + # Check for API rate limit or error response + if [[ "$response" == *'"message":'* ]] && [[ "$response" != *'"sha":'* ]]; then + # API error (rate limit, not found, etc.) + return 1 + fi + + sha=$(echo "$response" | grep '"sha"' | head -1 | cut -d'"' -f4 | cut -c1-7) + if [[ -z "$sha" ]]; then + return 1 + fi + + echo "$sha" +} + +# Perform the actual update +_perform_update() { + local new_commit="${1:-}" + local tmp + tmp=$(mktemp -d) + trap "rm -rf '$tmp'" RETURN + + # Download latest CLI + if ! curl -fsSL --max-time 60 "https://github.com/$FIZZY_REPO/archive/refs/heads/$FIZZY_BRANCH.tar.gz" | \ + tar -xz -C "$tmp" --strip-components=2 "fizzy-$FIZZY_BRANCH/cli" 2>/dev/null; then + return 1 + fi + + # Copy new files over existing installation + cp -r "$tmp"/* "$FIZZY_ROOT/" || return 1 + + # Write commit SHA for version tracking + # If not provided, try to fetch it (best-effort for --force case) + if [[ -z "$new_commit" ]]; then + new_commit=$(_fetch_remote_commit) || true + fi + if [[ -n "$new_commit" ]]; then + echo "$new_commit" > "$FIZZY_ROOT/.commit" + else + # Fallback: record the branch name if we can't get commit + echo "$FIZZY_BRANCH" > "$FIZZY_ROOT/.commit" + fi + + # Ensure executable + chmod +x "$FIZZY_ROOT/bin/fizzy" || return 1 + + return 0 +} + +# cmd_uninstall - Remove fizzy CLI installation +cmd_uninstall() { + local force=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --force|-f) + force=true + shift + ;; + --help|-h) + _uninstall_help + return 0 + ;; + *) + die "Unknown option: $1" $EXIT_USAGE + ;; + esac + done + + # Check if this is a git checkout + if [[ -d "$FIZZY_ROOT/.git" ]]; then + json_error "Cannot uninstall a git checkout" "usage" \ + "Just delete the directory manually if needed" + return $EXIT_USAGE + fi + + # Detect Homebrew install + if [[ "$FIZZY_ROOT" == */Cellar/* ]] || [[ "$FIZZY_ROOT" == */homebrew/* ]]; then + json_error "Cannot uninstall Homebrew installation" "usage" \ + "Use 'brew uninstall fizzy-cli' instead" + return $EXIT_USAGE + fi + + # Check if FIZZY_ROOT is writable + if [[ ! -w "$FIZZY_ROOT" ]] || [[ ! -w "$(dirname "$FIZZY_ROOT")" ]]; then + json_error "Cannot uninstall: insufficient permissions" "forbidden" \ + "Check permissions on $FIZZY_ROOT" + return $EXIT_FORBIDDEN + fi + + # Confirm uninstall unless --force + if [[ "$force" != "true" ]]; then + echo "This will remove fizzy from: $FIZZY_ROOT" + echo "Run with --force to confirm, or press Ctrl+C to cancel" + return 0 + fi + + # Find and remove symlink in common bin directories + local bin_link + for bin_dir in "$HOME/.local/bin" "$HOME/bin" "/usr/local/bin"; do + bin_link="$bin_dir/fizzy" + if [[ -L "$bin_link" ]] && [[ "$(readlink "$bin_link")" == "$FIZZY_ROOT/bin/fizzy" ]]; then + rm -f "$bin_link" 2>/dev/null || true + fi + done + + # Remove installation directory + if rm -rf "$FIZZY_ROOT"; then + if [[ "$(get_format)" == "json" ]]; then + json_output "true" '{ + "uninstalled": true, + "path": "'"$FIZZY_ROOT"'" + }' "fizzy has been uninstalled" + else + echo "fizzy has been uninstalled from $FIZZY_ROOT" + fi + return 0 + else + json_error "Failed to remove $FIZZY_ROOT" "api_error" \ + "Try removing manually: rm -rf $FIZZY_ROOT" + return $EXIT_API + fi +} + +# Help for self-update command +_self_update_help() { + if [[ "$(get_format)" == "json" ]]; then + cat <<'EOF' +{ + "command": "fizzy self-update", + "description": "Update fizzy CLI to the latest version", + "options": [ + {"flag": "--check", "description": "Check for updates without installing"}, + {"flag": "--force, -f", "description": "Force update even if already up to date"} + ], + "examples": [ + {"cmd": "fizzy self-update", "desc": "Update to latest version"}, + {"cmd": "fizzy self-update --check", "desc": "Check if update is available"}, + {"cmd": "fizzy self-update --force", "desc": "Force reinstall current version"} + ] +} +EOF + else + cat <<'EOF' +fizzy self-update - Update fizzy CLI to the latest version + +USAGE + fizzy self-update [options] + +OPTIONS + --check Check for updates without installing + --force, -f Force update even if already up to date + +EXAMPLES + fizzy self-update Update to latest version + fizzy self-update --check Check if update is available + fizzy self-update --force Force reinstall current version +EOF + fi +} + +# Help for uninstall command +_uninstall_help() { + if [[ "$(get_format)" == "json" ]]; then + cat <<'EOF' +{ + "command": "fizzy uninstall", + "description": "Remove fizzy CLI from your system", + "options": [ + {"flag": "--force, -f", "description": "Skip confirmation and remove immediately"} + ], + "examples": [ + {"cmd": "fizzy uninstall", "desc": "Show what will be removed"}, + {"cmd": "fizzy uninstall --force", "desc": "Remove fizzy immediately"} + ] +} +EOF + else + cat <<'EOF' +fizzy uninstall - Remove fizzy CLI from your system + +USAGE + fizzy uninstall [options] + +OPTIONS + --force, -f Skip confirmation and remove immediately + +EXAMPLES + fizzy uninstall Show what will be removed + fizzy uninstall --force Remove fizzy immediately +EOF + fi +} diff --git a/cli/lib/commands/show.sh b/cli/lib/commands/show.sh new file mode 100644 index 0000000000..71e066815b --- /dev/null +++ b/cli/lib/commands/show.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# show.sh - Show detail view dispatcher + + +# fizzy show +# Show detailed view of a resource + +cmd_show() { + if [[ $# -eq 0 ]]; then + _show_help + return 0 + fi + + local first_arg="$1" + shift + + case "$first_arg" in + --help|-h) + _show_help + ;; + board) + show_board "$@" + ;; + card) + show_card "$@" + ;; + *) + # If first arg looks like a number, assume it's a card number + if [[ "$first_arg" =~ ^[0-9]+$ ]]; then + show_card "$first_arg" + else + # Otherwise treat as board ID + show_board "$first_arg" + fi + ;; + esac +} + +# Show board details +show_board() { + local board_id="$1" + + if [[ -z "$board_id" ]]; then + die "Board ID required" $EXIT_USAGE "Usage: fizzy show board " + fi + + local response + response=$(api_get "/boards/$board_id") + + local name + name=$(echo "$response" | jq -r '.name') + local summary="Board: $name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "cards" "fizzy cards --board $board_id" "List cards")" \ + "$(breadcrumb "columns" "fizzy columns --board $board_id" "List columns")" \ + "$(breadcrumb "set" "fizzy config set board_id $board_id" "Set as default")" + ) + + output "$response" "$summary" "$breadcrumbs" "_show_board_md" +} + +_show_board_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local id name all_access creator_name created_at + + id=$(echo "$data" | jq -r '.id') + name=$(echo "$data" | jq -r '.name') + all_access=$(echo "$data" | jq -r 'if .all_access then "Yes (all users)" else "Selective" end') + creator_name=$(echo "$data" | jq -r '.creator.name') + created_at=$(echo "$data" | jq -r '.created_at | split("T")[0]') + + md_heading 2 "Board: $name" + + md_kv "ID" "$id" \ + "Access" "$all_access" \ + "Created" "$created_at by $creator_name" + + md_breadcrumbs "$breadcrumbs" +} + +_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy show", + description: "Show detailed view of a resource", + usage: [ + "fizzy show ", + "fizzy show card ", + "fizzy show board " + ], + examples: [ + "fizzy show 42", + "fizzy show card 42", + "fizzy show board abc123" + ] + }' + else + cat <<'EOF' +## fizzy show + +Show detailed view of a resource. + +### Usage + + fizzy show Show card by number + fizzy show card Show card by number + fizzy show board Show board by ID + +### Examples + + fizzy show 42 Show card #42 + fizzy show card 42 Show card #42 + fizzy show board abc123 Show board details +EOF + fi +} diff --git a/cli/lib/commands/tags.sh b/cli/lib/commands/tags.sh new file mode 100644 index 0000000000..e791059e64 --- /dev/null +++ b/cli/lib/commands/tags.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# tags.sh - Tag query commands + + +# fizzy tags [options] +# List tags in the account + +cmd_tags() { + local show_help=false + local page="" + local fetch_all=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --all|-a) + fetch_all=true + shift + ;; + --page|-p) + if [[ -z "${2:-}" ]]; then + die "--page requires a value" $EXIT_USAGE + fi + if ! [[ "$2" =~ ^[0-9]+$ ]] || [[ "$2" -lt 1 ]]; then + die "--page must be a positive integer" $EXIT_USAGE + fi + page="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy tags --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _tags_help + return 0 + fi + + local response + if [[ "$fetch_all" == "true" ]]; then + response=$(api_get_all "/tags") + else + local path="/tags" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + response=$(api_get "$path") + fi + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count tags" + [[ -n "$page" ]] && summary="$count tags (page $page)" + [[ "$fetch_all" == "true" ]] && summary="$count tags (all)" + + local next_page=$((${page:-1} + 1)) + local breadcrumbs + if [[ "$fetch_all" == "true" ]]; then + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "filter" "fizzy cards --tag " "Filter cards by tag")" \ + "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" + ) + else + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "filter" "fizzy cards --tag " "Filter cards by tag")" \ + "$(breadcrumb "add" "fizzy tag --with \"name\"" "Add tag to card")" \ + "$(breadcrumb "next" "fizzy tags --page $next_page" "Next page")" + ) + fi + + output "$response" "$summary" "$breadcrumbs" "_tags_md" +} + +_tags_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Tags ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No tags found." + echo + else + echo "| ID | Title | Created |" + echo "|----|-------|---------|" + echo "$data" | jq -r '.[] | "| \(.id) | #\(.title) | \(.created_at | split("T")[0]) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_tags_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy tags", + description: "List tags in the account", + options: [ + {flag: "--all, -a", description: "Fetch all pages"}, + {flag: "--page, -p", description: "Page number for pagination"} + ], + examples: [ + "fizzy tags", + "fizzy tags --all", + "fizzy tags --page 2" + ] + }' + else + cat <<'EOF' +## fizzy tags + +List tags in the account. + +### Usage + + fizzy tags [options] + +### Options + + --all, -a Fetch all pages + --page, -p Page number for pagination + --help, -h Show this help + +### Examples + + fizzy tags List tags (first page) + fizzy tags --all Fetch all tags + fizzy tags --page 2 Get second page +EOF + fi +} diff --git a/cli/lib/commands/users.sh b/cli/lib/commands/users.sh new file mode 100644 index 0000000000..a0edcaed27 --- /dev/null +++ b/cli/lib/commands/users.sh @@ -0,0 +1,804 @@ +#!/usr/bin/env bash +# users.sh - Identity and user management commands + + +# fizzy identity +# Show current identity and associated accounts + +cmd_identity() { + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + *) + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _identity_help + return 0 + fi + + # Identity endpoint doesn't require account_slug + local token + token=$(ensure_auth) + + local response + response=$(curl -s -w '\n%{http_code}' \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Accept: application/json" \ + "$FIZZY_BASE_URL/my/identity.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + case "$http_code" in + 200) ;; + 401|403) + die "Authentication failed" $EXIT_AUTH "Run: fizzy auth login" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + local accounts + accounts=$(echo "$response" | jq '.accounts // []') + local count + count=$(echo "$accounts" | jq 'length') + + local summary="${count} account" + [[ "$count" -ne 1 ]] && summary="${count} accounts" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "status" "fizzy auth status" "Auth status")" + ) + + output "$response" "$summary" "$breadcrumbs" "_identity_md" +} + +_identity_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local accounts + accounts=$(echo "$data" | jq -r '.accounts // []') + local count + count=$(echo "$accounts" | jq 'length') + + # Derive name/email from first account's user (API doesn't have top-level identity fields) + local email name + if [[ "$count" -gt 0 ]]; then + name=$(echo "$accounts" | jq -r '.[0].user.name // "unknown"') + email=$(echo "$accounts" | jq -r '.[0].user.email_address // "unknown"') + else + name="unknown" + email="unknown" + fi + + md_heading 2 "Identity" + echo + md_kv "Name" "$name" \ + "Email" "$email" + echo + + if [[ "$count" -gt 0 ]]; then + md_heading 3 "Accounts ($count)" + echo + echo "| Name | Slug | Role |" + echo "|------|------|------|" + echo "$accounts" | jq -r '.[] | "| \(.name) | \(.slug | ltrimstr("/")) | \(.user.role // "member") |"' + else + echo "No accounts found." + fi + + echo + md_breadcrumbs "$breadcrumbs" +} + +_identity_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy identity", + description: "Show current identity and associated accounts", + usage: "fizzy identity" + }' + else + cat <<'EOF' +## fizzy identity + +Show current identity and associated accounts. + +### Usage + + fizzy identity + +### Output + +Shows your email, name, and a table of accounts you have access to +with their names, slugs, and your role in each. + +### Examples + + fizzy identity Show identity info +EOF + fi +} + + +# fizzy user show + +cmd_user() { + local action="${1:-}" + shift || true + + case "$action" in + show) _user_show "$@" ;; + update) _user_update "$@" ;; + delete) _user_delete "$@" ;; + role) _user_role "$@" ;; + --help|-h|"") _user_help ;; + *) + die "Unknown subcommand: user $action" $EXIT_USAGE \ + "Available: fizzy user show|update|delete|role" + ;; + esac +} + +_user_show() { + local show_help=false + local user_ref="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy user show --help" + ;; + *) + if [[ -z "$user_ref" ]]; then + user_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _user_show_help + return 0 + fi + + if [[ -z "$user_ref" ]]; then + die "User ID, email, or name required" $EXIT_USAGE \ + "Usage: fizzy user show " + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + # Resolve user reference to ID + local user_id + user_id=$(resolve_user_id "$user_ref") || exit $? + + local response + response=$(api_get "/users/$user_id") + + local user_name + user_name=$(echo "$response" | jq -r '.name // "unknown"') + + local summary="User: $user_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "people" "fizzy people" "List users")" \ + "$(breadcrumb "update" "fizzy user update $user_id" "Update user")" + ) + + output "$response" "$summary" "$breadcrumbs" "_user_show_md" +} + +_user_show_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local id name email role avatar_url + id=$(echo "$data" | jq -r '.id') + name=$(echo "$data" | jq -r '.name // "unknown"') + email=$(echo "$data" | jq -r '.email_address // "unknown"') + role=$(echo "$data" | jq -r '.role // "member"') + avatar_url=$(echo "$data" | jq -r '.avatar_url // "none"') + + md_heading 2 "$name" + echo + md_kv "ID" "$id" \ + "Email" "$email" \ + "Role" "$role" \ + "Avatar" "$avatar_url" + + md_breadcrumbs "$breadcrumbs" +} + +_user_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user show", + description: "Show user details", + usage: "fizzy user show ", + examples: [ + "fizzy user show abc123", + "fizzy user show alice@example.com", + "fizzy user show \"Alice Smith\"" + ] + }' + else + cat <<'EOF' +## fizzy user show + +Show user details. + +### Usage + + fizzy user show + +### Arguments + +The user can be specified by: +- ID (exact match) +- Email address +- Name (partial match supported) + +### Examples + + fizzy user show abc123 By ID + fizzy user show alice@example.com By email + fizzy user show "Alice Smith" By name +EOF + fi +} + + +# fizzy user update [--name NAME] [--avatar PATH] + +_user_update() { + local show_help=false + local user_ref="" + local new_name="" + local avatar_path="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + --name) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + new_name="$2" + shift 2 + ;; + --avatar) + if [[ -z "${2:-}" ]]; then + die "--avatar requires a file path" $EXIT_USAGE + fi + avatar_path="$2" + shift 2 + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy user update --help" + ;; + *) + if [[ -z "$user_ref" ]]; then + user_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _user_update_help + return 0 + fi + + if [[ -z "$user_ref" ]]; then + die "User ID, email, or name required" $EXIT_USAGE \ + "Usage: fizzy user update [--name NAME] [--avatar PATH]" + fi + + if [[ -z "$new_name" ]] && [[ -z "$avatar_path" ]]; then + die "Nothing to update" $EXIT_USAGE \ + "Provide --name and/or --avatar" + fi + + # Validate avatar file exists + if [[ -n "$avatar_path" ]] && [[ ! -f "$avatar_path" ]]; then + die "File not found: $avatar_path" $EXIT_USAGE + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + # Resolve user reference to ID + local user_id + user_id=$(resolve_user_id "$user_ref") || exit $? + + local token + token=$(ensure_auth) + + local http_code + + if [[ -n "$avatar_path" ]]; then + # Multipart upload for avatar + local curl_args=( + -s -w '\n%{http_code}' + -X PATCH + -H "Authorization: Bearer $token" + -H "User-Agent: $FIZZY_USER_AGENT" + -H "Accept: application/json" + ) + + if [[ -n "$new_name" ]]; then + curl_args+=(--form-string "user[name]=$new_name") + fi + curl_args+=(-F "user[avatar]=@$avatar_path") + curl_args+=("$FIZZY_BASE_URL/$account_slug/users/$user_id.json") + + local response + api_multipart_request http_code response "${curl_args[@]}" + else + # JSON update for name only + local body + body=$(jq -n --arg name "$new_name" '{user: {name: $name}}') + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X PATCH \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$body" \ + "$FIZZY_BASE_URL/$account_slug/users/$user_id.json") + http_code=$(echo "$response" | tail -n1) + fi + + case "$http_code" in + 200|204) ;; + 401|403) + die "Not authorized to update this user" $EXIT_FORBIDDEN + ;; + 404) + die "User not found: $user_ref" $EXIT_NOT_FOUND + ;; + 422) + die "Validation error" $EXIT_API "Check the provided values" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + # Fetch updated user (204 returns no body) + local updated_user + updated_user=$(api_get "/users/$user_id") + + local user_name + user_name=$(echo "$updated_user" | jq -r '.name // "unknown"') + + local summary="Updated user $user_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy user show $user_id" "View user")" \ + "$(breadcrumb "people" "fizzy people" "List users")" + ) + + output "$updated_user" "$summary" "$breadcrumbs" "_user_show_md" +} + +_user_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user update", + description: "Update user profile", + usage: "fizzy user update [--name NAME] [--avatar PATH]", + options: [ + {flag: "--name", description: "New display name"}, + {flag: "--avatar", description: "Path to avatar image file"} + ], + examples: [ + "fizzy user update abc123 --name \"New Name\"", + "fizzy user update alice@example.com --avatar ~/photo.jpg" + ] + }' + else + cat <<'EOF' +## fizzy user update + +Update user profile. + +### Usage + + fizzy user update [--name NAME] [--avatar PATH] + +### Options + + --name NAME New display name + --avatar PATH Path to avatar image file + +### Examples + + fizzy user update abc123 --name "New Name" + fizzy user update alice@example.com --avatar ~/photo.jpg + fizzy user update "Alice" --name "Alice Smith" --avatar ~/alice.png +EOF + fi +} + + +# fizzy user delete + +_user_delete() { + local show_help=false + local user_ref="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy user delete --help" + ;; + *) + if [[ -z "$user_ref" ]]; then + user_ref="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _user_delete_help + return 0 + fi + + if [[ -z "$user_ref" ]]; then + die "User ID, email, or name required" $EXIT_USAGE \ + "Usage: fizzy user delete " + fi + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + # Resolve user to get name for summary before deletion + local user_id user_name + user_id=$(resolve_user_id "$user_ref") || exit $? + + # Fetch user name before delete + local user_data + user_data=$(api_get "/users/$user_id" 2>/dev/null || echo '{}') + user_name=$(echo "$user_data" | jq -r '.name // "unknown"') + + # DELETE returns 204 + api_delete "/users/$user_id" > /dev/null + + local summary="Deactivated user $user_name" + + local response + response=$(jq -n --arg id "$user_id" --arg name "$user_name" \ + '{id: $id, name: $name, deactivated: true}') + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "people" "fizzy people" "List users")" + ) + + output "$response" "$summary" "$breadcrumbs" "_user_delete_md" +} + +_user_delete_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local user_name + user_name=$(echo "$data" | jq -r '.name // "unknown"') + + md_heading 2 "User Deactivated" + echo + echo "User **$user_name** has been deactivated." + + md_breadcrumbs "$breadcrumbs" +} + +_user_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user delete", + description: "Deactivate a user", + usage: "fizzy user delete ", + warning: "This deactivates the user from the account", + examples: [ + "fizzy user delete abc123", + "fizzy user delete alice@example.com" + ] + }' + else + cat <<'EOF' +## fizzy user delete + +Deactivate a user from the account. + +### Usage + + fizzy user delete + +### Warning + +This deactivates the user's access to the account. The user's data +is preserved but they can no longer access the account. + +### Examples + + fizzy user delete abc123 + fizzy user delete alice@example.com + fizzy user delete "Alice Smith" +EOF + fi +} + + +_user_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user", + description: "Manage users in the account", + subcommands: [ + {name: "show", description: "Show user details"}, + {name: "update", description: "Update user profile"}, + {name: "delete", description: "Deactivate a user"}, + {name: "role", description: "Change user role (admin/member)"} + ] + }' + else + cat <<'EOF' +## fizzy user + +Manage users in the account. + +### Subcommands + + show Show user details + update [options] Update user profile + delete Deactivate a user + role Change user role (admin/member) + +### Examples + + fizzy user show alice@example.com + fizzy user update abc123 --name "New Name" + fizzy user delete "Former Employee" + fizzy user role alice@example.com admin +EOF + fi +} + + +# fizzy user role + +_user_role() { + local show_help=false + local user_ref="" + local new_role="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy user role --help" + ;; + *) + if [[ -z "$user_ref" ]]; then + user_ref="$1" + elif [[ -z "$new_role" ]]; then + new_role="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _user_role_help + return 0 + fi + + if [[ -z "$user_ref" ]]; then + die "User ID, email, or name required" $EXIT_USAGE \ + "Usage: fizzy user role " + fi + + if [[ -z "$new_role" ]]; then + die "Role required (admin or member)" $EXIT_USAGE \ + "Usage: fizzy user role " + fi + + # Validate role value + case "$new_role" in + admin|member) ;; + *) + die "Invalid role: $new_role" $EXIT_USAGE \ + "Valid roles: admin, member" + ;; + esac + + local account_slug + account_slug=$(get_account_slug) + if [[ -z "$account_slug" ]]; then + die "Account not configured" $EXIT_USAGE \ + "Run: fizzy config set account_slug " + fi + + # Resolve user reference to ID + local user_id + user_id=$(resolve_user_id "$user_ref") || exit $? + + local token + token=$(ensure_auth) + + # PUT to role endpoint + local body + body=$(jq -n --arg role "$new_role" '{user: {role: $role}}') + + local response + response=$(curl -s -w '\n%{http_code}' \ + -X PUT \ + -H "Authorization: Bearer $token" \ + -H "User-Agent: $FIZZY_USER_AGENT" \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$body" \ + "$FIZZY_BASE_URL/$account_slug/users/$user_id/role.json") + + local http_code + http_code=$(echo "$response" | tail -n1) + + case "$http_code" in + 200|204|302) ;; + 401|403) + die "Not authorized to change role" $EXIT_FORBIDDEN \ + "Only admins/owners can change user roles" + ;; + 404) + die "User not found: $user_ref" $EXIT_NOT_FOUND + ;; + 422) + die "Cannot change role" $EXIT_API \ + "Owner role cannot be changed" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + # Fetch updated user + local updated_user + updated_user=$(api_get "/users/$user_id") + + local user_name + user_name=$(echo "$updated_user" | jq -r '.name // "unknown"') + + local summary="Changed $user_name role to $new_role" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy user show $user_id" "View user")" \ + "$(breadcrumb "people" "fizzy people" "List users")" + ) + + output "$updated_user" "$summary" "$breadcrumbs" "_user_show_md" +} + +_user_role_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy user role", + description: "Change user role", + usage: "fizzy user role ", + arguments: [ + {name: "", description: "User ID, email, or name"}, + {name: "", description: "New role: admin or member"} + ], + notes: [ + "Only admins and owners can change user roles", + "Owner role cannot be changed", + "System role cannot be assigned" + ], + examples: [ + "fizzy user role alice@example.com admin", + "fizzy user role \"Alice Smith\" member", + "fizzy user role abc123 admin" + ] + }' + else + cat <<'EOF' +## fizzy user role + +Change user role. + +### Usage + + fizzy user role + +### Arguments + + User ID, email, or name + New role: admin or member + +### Notes + +- Only admins and owners can change user roles +- Owner role cannot be changed +- System role cannot be assigned + +### Examples + + fizzy user role alice@example.com admin + fizzy user role "Alice Smith" member + fizzy user role abc123 admin +EOF + fi +} diff --git a/cli/lib/commands/webhooks.sh b/cli/lib/commands/webhooks.sh new file mode 100644 index 0000000000..852714b2f8 --- /dev/null +++ b/cli/lib/commands/webhooks.sh @@ -0,0 +1,879 @@ +#!/usr/bin/env bash +# webhooks.sh - Webhook management commands + + +# fizzy webhooks --board +# List webhooks for a board + +cmd_webhooks() { + local board_id="" + local show_help=false + local page="" + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --page|-p) + if [[ -z "${2:-}" ]]; then + die "--page requires a value" $EXIT_USAGE + fi + page="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy webhooks --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _webhooks_list_help + return 0 + fi + + # Resolve board + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + if [[ -z "$board_id" ]]; then + die "Board required" $EXIT_USAGE "Use --board or set default board" + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + local path="/boards/$board_id/webhooks" + if [[ -n "$page" ]]; then + path="$path?page=$page" + fi + + local response + response=$(api_get "$path") + + local count + count=$(echo "$response" | jq 'length') + + local summary="$count webhooks" + [[ -n "$page" ]] && summary="$count webhooks (page $page)" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "create" "fizzy webhook create --board $board_id" "Create webhook")" \ + "$(breadcrumb "board" "fizzy show board $board_id" "View board")" + ) + + output "$response" "$summary" "$breadcrumbs" "_webhooks_md" +} + +_webhooks_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + md_heading 2 "Webhooks ($summary)" + + local count + count=$(echo "$data" | jq 'length') + + if [[ "$count" -eq 0 ]]; then + echo "No webhooks found." + echo + else + echo "| ID | Name | URL | Actions |" + echo "|----|------|-----|---------|" + echo "$data" | jq -r '.[] | "| \(.id) | \(.name) | \(.url | .[0:40])... | \(.subscribed_actions | length) |"' + echo + fi + + md_breadcrumbs "$breadcrumbs" +} + +_webhooks_list_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy webhooks", + description: "List webhooks for a board", + usage: "fizzy webhooks --board ", + options: [ + {flag: "--board, -b", description: "Board ID or name"}, + {flag: "--page", description: "Page number"} + ], + examples: [ + "fizzy webhooks --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy webhooks + +List webhooks for a board. + +### Usage + + fizzy webhooks --board + +### Options + + --board, -b Board ID or name + --page Page number + +### Examples + + fizzy webhooks --board "My Board" +EOF + fi +} + + +# fizzy webhook +# Manage webhooks + +cmd_webhook() { + case "${1:-}" in + create) + shift + _webhook_create "$@" + ;; + show) + shift + _webhook_show "$@" + ;; + update) + shift + _webhook_update "$@" + ;; + delete) + shift + _webhook_delete "$@" + ;; + --help|-h|"") + _webhook_help + ;; + *) + die "Unknown subcommand: webhook ${1:-}" $EXIT_USAGE \ + "Available: fizzy webhook create|show|update|delete" + ;; + esac +} + +_webhook_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy webhook", + description: "Manage webhooks", + subcommands: [ + {name: "create", description: "Create a webhook"}, + {name: "show", description: "Show webhook details"}, + {name: "update", description: "Update a webhook"}, + {name: "delete", description: "Delete a webhook"} + ], + examples: [ + "fizzy webhook create --board \"My Board\" --name \"Slack\" --url \"https://...\" --actions card_published card_closed", + "fizzy webhook show abc123 --board \"My Board\"", + "fizzy webhook update abc123 --board \"My Board\" --name \"New Name\"", + "fizzy webhook delete abc123 --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy webhook + +Manage webhooks. + +### Subcommands + + create Create a webhook + show Show webhook details + update Update a webhook + delete Delete a webhook + +### Examples + + fizzy webhook create --board "My Board" --name "Slack" --url "https://..." --actions card_published + fizzy webhook show abc123 --board "My Board" + fizzy webhook update abc123 --board "My Board" --name "New Name" + fizzy webhook delete abc123 --board "My Board" +EOF + fi +} + + +# fizzy webhook create --board --name --url [--actions ...] + +_webhook_create() { + local board_id="" + local name="" + local url="" + local actions=() + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --name|-n) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --url|-u) + if [[ -z "${2:-}" ]]; then + die "--url requires a value" $EXIT_USAGE + fi + url="$2" + shift 2 + ;; + --actions|-a) + shift + while [[ $# -gt 0 ]] && [[ ! "$1" =~ ^- ]]; do + actions+=("$1") + shift + done + ;; + --help|-h) + show_help=true + shift + ;; + *) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy webhook create --help" + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _webhook_create_help + return 0 + fi + + if [[ -z "$name" ]]; then + die "Name required" $EXIT_USAGE "Use --name" + fi + + if [[ -z "$url" ]]; then + die "URL required" $EXIT_USAGE "Use --url" + fi + + # Resolve board + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + if [[ -z "$board_id" ]]; then + die "Board required" $EXIT_USAGE "Use --board or set default board" + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + local token + token=$(ensure_auth) + + local account_slug + account_slug=$(get_account_slug) + + # Build form data + local curl_args=( + -s -w '\n%{http_code}' + -X POST + -H "Authorization: Bearer $token" + -H "User-Agent: $FIZZY_USER_AGENT" + -H "Accept: application/json" + ) + + curl_args+=(--form-string "webhook[name]=$name") + curl_args+=(--form-string "webhook[url]=$url") + + for action in "${actions[@]}"; do + curl_args+=(--form-string "webhook[subscribed_actions][]=$action") + done + + curl_args+=("$FIZZY_BASE_URL/$account_slug/boards/$board_id/webhooks.json") + + local response + response=$(curl "${curl_args[@]}") + + local http_code + http_code=$(echo "$response" | tail -n1) + response=$(echo "$response" | sed '$d') + + case "$http_code" in + 200|201|302) ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN "Only admins can create webhooks" + ;; + 422) + local error_msg + error_msg=$(echo "$response" | jq -r '.errors // "Validation failed"' 2>/dev/null || echo "Validation failed") + die "Validation error: $error_msg" $EXIT_API + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + # Try to parse response or construct one + if ! echo "$response" | jq -e '.id' > /dev/null 2>&1; then + response=$(jq -n --arg name "$name" --arg url "$url" \ + '{name: $name, url: $url, created: true}') + fi + + local summary="Created webhook $name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "list" "fizzy webhooks --board $board_id" "List webhooks")" + ) + + output "$response" "$summary" "$breadcrumbs" "_webhook_md" +} + +_webhook_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local id name url + id=$(echo "$data" | jq -r '.id // "pending"') + name=$(echo "$data" | jq -r '.name // "unknown"') + url=$(echo "$data" | jq -r '.url // "unknown"') + + local actions + actions=$(echo "$data" | jq -r '.subscribed_actions // [] | join(", ")') + [[ -z "$actions" ]] && actions="(none)" + + md_heading 2 "Webhook: $name" + echo + md_kv "ID" "$id" \ + "URL" "$url" \ + "Actions" "$actions" + + md_breadcrumbs "$breadcrumbs" +} + +_webhook_create_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy webhook create", + description: "Create a webhook", + usage: "fizzy webhook create --board --name --url [--actions ...]", + options: [ + {flag: "--board, -b", description: "Board ID or name"}, + {flag: "--name, -n", description: "Webhook name"}, + {flag: "--url, -u", description: "Webhook URL"}, + {flag: "--actions, -a", description: "Event types to subscribe to"} + ], + available_actions: [ + "card_assigned", "card_closed", "card_postponed", + "card_auto_postponed", "card_board_changed", "card_published", + "card_reopened", "card_sent_back_to_triage", "card_triaged", + "card_unassigned", "comment_created" + ], + examples: [ + "fizzy webhook create --board \"My Board\" --name \"Slack\" --url \"https://hooks.slack.com/...\" --actions card_published card_closed" + ] + }' + else + cat <<'EOF' +## fizzy webhook create + +Create a webhook. + +### Usage + + fizzy webhook create --board --name --url [--actions ...] + +### Options + + --board, -b Board ID or name + --name, -n Webhook name + --url, -u Webhook URL + --actions, -a Event types to subscribe to + +### Available Actions + + card_assigned card_closed card_postponed + card_auto_postponed card_board_changed card_published + card_reopened card_sent_back_to_triage card_triaged + card_unassigned comment_created + +### Examples + + fizzy webhook create --board "My Board" --name "Slack" \ + --url "https://hooks.slack.com/..." --actions card_published card_closed +EOF + fi +} + + +# fizzy webhook show --board + +_webhook_show() { + local webhook_id="" + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy webhook show --help" + ;; + *) + if [[ -z "$webhook_id" ]]; then + webhook_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _webhook_show_help + return 0 + fi + + if [[ -z "$webhook_id" ]]; then + die "Webhook ID required" $EXIT_USAGE "Usage: fizzy webhook show --board " + fi + + # Resolve board + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + if [[ -z "$board_id" ]]; then + die "Board required" $EXIT_USAGE "Use --board or set default board" + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + local response + response=$(api_get "/boards/$board_id/webhooks/$webhook_id") + + local webhook_name + webhook_name=$(echo "$response" | jq -r '.name // "unknown"') + + local summary="Webhook: $webhook_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "list" "fizzy webhooks --board $board_id" "List webhooks")" \ + "$(breadcrumb "update" "fizzy webhook update $webhook_id --board $board_id" "Update")" \ + "$(breadcrumb "delete" "fizzy webhook delete $webhook_id --board $board_id" "Delete")" + ) + + output "$response" "$summary" "$breadcrumbs" "_webhook_md" +} + +_webhook_show_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy webhook show", + description: "Show webhook details", + usage: "fizzy webhook show --board ", + options: [ + {flag: "--board, -b", description: "Board ID or name"} + ], + examples: [ + "fizzy webhook show abc123 --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy webhook show + +Show webhook details. + +### Usage + + fizzy webhook show --board + +### Options + + --board, -b Board ID or name + +### Examples + + fizzy webhook show abc123 --board "My Board" +EOF + fi +} + + +# fizzy webhook update --board [--name ] [--actions ...] + +_webhook_update() { + local webhook_id="" + local board_id="" + local name="" + local actions=() + local has_actions=false + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --name|-n) + if [[ -z "${2:-}" ]]; then + die "--name requires a value" $EXIT_USAGE + fi + name="$2" + shift 2 + ;; + --actions|-a) + has_actions=true + shift + while [[ $# -gt 0 ]] && [[ ! "$1" =~ ^- ]]; do + actions+=("$1") + shift + done + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy webhook update --help" + ;; + *) + if [[ -z "$webhook_id" ]]; then + webhook_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _webhook_update_help + return 0 + fi + + if [[ -z "$webhook_id" ]]; then + die "Webhook ID required" $EXIT_USAGE "Usage: fizzy webhook update --board " + fi + + if [[ -z "$name" ]] && [[ "$has_actions" == "false" ]]; then + die "Nothing to update" $EXIT_USAGE "Provide --name or --actions" + fi + + # Resolve board + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + if [[ -z "$board_id" ]]; then + die "Board required" $EXIT_USAGE "Use --board or set default board" + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + local token + token=$(ensure_auth) + + local account_slug + account_slug=$(get_account_slug) + + # Build form data + local curl_args=( + -s -w '\n%{http_code}' + -X PATCH + -H "Authorization: Bearer $token" + -H "User-Agent: $FIZZY_USER_AGENT" + -H "Accept: application/json" + ) + + if [[ -n "$name" ]]; then + curl_args+=(--form-string "webhook[name]=$name") + fi + + if [[ "$has_actions" == "true" ]]; then + for action in "${actions[@]}"; do + curl_args+=(--form-string "webhook[subscribed_actions][]=$action") + done + # Include empty if no actions specified (clears all) + if [[ ${#actions[@]} -eq 0 ]]; then + curl_args+=(--form-string "webhook[subscribed_actions][]=") + fi + fi + + curl_args+=("$FIZZY_BASE_URL/$account_slug/boards/$board_id/webhooks/$webhook_id.json") + + local response + response=$(curl "${curl_args[@]}") + + local http_code + http_code=$(echo "$response" | tail -n1) + + case "$http_code" in + 200|204|302) ;; + 401|403) + die "Not authorized" $EXIT_FORBIDDEN "Only admins can update webhooks" + ;; + 404) + die "Webhook not found" $EXIT_NOT_FOUND + ;; + 422) + die "Validation error" $EXIT_API "Check the provided values" + ;; + *) + die "API error: HTTP $http_code" $EXIT_API + ;; + esac + + # Fetch updated webhook + local updated + updated=$(api_get "/boards/$board_id/webhooks/$webhook_id") + + local webhook_name + webhook_name=$(echo "$updated" | jq -r '.name // "unknown"') + + local summary="Updated webhook $webhook_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "show" "fizzy webhook show $webhook_id --board $board_id" "View")" \ + "$(breadcrumb "list" "fizzy webhooks --board $board_id" "List webhooks")" + ) + + output "$updated" "$summary" "$breadcrumbs" "_webhook_md" +} + +_webhook_update_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy webhook update", + description: "Update a webhook", + usage: "fizzy webhook update --board [--name ] [--actions ...]", + options: [ + {flag: "--board, -b", description: "Board ID or name"}, + {flag: "--name, -n", description: "New webhook name"}, + {flag: "--actions, -a", description: "New event types (replaces existing)"} + ], + notes: ["URL cannot be changed after creation"], + examples: [ + "fizzy webhook update abc123 --board \"My Board\" --name \"New Name\"", + "fizzy webhook update abc123 --board \"My Board\" --actions card_published" + ] + }' + else + cat <<'EOF' +## fizzy webhook update + +Update a webhook. + +### Usage + + fizzy webhook update --board [--name ] [--actions ...] + +### Options + + --board, -b Board ID or name + --name, -n New webhook name + --actions, -a New event types (replaces existing) + +### Notes + +URL cannot be changed after creation. + +### Examples + + fizzy webhook update abc123 --board "My Board" --name "New Name" + fizzy webhook update abc123 --board "My Board" --actions card_published +EOF + fi +} + + +# fizzy webhook delete --board + +_webhook_delete() { + local webhook_id="" + local board_id="" + local show_help=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a board name or ID" $EXIT_USAGE + fi + board_id="$2" + shift 2 + ;; + --help|-h) + show_help=true + shift + ;; + -*) + die "Unknown option: $1" $EXIT_USAGE "Run: fizzy webhook delete --help" + ;; + *) + if [[ -z "$webhook_id" ]]; then + webhook_id="$1" + fi + shift + ;; + esac + done + + if [[ "$show_help" == "true" ]]; then + _webhook_delete_help + return 0 + fi + + if [[ -z "$webhook_id" ]]; then + die "Webhook ID required" $EXIT_USAGE "Usage: fizzy webhook delete --board " + fi + + # Resolve board + if [[ -z "$board_id" ]]; then + board_id=$(get_board_id 2>/dev/null || true) + fi + if [[ -z "$board_id" ]]; then + die "Board required" $EXIT_USAGE "Use --board or set default board" + fi + + local resolved_board + if resolved_board=$(resolve_board_id "$board_id"); then + board_id="$resolved_board" + else + die "$RESOLVE_ERROR" $EXIT_NOT_FOUND "Use: fizzy boards" + fi + + # Fetch webhook name before delete + local webhook_data + webhook_data=$(api_get "/boards/$board_id/webhooks/$webhook_id" 2>/dev/null || echo '{}') + local webhook_name + webhook_name=$(echo "$webhook_data" | jq -r '.name // "unknown"') + + api_delete "/boards/$board_id/webhooks/$webhook_id" > /dev/null + + local result + result=$(jq -n --arg id "$webhook_id" --arg name "$webhook_name" \ + '{id: $id, name: $name, deleted: true}') + + local summary="Deleted webhook $webhook_name" + + local breadcrumbs + breadcrumbs=$(breadcrumbs \ + "$(breadcrumb "list" "fizzy webhooks --board $board_id" "List webhooks")" + ) + + output "$result" "$summary" "$breadcrumbs" "_webhook_deleted_md" +} + +_webhook_deleted_md() { + local data="$1" + local summary="$2" + local breadcrumbs="$3" + + local name + name=$(echo "$data" | jq -r '.name // "unknown"') + + md_heading 2 "Webhook Deleted" + echo + echo "Webhook **$name** has been deleted." + + md_breadcrumbs "$breadcrumbs" +} + +_webhook_delete_help() { + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + jq -n '{ + command: "fizzy webhook delete", + description: "Delete a webhook", + usage: "fizzy webhook delete --board ", + options: [ + {flag: "--board, -b", description: "Board ID or name"} + ], + examples: [ + "fizzy webhook delete abc123 --board \"My Board\"" + ] + }' + else + cat <<'EOF' +## fizzy webhook delete + +Delete a webhook. + +### Usage + + fizzy webhook delete --board + +### Options + + --board, -b Board ID or name + +### Examples + + fizzy webhook delete abc123 --board "My Board" +EOF + fi +} diff --git a/cli/lib/config.sh b/cli/lib/config.sh new file mode 100644 index 0000000000..db4eefef34 --- /dev/null +++ b/cli/lib/config.sh @@ -0,0 +1,566 @@ +#!/usr/bin/env bash +# config.sh - Layered configuration system for fizzy +# +# Config hierarchy (later overrides earlier): +# 1. /etc/fizzy/config.json (system-wide) +# 2. ~/.config/fizzy/config.json (user/global) +# 3. /.fizzy/config.json (repo) +# 4. /.fizzy/config.json (local, walks up tree) +# 5. Environment variables +# 6. Command-line flags + + +# Paths + +FIZZY_SYSTEM_CONFIG_DIR="${FIZZY_SYSTEM_CONFIG_DIR:-/etc/fizzy}" +FIZZY_GLOBAL_CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/fizzy" +FIZZY_LOCAL_CONFIG_DIR=".fizzy" +FIZZY_CONFIG_FILE="config.json" +FIZZY_CREDENTIALS_FILE="credentials.json" +FIZZY_CLIENT_FILE="client.json" +FIZZY_ACCOUNTS_FILE="accounts.json" + + +# Config Loading + +declare -A _FIZZY_CONFIG + +_load_config_file() { + local file="$1" + if [[ -f "$file" ]]; then + debug "Loading config from $file" + while IFS='=' read -r key value; do + _FIZZY_CONFIG["$key"]="$value" + done < <(jq -r 'to_entries | .[] | "\(.key)=\(.value)"' "$file" 2>/dev/null || true) + fi +} + +load_config() { + _FIZZY_CONFIG=() + + # Layer 1: System-wide config + _load_config_file "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + # Layer 2: User/global config + _load_config_file "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + # Layer 3: Git repo root config (if in a git repo) + local git_root + git_root=$(git rev-parse --show-toplevel 2>/dev/null || true) + if [[ -n "$git_root" ]] && [[ -f "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + _load_config_file "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + fi + + # Layer 4: Local config (walk up directory tree, skip git root if already loaded) + local dir="$PWD" + local local_configs=() + while [[ "$dir" != "/" ]]; do + local config_path="$dir/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + # Skip if this is the git root (already loaded above) + if [[ -f "$config_path" ]] && [[ "$dir" != "$git_root" ]]; then + local_configs+=("$config_path") + fi + dir="$(dirname "$dir")" + done + + # Apply local configs from root to current (so closer overrides) + for ((i=${#local_configs[@]}-1; i>=0; i--)); do + _load_config_file "${local_configs[$i]}" + done + + # Layer 5: Environment variables + # Note: FIZZY_BASE_URL is handled separately in _update_base_url_from_config + # because core.sh sets a default before we can capture the original env value + # Note: FIZZY_TOKEN is handled in get_access_token() directly, not via config + [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]] && _FIZZY_CONFIG["account_slug"]="$FIZZY_ACCOUNT_SLUG" || true + [[ -n "${FIZZY_BOARD_ID:-}" ]] && _FIZZY_CONFIG["board_id"]="$FIZZY_BOARD_ID" || true + [[ -n "${FIZZY_COLUMN_ID:-}" ]] && _FIZZY_CONFIG["column_id"]="$FIZZY_COLUMN_ID" || true + + # Layer 6: Command-line flags (already handled in global flag parsing) + [[ -n "${FIZZY_ACCOUNT:-}" ]] && _FIZZY_CONFIG["account_slug"]="$FIZZY_ACCOUNT" || true + [[ -n "${FIZZY_BOARD:-}" ]] && _FIZZY_CONFIG["board_id"]="$FIZZY_BOARD" || true +} + +get_config() { + local key="$1" + local default="${2:-}" + echo "${_FIZZY_CONFIG[$key]:-$default}" +} + +has_config() { + local key="$1" + [[ -n "${_FIZZY_CONFIG[$key]:-}" ]] +} + + +# Config Writing + +ensure_global_config_dir() { + mkdir -p "$FIZZY_GLOBAL_CONFIG_DIR" +} + +ensure_local_config_dir() { + mkdir -p "$FIZZY_LOCAL_CONFIG_DIR" +} + +set_global_config() { + local key="$1" + local value="$2" + + ensure_global_config_dir + local file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + if [[ -f "$file" ]]; then + local tmp + tmp=$(mktemp) + jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$file" > "$tmp" + mv "$tmp" "$file" + else + jq -n --arg key "$key" --arg value "$value" '{($key): $value}' > "$file" + fi + + _FIZZY_CONFIG["$key"]="$value" +} + +set_local_config() { + local key="$1" + local value="$2" + + ensure_local_config_dir + local file="$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + + if [[ -f "$file" ]]; then + local tmp + tmp=$(mktemp) + jq --arg key "$key" --arg value "$value" '.[$key] = $value' "$file" > "$tmp" + mv "$tmp" "$file" + else + jq -n --arg key "$key" --arg value "$value" '{($key): $value}' > "$file" + fi + + _FIZZY_CONFIG["$key"]="$value" +} + +unset_config() { + local key="$1" + local scope="${2:---local}" + + local file + if [[ "$scope" == "--global" ]]; then + file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + else + file="$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" + fi + + if [[ -f "$file" ]]; then + local tmp + tmp=$(mktemp) + jq --arg key "$key" 'del(.[$key])' "$file" > "$tmp" + mv "$tmp" "$file" + fi + + unset "_FIZZY_CONFIG[$key]" +} + + +# Multi-Origin Helpers +# +# Credentials, client registration, and accounts are all keyed by base URL +# to support multiple Fizzy instances (self-hosted, production, local dev). + +_normalize_base_url() { + # Remove trailing slash for consistent keys + local url="${1:-$FIZZY_BASE_URL}" + echo "${url%/}" +} + + +# Credentials + +get_credentials_path() { + echo "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CREDENTIALS_FILE" +} + +load_credentials() { + local file + file=$(get_credentials_path) + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then + echo '{}' + return + fi + + # Return credentials for current base URL + jq -r --arg url "$base_url" '.[$url] // {}' "$file" +} + +save_credentials() { + local json="$1" + ensure_global_config_dir + local file + file=$(get_credentials_path) + local base_url + base_url=$(_normalize_base_url) + + # Load existing multi-origin credentials + local existing='{}' + if [[ -f "$file" ]]; then + existing=$(cat "$file") + fi + + # Update credentials for current base URL + local updated + updated=$(echo "$existing" | jq --arg url "$base_url" --argjson creds "$json" '.[$url] = $creds') + echo "$updated" > "$file" + chmod 600 "$file" +} + +clear_credentials() { + local file + file=$(get_credentials_path) + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then + return + fi + + # Remove credentials for current base URL only + local updated + updated=$(jq --arg url "$base_url" 'del(.[$url])' "$file") + echo "$updated" > "$file" + chmod 600 "$file" +} + +# Add account_slug to per-origin credentials +# This ensures switching FIZZY_BASE_URL picks up the right account +set_credential_account_slug() { + local account_slug="$1" + local creds + creds=$(load_credentials) + + # Add account_slug to existing credentials + local updated + updated=$(echo "$creds" | jq --arg slug "$account_slug" '. + {account_slug: $slug}') + save_credentials "$updated" +} + +get_access_token() { + # Priority: FIZZY_TOKEN env → stored credentials + if [[ -n "${FIZZY_TOKEN:-}" ]]; then + echo "$FIZZY_TOKEN" + return + fi + + local creds + creds=$(load_credentials) + local token + token=$(echo "$creds" | jq -r '.access_token // empty') + + if [[ -z "$token" ]]; then + return 1 + fi + + echo "$token" +} + +# Detect auth type: "token_env" | "oauth" | "none" +get_auth_type() { + if [[ -n "${FIZZY_TOKEN:-}" ]]; then + echo "token_env" + return + fi + + local creds + creds=$(load_credentials) + local token + token=$(echo "$creds" | jq -r '.access_token // empty') + if [[ -n "$token" ]]; then + echo "oauth" + return + fi + + echo "none" +} + +is_token_expired() { + # Env var tokens never expire + if [[ -n "${FIZZY_TOKEN:-}" ]]; then + return 1 # Not expired + fi + + local creds + creds=$(load_credentials) + local expires_at + expires_at=$(echo "$creds" | jq -r '.expires_at // "null"') + + # Long-lived tokens (null or 0 expires_at) never expire + if [[ "$expires_at" == "null" ]] || [[ "$expires_at" == "0" ]]; then + return 1 # Not expired + fi + + local now + now=$(date +%s) + (( now > expires_at - 60 )) +} + +get_token_scope() { + local creds + creds=$(load_credentials) + local scope + scope=$(echo "$creds" | jq -r '.scope // empty') + + if [[ -z "$scope" ]]; then + echo "unknown" + return 1 + fi + + echo "$scope" +} + + +# Account Management + +get_account_slug() { + # Priority: env var → per-origin credentials → global config + if [[ -n "${FIZZY_ACCOUNT:-}" ]]; then + echo "$FIZZY_ACCOUNT" + return + fi + if [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]]; then + echo "$FIZZY_ACCOUNT_SLUG" + return + fi + + # Check per-origin credentials (stored alongside access_token) + local creds + creds=$(load_credentials) + local origin_account + origin_account=$(echo "$creds" | jq -r '.account_slug // empty') + if [[ -n "$origin_account" ]]; then + echo "$origin_account" + return + fi + + # Fall back to global config + get_config "account_slug" +} + +get_board_id() { + if [[ -n "${FIZZY_BOARD:-}" ]]; then + echo "$FIZZY_BOARD" + return + fi + if [[ -n "${FIZZY_BOARD_ID:-}" ]]; then + echo "$FIZZY_BOARD_ID" + return + fi + get_config "board_id" +} + +get_column_id() { + if [[ -n "${FIZZY_COLUMN_ID:-}" ]]; then + echo "$FIZZY_COLUMN_ID" + return + fi + get_config "column_id" +} + +get_git_root() { + git rev-parse --show-toplevel 2>/dev/null || true +} + +load_accounts() { + local file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_ACCOUNTS_FILE" + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then + echo '[]' + return + fi + + # Return accounts for current base URL + jq -r --arg url "$base_url" '.[$url] // []' "$file" +} + +save_accounts() { + local json="$1" + ensure_global_config_dir + local file="$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_ACCOUNTS_FILE" + local base_url + base_url=$(_normalize_base_url) + + # Load existing multi-origin accounts + local existing='{}' + if [[ -f "$file" ]]; then + existing=$(cat "$file") + fi + + # Update accounts for current base URL + local updated + updated=$(echo "$existing" | jq --arg url "$base_url" --argjson accts "$json" '.[$url] = $accts') + echo "$updated" > "$file" +} + + +# Client Registration + +get_client_path() { + echo "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CLIENT_FILE" +} + +load_client() { + local file + file=$(get_client_path) + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then + echo '{}' + return + fi + + # Return client for current base URL + jq -r --arg url "$base_url" '.[$url] // {}' "$file" +} + +save_client() { + local json="$1" + ensure_global_config_dir + local file + file=$(get_client_path) + local base_url + base_url=$(_normalize_base_url) + + # Load existing multi-origin clients + local existing='{}' + if [[ -f "$file" ]]; then + existing=$(cat "$file") + fi + + # Update client for current base URL + local updated + updated=$(echo "$existing" | jq --arg url "$base_url" --argjson client "$json" '.[$url] = $client') + echo "$updated" > "$file" + chmod 600 "$file" +} + +clear_client() { + local file + file=$(get_client_path) + local base_url + base_url=$(_normalize_base_url) + + if [[ ! -f "$file" ]]; then + return + fi + + # Remove client for current base URL only + local updated + updated=$(jq --arg url "$base_url" 'del(.[$url])' "$file") + echo "$updated" > "$file" + chmod 600 "$file" +} + +get_client_id() { + local client + client=$(load_client) + echo "$client" | jq -r '.client_id // empty' +} + +get_client_secret() { + local client + client=$(load_client) + echo "$client" | jq -r '.client_secret // empty' +} + + +# Config Display + +get_effective_config() { + local result='{}' + for key in "${!_FIZZY_CONFIG[@]}"; do + if [[ "$key" == "access_token" ]] || [[ "$key" == "refresh_token" ]]; then + result=$(echo "$result" | jq --arg key "$key" '.[$key] = "***"') + else + result=$(echo "$result" | jq --arg key "$key" --arg value "${_FIZZY_CONFIG[$key]}" '.[$key] = $value') + fi + done + echo "$result" +} + +get_config_source() { + local key="$1" + + # Check flags first (highest priority) + case "$key" in + account_slug) [[ -n "${FIZZY_ACCOUNT:-}" ]] && echo "flag" && return ;; + board_id) [[ -n "${FIZZY_BOARD:-}" ]] && echo "flag" && return ;; + esac + + # Check environment variables + case "$key" in + account_slug) [[ -n "${FIZZY_ACCOUNT_SLUG:-}" ]] && echo "env" && return ;; + board_id) [[ -n "${FIZZY_BOARD_ID:-}" ]] && echo "env" && return ;; + column_id) [[ -n "${FIZZY_COLUMN_ID:-}" ]] && echo "env" && return ;; + esac + + # Check local (cwd) config + if [[ -f "$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local local_value + local_value=$(jq -r --arg key "$key" '.[$key] // empty' "$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$local_value" ]] && echo "local (.fizzy/)" && return + fi + + # Check git repo root config + local git_root + git_root=$(get_git_root) + if [[ -n "$git_root" ]] && [[ -f "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local repo_value + repo_value=$(jq -r --arg key "$key" '.[$key] // empty' "$git_root/$FIZZY_LOCAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$repo_value" ]] && echo "repo ($git_root/.fizzy/)" && return + fi + + # Check user/global config + if [[ -f "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local global_value + global_value=$(jq -r --arg key "$key" '.[$key] // empty' "$FIZZY_GLOBAL_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$global_value" ]] && echo "user (~/.config/fizzy/)" && return + fi + + # Check system-wide config + if [[ -f "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" ]]; then + local system_value + system_value=$(jq -r --arg key "$key" '.[$key] // empty' "$FIZZY_SYSTEM_CONFIG_DIR/$FIZZY_CONFIG_FILE" 2>/dev/null) + [[ -n "$system_value" ]] && echo "system (/etc/fizzy/)" && return + fi + + echo "unset" +} + + +# Initialize + +load_config + +# Update FIZZY_BASE_URL from full config hierarchy +# This should override the initial value from core.sh (which only reads global config) +# unless FIZZY_BASE_URL was explicitly set via environment variable +_update_base_url_from_config() { + # If user explicitly set FIZZY_BASE_URL env var, respect it + if [[ -n "${_FIZZY_BASE_URL_FROM_ENV:-}" ]]; then + return + fi + + # Otherwise, apply full config hierarchy (local > repo > global > system) + local base_url + base_url=$(get_config "base_url") + if [[ -n "$base_url" ]]; then + FIZZY_BASE_URL="$base_url" + fi +} + +_update_base_url_from_config diff --git a/cli/lib/core.sh b/cli/lib/core.sh new file mode 100644 index 0000000000..16c60b4188 --- /dev/null +++ b/cli/lib/core.sh @@ -0,0 +1,418 @@ +#!/usr/bin/env bash +# core.sh - Core utilities for fizzy +# Output formatting, response envelope, global flags + +# Environment Configuration +# FIZZY_URL - Convenience: base URL + optional account slug (e.g., http://fizzy.localhost:3006/897362094) +# FIZZY_BASE_URL - Base URL for Fizzy (web app + API) +# In production: https://app.fizzy.do +# In development: http://fizzy.localhost:3006 + +# Parse FIZZY_URL into FIZZY_BASE_URL + FIZZY_ACCOUNT_SLUG (if not explicitly set) +# Accepts: http://host or http://host/897362094 (numeric 7+ digit slug) +if [[ -n "${FIZZY_URL:-}" ]]; then + _fizzy_url="${FIZZY_URL%%\#*}" # Strip fragment + _fizzy_url="${_fizzy_url%%\?*}" # Strip query + _fizzy_url="${_fizzy_url%/}" # Strip trailing slash + + if [[ "$_fizzy_url" =~ ^(https?://[^/]+)(/.*)?$ ]]; then + _fizzy_base="${BASH_REMATCH[1]}" + _fizzy_path="${BASH_REMATCH[2]:-}" + _fizzy_path="${_fizzy_path#/}" + _fizzy_slug="${_fizzy_path%%/*}" + + # Set base URL if not explicitly provided + if [[ -z "${FIZZY_BASE_URL:-}" ]]; then + FIZZY_BASE_URL="$_fizzy_base" + fi + + # Set account slug if not explicitly provided and bases match + if [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] && [[ "${FIZZY_BASE_URL:-}" == "$_fizzy_base" ]]; then + # Only accept numeric slugs (7+ digits = external_account_id) + if [[ "$_fizzy_slug" =~ ^[0-9]{7,}$ ]]; then + FIZZY_ACCOUNT_SLUG="$_fizzy_slug" + fi + fi + fi + unset _fizzy_url _fizzy_base _fizzy_path _fizzy_slug +fi + +# Capture original env value before any modifications (used by config.sh) +# This marker tells config.sh whether FIZZY_BASE_URL was explicitly set by user +_FIZZY_BASE_URL_FROM_ENV="${FIZZY_BASE_URL:-}" + +# Load URL from config if not set via environment +# Note: This only reads global config; config.sh will later apply full hierarchy +_load_url_config() { + local config_file="${XDG_CONFIG_HOME:-$HOME/.config}/fizzy/config.json" + if [[ -f "$config_file" ]]; then + local base_url + base_url=$(jq -r '.base_url // empty' "$config_file" 2>/dev/null) || true + if [[ -z "${FIZZY_BASE_URL:-}" ]] && [[ -n "$base_url" ]]; then + FIZZY_BASE_URL="$base_url" + fi + fi +} + +_load_url_config + +# Apply defaults if still not set +FIZZY_BASE_URL="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + + +# Global State + +FIZZY_FORMAT="${FIZZY_FORMAT:-auto}" # Output format: auto, json, md +FIZZY_QUIET="${FIZZY_QUIET:-false}" # Suppress non-essential output +FIZZY_VERBOSE="${FIZZY_VERBOSE:-false}" # Debug output +GLOBAL_FLAGS_CONSUMED=0 # For shift in main + + +# Output Format Detection + +is_tty() { + [[ -t 1 ]] +} + +get_format() { + case "$FIZZY_FORMAT" in + json) echo "json" ;; + md|markdown) echo "md" ;; + auto) + if is_tty; then + echo "md" + else + echo "json" + fi + ;; + *) echo "json" ;; + esac +} + + +# JSON Response Building + +json_ok() { + local data="$1" + local summary="${2:-}" + local breadcrumbs="${3:-[]}" + local context="${4:-"{}"}" + local meta="${5:-"{}"}" + + jq -n \ + --argjson data "$data" \ + --arg summary "$summary" \ + --argjson breadcrumbs "$breadcrumbs" \ + --argjson context "$context" \ + --argjson meta "$meta" \ + '{ + ok: true, + data: $data, + summary: $summary, + breadcrumbs: $breadcrumbs, + context: $context, + meta: $meta + }' +} + +json_error() { + local message="$1" + local code="${2:-error}" + local hint="${3:-}" + + if [[ -n "$hint" ]]; then + jq -n \ + --arg message "$message" \ + --arg code "$code" \ + --arg hint "$hint" \ + '{ok: false, error: $message, code: $code, hint: $hint}' + else + jq -n \ + --arg message "$message" \ + --arg code "$code" \ + '{ok: false, error: $message, code: $code}' + fi +} + + +# Breadcrumb Generation + +breadcrumb() { + local action="$1" + local cmd="$2" + local description="${3:-}" + + jq -n \ + --arg action "$action" \ + --arg cmd "$cmd" \ + --arg description "$description" \ + '{action: $action, cmd: $cmd, description: $description}' +} + +breadcrumbs() { + local result="[" + local first=true + for bc in "$@"; do + if [[ "$first" == "true" ]]; then + first=false + else + result+="," + fi + result+="$bc" + done + result+="]" + echo "$result" +} + + +# Markdown Output + +md_heading() { + local level="${1:-2}" + local text="$2" + local prefix="" + for ((i=0; i /dev/null; then + "$md_renderer" "$data" "$summary" "$breadcrumbs" "$context" "$meta" + else + if [[ -n "$summary" ]]; then + echo "$summary" + echo + fi + echo '```json' + echo "$data" | jq . + echo '```' + echo + md_breadcrumbs "$breadcrumbs" + fi + fi +} + +output_error() { + local message="$1" + local code="${2:-error}" + local hint="${3:-}" + + local format + format=$(get_format) + + if [[ "$format" == "json" ]]; then + json_error "$message" "$code" "$hint" >&2 + else + echo "Error: $message" >&2 + if [[ -n "$hint" ]]; then + echo >&2 + echo "$hint" >&2 + fi + fi +} + + +# Exit Codes + +EXIT_OK=0 +EXIT_USAGE=1 +EXIT_NOT_FOUND=2 +EXIT_AUTH=3 +EXIT_FORBIDDEN=4 +EXIT_RATE_LIMIT=5 +EXIT_NETWORK=6 +EXIT_API=7 +EXIT_AMBIGUOUS=8 + +die() { + local message="$1" + local code="${2:-1}" + local hint="${3:-}" + + local error_code="error" + case "$code" in + $EXIT_USAGE) error_code="usage" ;; + $EXIT_NOT_FOUND) error_code="not_found" ;; + $EXIT_AUTH) error_code="auth_required" ;; + $EXIT_FORBIDDEN) error_code="forbidden" ;; + $EXIT_RATE_LIMIT) error_code="rate_limit" ;; + $EXIT_NETWORK) error_code="network" ;; + $EXIT_API) error_code="api_error" ;; + $EXIT_AMBIGUOUS) error_code="ambiguous" ;; + esac + + output_error "$message" "$error_code" "$hint" + exit "$code" +} + + +# Global Flag Parsing + +parse_global_flags() { + GLOBAL_FLAGS_CONSUMED=0 + + while [[ $# -gt 0 ]]; do + case "$1" in + --json|-j) + FIZZY_FORMAT="json" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --md|-m|--markdown) + FIZZY_FORMAT="md" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --quiet|-q|--data) + FIZZY_QUIET="true" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --verbose|-v) + FIZZY_VERBOSE="true" + (( ++GLOBAL_FLAGS_CONSUMED )) + shift + ;; + --board|-b|--in) + if [[ -z "${2:-}" ]]; then + die "--board requires a value" $EXIT_USAGE + fi + FIZZY_BOARD="$2" + (( GLOBAL_FLAGS_CONSUMED += 2 )) + shift 2 + ;; + --account|-a) + if [[ -z "${2:-}" ]]; then + die "--account requires a value" $EXIT_USAGE + fi + FIZZY_ACCOUNT="$2" + (( GLOBAL_FLAGS_CONSUMED += 2 )) + shift 2 + ;; + --) + (( ++GLOBAL_FLAGS_CONSUMED )) + break + ;; + -*) + break + ;; + *) + break + ;; + esac + done +} + + +# Logging + +debug() { + if [[ "$FIZZY_VERBOSE" == "true" ]]; then + echo "[debug] $*" >&2 + fi +} + +info() { + if [[ "$FIZZY_QUIET" != "true" ]]; then + echo "$*" >&2 + fi +} + +warn() { + echo "Warning: $*" >&2 +} + + +# Utilities + +has_command() { + command -v "$1" &> /dev/null +} + +require_command() { + local cmd="$1" + local hint="${2:-Please install $cmd}" + if ! has_command "$cmd"; then + die "Required command not found: $cmd" $EXIT_USAGE "$hint" + fi +} + +urlencode() { + local string="$1" + # Use jq for URL encoding - no Python dependency needed + printf '%s' "$string" | jq -sRr @uri +} diff --git a/cli/lib/names.sh b/cli/lib/names.sh new file mode 100644 index 0000000000..e1211f8cf4 --- /dev/null +++ b/cli/lib/names.sh @@ -0,0 +1,438 @@ +#!/usr/bin/env bash +# names.sh - Name resolution for fizzy +# +# Allows using human-readable names instead of IDs for boards and users. +# Uses a session cache to avoid repeated API calls. + + +# Cache directory (session-scoped temp files) +_FIZZY_CACHE_DIR="${TMPDIR:-/tmp}/fizzy-cache-$$" + +# Global error message for resolution failures +# Initialized here so it's always declared (resolver functions run in subshells +# via command substitution, so their assignments don't reach the parent shell) +RESOLVE_ERROR="" + + +# Cache Management + +_ensure_cache_dir() { + if [[ ! -d "$_FIZZY_CACHE_DIR" ]]; then + mkdir -p "$_FIZZY_CACHE_DIR" + fi +} + +_get_cache() { + local type="$1" + local file="$_FIZZY_CACHE_DIR/${type}.json" + if [[ -f "$file" ]]; then + cat "$file" + fi +} + +_set_cache() { + local type="$1" + local data="$2" + _ensure_cache_dir + echo "$data" > "$_FIZZY_CACHE_DIR/${type}.json" +} + +_clear_cache() { + rm -rf "$_FIZZY_CACHE_DIR" +} + + +# Board Resolution + +# Resolve a board name or ID to an ID +# Args: $1 - board name, partial name, or ID +# Returns: board ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_board_id() { + local input="$1" + RESOLVE_ERROR="" + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch boards (with cache) - use api_get_all to handle pagination + local boards + boards=$(_get_cache "boards") + if [[ -z "$boards" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + boards=$(api_get_all "/boards") || return 1 + _set_cache "boards" "$boards" + fi + + # Try exact match first + local exact_match + exact_match=$(echo "$boards" | jq -r --arg name "$input" \ + '.[] | select(.name == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive match + local ci_matches + ci_matches=$(echo "$boards" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count + ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial match (contains) + local partial_matches + partial_matches=$(echo "$boards" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + local partial_count + partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous board name '$input' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="Board not found: $input" + local suggestions + suggestions=$(_suggest_similar "$input" "$boards" "name") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + +# Get cached boards list (for suggestions) +get_boards_list() { + local boards + boards=$(_get_cache "boards") + if [[ -z "$boards" ]]; then + boards=$(api_get "/boards") || return 1 + _set_cache "boards" "$boards" + fi + echo "$boards" +} + + +# User Resolution + +# Resolve a user name, email, or ID to an ID +# Args: $1 - user name, email, partial name, or ID +# Returns: user ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_user_id() { + local input="$1" + RESOLVE_ERROR="" + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch users (with cache) - use api_get_all to handle pagination + local users + users=$(_get_cache "users") + if [[ -z "$users" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + users=$(api_get_all "/users") || return 1 + _set_cache "users" "$users" + fi + + # Try exact email match first + if [[ "$input" == *@* ]]; then + local email_match + email_match=$(echo "$users" | jq -r --arg email "$input" \ + '.[] | select(.email_address == $email) | .id' | head -1) + if [[ -n "$email_match" ]]; then + echo "$email_match" + return 0 + fi + fi + + # Try exact name match + local exact_match + exact_match=$(echo "$users" | jq -r --arg name "$input" \ + '.[] | select(.name == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive name match + local ci_matches + ci_matches=$(echo "$users" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count + ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial name match (contains) + local partial_matches + partial_matches=$(echo "$users" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + local partial_count + partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous user name '$input' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="User not found: $input" + local suggestions + suggestions=$(_suggest_similar "$input" "$users" "name") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + +# Get cached users list (for suggestions) +get_users_list() { + local users + users=$(_get_cache "users") + if [[ -z "$users" ]]; then + users=$(api_get "/users") || return 1 + _set_cache "users" "$users" + fi + echo "$users" +} + + +# Column Resolution + +# Resolve a column name or ID to an ID (within a board) +# Args: $1 - column name, partial name, or ID +# $2 - board ID (required) +# Returns: column ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_column_id() { + local input="$1" + local board_id="$2" + RESOLVE_ERROR="" + + if [[ -z "$board_id" ]]; then + RESOLVE_ERROR="Board ID required for column resolution" + return 1 + fi + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch columns (with cache per board) + local cache_key="columns_${board_id}" + local columns + columns=$(_get_cache "$cache_key") + if [[ -z "$columns" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + columns=$(api_get "/boards/$board_id/columns") || return 1 + _set_cache "$cache_key" "$columns" + fi + + # Try exact match first + local exact_match + exact_match=$(echo "$columns" | jq -r --arg name "$input" \ + '.[] | select(.name == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive match + local ci_matches + ci_matches=$(echo "$columns" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial match (contains) + local partial_matches + partial_matches=$(echo "$columns" | jq -r --arg name "$input" \ + '.[] | select(.name | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.name)"') + local partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous column name '$input' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="Column not found: $input" + local suggestions + suggestions=$(_suggest_similar "$input" "$columns" "name") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + + +# Tag Resolution + +# Resolve a tag name or ID to an ID +# Args: $1 - tag name, partial name, or ID +# Returns: tag ID (or empty if not found) +# Sets: RESOLVE_ERROR with error message if ambiguous/not found +resolve_tag_id() { + local input="$1" + RESOLVE_ERROR="" + + # If it looks like a UUID (25+ chars, alphanumeric), assume it's an ID + if [[ "$input" =~ ^[a-z0-9]{20,}$ ]]; then + echo "$input" + return 0 + fi + + # Fetch tags (with cache) - use api_get_all to handle pagination + local tags + tags=$(_get_cache "tags") + if [[ -z "$tags" ]]; then + # Don't suppress stderr - let auth errors propagate and exit + tags=$(api_get_all "/tags") || return 1 + _set_cache "tags" "$tags" + fi + + # Strip leading # if present (users may type #bug or bug) + local search_term="${input#\#}" + + # Try exact match first (API returns .title, not .name) + local exact_match + exact_match=$(echo "$tags" | jq -r --arg name "$search_term" \ + '.[] | select(.title == $name) | .id' | head -1) + if [[ -n "$exact_match" ]]; then + echo "$exact_match" + return 0 + fi + + # Try case-insensitive match + local ci_matches + ci_matches=$(echo "$tags" | jq -r --arg name "$search_term" \ + '.[] | select(.title | ascii_downcase == ($name | ascii_downcase)) | .id') + local ci_count + ci_count=0 + [[ -n "$ci_matches" ]] && ci_count=$(echo "$ci_matches" | grep -c . || true) + if [[ "$ci_count" -eq 1 ]]; then + echo "$ci_matches" + return 0 + fi + + # Try partial match (contains) + local partial_matches + partial_matches=$(echo "$tags" | jq -r --arg name "$search_term" \ + '.[] | select(.title | ascii_downcase | contains($name | ascii_downcase)) | "\(.id):\(.title)"') + local partial_count + partial_count=0 + [[ -n "$partial_matches" ]] && partial_count=$(echo "$partial_matches" | grep -c . || true) + + if [[ "$partial_count" -eq 1 ]]; then + echo "$partial_matches" | cut -d: -f1 + return 0 + elif [[ "$partial_count" -gt 1 ]]; then + local names + names=$(echo "$partial_matches" | cut -d: -f2- | tr '\n' ',' | sed 's/,$//') + RESOLVE_ERROR="Ambiguous tag '$search_term' matches: $names" + return 1 + fi + + # Not found - provide suggestions + RESOLVE_ERROR="Tag not found: $search_term" + local suggestions + suggestions=$(_suggest_similar "$search_term" "$tags" "title") + if [[ -n "$suggestions" ]]; then + RESOLVE_ERROR="$RESOLVE_ERROR. Did you mean: $suggestions?" + fi + return 1 +} + + +# Suggestion Helpers + +# Suggest similar names using simple substring/distance matching +# Args: $1 - input string +# $2 - JSON array of objects +# $3 - field name to match against +# Returns: comma-separated list of similar names (up to 3) +_suggest_similar() { + local input="$1" + local json_array="$2" + local field="$3" + + # Get all names + local names + names=$(echo "$json_array" | jq -r ".[].$field") + + # Find names that share a common prefix (first 3 chars) + # Use grep -iF for fixed-string matching to avoid regex errors from special chars + local prefix="${input:0:3}" + local suggestions + suggestions=$(echo "$names" | grep -iF "$prefix" | head -3 | tr '\n' ',' | sed 's/,$//') + + # If no prefix matches, try substring matches + if [[ -z "$suggestions" ]]; then + suggestions=$(echo "$names" | grep -iF "$input" | head -3 | tr '\n' ',' | sed 's/,$//') + fi + + # If still nothing, return first few available options + if [[ -z "$suggestions" ]]; then + suggestions=$(echo "$names" | head -3 | tr '\n' ',' | sed 's/,$//') + fi + + echo "$suggestions" +} + + +# Error Message Formatting + +# Format resolution error for die() with hint +# Args: $1 - entity type (board, user, column, tag) +# $2 - the failed input +# Uses: RESOLVE_ERROR +format_resolve_error() { + local type="$1" + local input="$2" + + if [[ -n "$RESOLVE_ERROR" ]]; then + echo "$RESOLVE_ERROR" + else + echo "${type^} not found: $input" + fi +} diff --git a/cli/test/actions.bats b/cli/test/actions.bats new file mode 100644 index 0000000000..728ff0bcc0 --- /dev/null +++ b/cli/test/actions.bats @@ -0,0 +1,1133 @@ +#!/usr/bin/env bats +# actions.bats - Tests for card action commands (Phase 3) + +load test_helper + + +# card (create) --help + +@test "card --help shows help" { + run fizzy --md card --help + assert_success + assert_output_contains "fizzy card" + assert_output_contains "Create" +} + +@test "card -h shows help" { + run fizzy --md card -h + assert_success + assert_output_contains "fizzy card" +} + +@test "card --help --json outputs JSON" { + run fizzy --json card --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# card requires title + +@test "card without title shows error" { + run fizzy card + assert_failure + assert_output_contains "title required" +} + +@test "card requires board" { + run fizzy card "Test" + assert_failure + assert_output_contains "No board specified" +} + +@test "card requires authentication with board" { + create_local_config '{"board_id": "test-board-id"}' + run fizzy card "Test" + assert_failure + assert_output_contains "Not authenticated" +} + +@test "card rejects unknown option" { + run fizzy card --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + + +# card update --help + +@test "card update --help shows help" { + run fizzy --md card update --help + assert_success + assert_output_contains "fizzy card update" + assert_output_contains "Update" +} + +@test "card update -h shows help" { + run fizzy --md card update -h + assert_success + assert_output_contains "fizzy card update" +} + +@test "card update --help --json outputs JSON" { + run fizzy --json card update --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# card update requires card number and options + +@test "card update without number shows error" { + run fizzy card update + assert_failure + assert_output_contains "Card number required" +} + +@test "card update without options shows error" { + run fizzy card update 123 + assert_failure + assert_output_contains "Nothing to update" +} + +@test "card update requires authentication" { + run fizzy card update 123 --title "New" + assert_failure + assert_output_contains "Not authenticated" +} + +@test "card update rejects unknown option" { + run fizzy card update --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + +@test "card update --description-file with missing file shows error" { + run fizzy card update 123 --description-file nonexistent.txt + assert_failure + assert_output_contains "File not found" +} + +@test "card update --image with missing file shows error" { + run fizzy card update 123 --image nonexistent.png + assert_failure + assert_output_contains "File not found" +} + +@test "card update --help documents --image flag" { + run fizzy --md card update --help + assert_success + assert_output_contains "--image" +} + +@test "card create --image with missing file shows error" { + run fizzy card "Test card" --board testboard --image nonexistent.png + assert_failure + assert_output_contains "File not found" +} + +@test "card create --help documents --image flag" { + run fizzy --md card --help + assert_success + assert_output_contains "--image" +} + +# Regression test: non-file multipart fields must use --form-string to prevent +# values starting with @ from being interpreted as file paths +@test "multipart uploads use --form-string for non-file fields" { + # Check that card title/description don't use -F (which interprets @-prefix as file) + run grep -E '\-F ["\047]card\[(title|description)\]' lib/commands/actions.sh + assert_failure # Should NOT find this pattern + + # Check that user name doesn't use -F + run grep -E '\-F ["\047]user\[name\]' lib/commands/users.sh + assert_failure # Should NOT find this pattern +} + + +# close --help + +@test "close --help shows help" { + run fizzy --md close --help + assert_success + assert_output_contains "fizzy close" + assert_output_contains "Close" +} + +@test "close -h shows help" { + run fizzy --md close -h + assert_success + assert_output_contains "fizzy close" +} + +@test "close --help --json outputs JSON" { + run fizzy --json close --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# close requires card number + +@test "close without number shows error" { + run fizzy close + assert_failure + assert_output_contains "Card number required" +} + +@test "close requires authentication" { + run fizzy close 123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "close rejects unknown option" { + run fizzy close --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + + +# reopen --help + +@test "reopen --help shows help" { + run fizzy --md reopen --help + assert_success + assert_output_contains "fizzy reopen" + assert_output_contains "Reopen" +} + +@test "reopen -h shows help" { + run fizzy --md reopen -h + assert_success + assert_output_contains "fizzy reopen" +} + +@test "reopen --help --json outputs JSON" { + run fizzy --json reopen --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# reopen requires card number + +@test "reopen without number shows error" { + run fizzy reopen + assert_failure + assert_output_contains "Card number required" +} + +@test "reopen requires authentication" { + run fizzy reopen 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# card delete --help + +@test "card delete --help shows help" { + run fizzy --md card delete --help + assert_success + assert_output_contains "fizzy card delete" + assert_output_contains "delete" +} + +@test "card delete -h shows help" { + run fizzy --md card delete -h + assert_success + assert_output_contains "fizzy card delete" +} + +@test "card delete --help --json outputs JSON" { + run fizzy --json card delete --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + +@test "card delete --help shows warning" { + run fizzy --md card delete --help + assert_success + assert_output_contains "cannot be undone" +} + + +# card delete requires card number + +@test "card delete without number shows error" { + run fizzy card delete + assert_failure + assert_output_contains "Card number required" +} + +@test "card delete requires authentication" { + run fizzy card delete 123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "card delete rejects unknown option" { + run fizzy card delete --invalid + assert_failure + assert_output_contains "Unknown option" +} + +@test "card delete rejects non-numeric input" { + run fizzy card delete abc + assert_failure + assert_output_contains "Invalid card number" +} + +@test "card delete rejects zero" { + run fizzy card delete 0 + assert_failure + assert_output_contains "Invalid card number: 0" +} + +@test "card delete rejects mixed valid and invalid numbers" { + run fizzy card delete 123 abc 456 + assert_failure + assert_output_contains "Invalid card number: abc" +} + + +# card image --help + +@test "card image --help shows subcommands" { + run fizzy --md card image --help + assert_success + assert_output_contains "fizzy card image" + assert_output_contains "delete" +} + +@test "card image without subcommand shows help" { + run fizzy --md card image + assert_success + assert_output_contains "Subcommands" +} + +@test "card image --help --json outputs JSON" { + run fizzy --json card image --help + assert_success + is_valid_json + assert_json_not_null ".subcommands" +} + + +# card image delete --help + +@test "card image delete --help shows help" { + run fizzy --md card image delete --help + assert_success + assert_output_contains "fizzy card image delete" + assert_output_contains "image" +} + +@test "card image delete -h shows help" { + run fizzy --md card image delete -h + assert_success + assert_output_contains "fizzy card image delete" +} + +@test "card image delete --help --json outputs JSON" { + run fizzy --json card image delete --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# card image delete requires card number + +@test "card image delete without number shows error" { + run fizzy card image delete + assert_failure + assert_output_contains "Card number required" +} + +@test "card image delete requires authentication" { + run fizzy card image delete 123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "card image delete rejects unknown option" { + run fizzy card image delete --invalid + assert_failure + assert_output_contains "Unknown option" +} + + +# triage --help + +@test "triage --help shows help" { + run fizzy --md triage --help + assert_success + assert_output_contains "fizzy triage" + assert_output_contains "Move card" +} + +@test "triage -h shows help" { + run fizzy --md triage -h + assert_success + assert_output_contains "fizzy triage" +} + +@test "triage --help --json outputs JSON" { + run fizzy --json triage --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# triage requires parameters + +@test "triage without number shows error" { + run fizzy triage + assert_failure + assert_output_contains "Card number required" +} + +@test "triage without --to shows error" { + run fizzy triage 123 + assert_failure + assert_output_contains "column ID required" +} + +@test "triage without board context shows resolution error" { + run fizzy triage 123 --to col456 + assert_failure + assert_output_contains "Cannot resolve column name without board context" +} + + +# untriage --help + +@test "untriage --help shows help" { + run fizzy --md untriage --help + assert_success + assert_output_contains "fizzy untriage" + assert_output_contains "triage" +} + +@test "untriage -h shows help" { + run fizzy --md untriage -h + assert_success + assert_output_contains "fizzy untriage" +} + +@test "untriage --help --json outputs JSON" { + run fizzy --json untriage --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# untriage requires card number + +@test "untriage without number shows error" { + run fizzy untriage + assert_failure + assert_output_contains "Card number required" +} + +@test "untriage requires authentication" { + run fizzy untriage 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# postpone --help + +@test "postpone --help shows help" { + run fizzy --md postpone --help + assert_success + assert_output_contains "fizzy postpone" + assert_output_contains "Not Now" +} + +@test "postpone -h shows help" { + run fizzy --md postpone -h + assert_success + assert_output_contains "fizzy postpone" +} + +@test "postpone --help --json outputs JSON" { + run fizzy --json postpone --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# postpone requires card number + +@test "postpone without number shows error" { + run fizzy postpone + assert_failure + assert_output_contains "Card number required" +} + +@test "postpone requires authentication" { + run fizzy postpone 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# comment --help + +@test "comment --help shows help" { + run fizzy --md comment --help + assert_success + assert_output_contains "fizzy comment" + assert_output_contains "Add comment" +} + +@test "comment -h shows help" { + run fizzy --md comment -h + assert_success + assert_output_contains "fizzy comment" +} + +@test "comment --help --json outputs JSON" { + run fizzy --json comment --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# comment requires parameters + +@test "comment without text shows error" { + run fizzy comment + assert_failure + assert_output_contains "content required" +} + +@test "comment without --on shows error" { + run fizzy comment "Test comment" + assert_failure + assert_output_contains "card number required" +} + +@test "comment requires authentication" { + run fizzy comment "Test" --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# comment edit --help + +@test "comment edit --help shows help" { + run fizzy --md comment edit --help + assert_success + assert_output_contains "fizzy comment edit" + assert_output_contains "Update" +} + +@test "comment edit -h shows help" { + run fizzy --md comment edit -h + assert_success + assert_output_contains "fizzy comment edit" +} + +@test "comment edit --help --json outputs JSON" { + run fizzy --json comment edit --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# comment edit requires arguments + +@test "comment edit without args shows error" { + run fizzy comment edit + assert_failure + assert_output_contains "Comment ID required" +} + +@test "comment edit without --on shows error" { + run fizzy comment edit abc123 "new text" + assert_failure + assert_output_contains "--on card number required" +} + +@test "comment edit without new text shows error" { + run fizzy comment edit abc123 --on 123 + assert_failure + assert_output_contains "New comment text required" +} + +@test "comment edit requires authentication" { + run fizzy comment edit abc123 --on 123 "new text" + assert_failure + assert_output_contains "Not authenticated" +} + + +# comment delete --help + +@test "comment delete --help shows help" { + run fizzy --md comment delete --help + assert_success + assert_output_contains "fizzy comment delete" + assert_output_contains "Delete" +} + +@test "comment delete -h shows help" { + run fizzy --md comment delete -h + assert_success + assert_output_contains "fizzy comment delete" +} + +@test "comment delete --help --json outputs JSON" { + run fizzy --json comment delete --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# comment delete requires arguments + +@test "comment delete without args shows error" { + run fizzy comment delete + assert_failure + assert_output_contains "Comment ID required" +} + +@test "comment delete without --on shows error" { + run fizzy comment delete abc123 + assert_failure + assert_output_contains "--on card number required" +} + +@test "comment delete requires authentication" { + run fizzy comment delete abc123 --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# assign --help + +@test "assign --help shows help" { + run fizzy --md assign --help + assert_success + assert_output_contains "fizzy assign" + assert_output_contains "Toggle assignment" +} + +@test "assign -h shows help" { + run fizzy --md assign -h + assert_success + assert_output_contains "fizzy assign" +} + +@test "assign --help --json outputs JSON" { + run fizzy --json assign --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# assign requires parameters + +@test "assign without number shows error" { + run fizzy assign + assert_failure + assert_output_contains "Card number required" +} + +@test "assign without --to shows error" { + run fizzy assign 123 + assert_failure + assert_output_contains "user ID required" +} + +@test "assign requires authentication" { + run fizzy assign 123 --to user456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# tag --help + +@test "tag --help shows help" { + run fizzy --md tag --help + assert_success + assert_output_contains "fizzy tag" + assert_output_contains "Toggle tag" +} + +@test "tag -h shows help" { + run fizzy --md tag -h + assert_success + assert_output_contains "fizzy tag" +} + +@test "tag --help --json outputs JSON" { + run fizzy --json tag --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# tag requires parameters + +@test "tag without number shows error" { + run fizzy tag + assert_failure + assert_output_contains "Card number required" +} + +@test "tag without --with shows error" { + run fizzy tag 123 + assert_failure + assert_output_contains "tag name required" +} + +@test "tag requires authentication" { + run fizzy tag 123 --with tag456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# watch --help + +@test "watch --help shows help" { + run fizzy --md watch --help + assert_success + assert_output_contains "fizzy watch" + assert_output_contains "Subscribe" +} + +@test "watch -h shows help" { + run fizzy --md watch -h + assert_success + assert_output_contains "fizzy watch" +} + +@test "watch --help --json outputs JSON" { + run fizzy --json watch --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# watch requires card number + +@test "watch without number shows error" { + run fizzy watch + assert_failure + assert_output_contains "Card number required" +} + +@test "watch requires authentication" { + run fizzy watch 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# unwatch --help + +@test "unwatch --help shows help" { + run fizzy --md unwatch --help + assert_success + assert_output_contains "fizzy unwatch" + assert_output_contains "Unsubscribe" +} + +@test "unwatch -h shows help" { + run fizzy --md unwatch -h + assert_success + assert_output_contains "fizzy unwatch" +} + +@test "unwatch --help --json outputs JSON" { + run fizzy --json unwatch --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# unwatch requires card number + +@test "unwatch without number shows error" { + run fizzy unwatch + assert_failure + assert_output_contains "Card number required" +} + +@test "unwatch requires authentication" { + run fizzy unwatch 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# gild --help + +@test "gild --help shows help" { + run fizzy --md gild --help + assert_success + assert_output_contains "fizzy gild" + assert_output_contains "golden" +} + +@test "gild -h shows help" { + run fizzy --md gild -h + assert_success + assert_output_contains "fizzy gild" +} + +@test "gild --help --json outputs JSON" { + run fizzy --json gild --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# gild requires card number + +@test "gild without number shows error" { + run fizzy gild + assert_failure + assert_output_contains "Card number required" +} + +@test "gild requires authentication" { + run fizzy gild 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# ungild --help + +@test "ungild --help shows help" { + run fizzy --md ungild --help + assert_success + assert_output_contains "fizzy ungild" + assert_output_contains "golden" +} + +@test "ungild -h shows help" { + run fizzy --md ungild -h + assert_success + assert_output_contains "fizzy ungild" +} + +@test "ungild --help --json outputs JSON" { + run fizzy --json ungild --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# ungild requires card number + +@test "ungild without number shows error" { + run fizzy ungild + assert_failure + assert_output_contains "Card number required" +} + +@test "ungild requires authentication" { + run fizzy ungild 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# step --help + +@test "step --help shows help" { + run fizzy --md step --help + assert_success + assert_output_contains "fizzy step" + assert_output_contains "Manage steps" +} + +@test "step -h shows help" { + run fizzy --md step -h + assert_success + assert_output_contains "fizzy step" +} + +@test "step --help --json outputs JSON" { + run fizzy --json step --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".subcommands" +} + + +# step create requires parameters + +@test "step without text shows error" { + run fizzy step + assert_failure + assert_output_contains "content required" +} + +@test "step without --on shows error" { + run fizzy step "Test step" + assert_failure + assert_output_contains "card number required" +} + +@test "step requires authentication" { + run fizzy step "Test" --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# step show + +@test "step show --help shows help" { + run fizzy --md step show --help + assert_success + assert_output_contains "fizzy step show" + assert_output_contains "Show step" +} + +@test "step show without id shows error" { + run fizzy step show + assert_failure + assert_output_contains "Step ID required" +} + +@test "step show without --on shows error" { + run fizzy step show abc123 + assert_failure + assert_output_contains "card number required" +} + +@test "step show requires authentication" { + run fizzy step show abc123 --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# step update + +@test "step update --help shows help" { + run fizzy --md step update --help + assert_success + assert_output_contains "fizzy step update" + assert_output_contains "Update a step" +} + +@test "step update without id shows error" { + run fizzy step update + assert_failure + assert_output_contains "Step ID required" +} + +@test "step update without --on shows error" { + run fizzy step update abc123 + assert_failure + assert_output_contains "card number required" +} + +@test "step update without changes shows error" { + run fizzy step update abc123 --on 123 + assert_failure + assert_output_contains "Nothing to update" +} + +@test "step update requires authentication" { + run fizzy step update abc123 --on 123 --completed + assert_failure + assert_output_contains "Not authenticated" +} + + +# step delete + +@test "step delete --help shows help" { + run fizzy --md step delete --help + assert_success + assert_output_contains "fizzy step delete" + assert_output_contains "Delete a step" +} + +@test "step delete without id shows error" { + run fizzy step delete + assert_failure + assert_output_contains "Step ID required" +} + +@test "step delete without --on shows error" { + run fizzy step delete abc123 + assert_failure + assert_output_contains "card number required" +} + +@test "step delete requires authentication" { + run fizzy step delete abc123 --on 123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# react --help + +@test "react --help shows help" { + run fizzy --md react --help + assert_success + assert_output_contains "fizzy react" + assert_output_contains "reaction" +} + +@test "react -h shows help" { + run fizzy --md react -h + assert_success + assert_output_contains "fizzy react" +} + +@test "react --help --json outputs JSON" { + run fizzy --json react --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# react requires parameters + +@test "react without emoji shows error" { + run fizzy react + assert_failure + assert_output_contains "Emoji required" +} + +@test "react without --card shows error" { + run fizzy react "👍" + assert_failure + assert_output_contains "card" +} + +@test "react without --comment shows error" { + run fizzy react "👍" --card 123 + assert_failure + assert_output_contains "comment" +} + +@test "react requires authentication" { + run fizzy react "👍" --card 123 --comment abc456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# react delete + +@test "react delete --help shows help" { + run fizzy --md react delete --help + assert_success + assert_output_contains "fizzy react delete" + assert_output_contains "Delete a reaction" +} + +@test "react delete without id shows error" { + run fizzy react delete + assert_failure + assert_output_contains "Reaction ID required" +} + +@test "react delete without --card shows error" { + run fizzy react delete xyz789 + assert_failure + assert_output_contains "card" +} + +@test "react delete without --comment shows error" { + run fizzy react delete xyz789 --card 123 + assert_failure + assert_output_contains "comment" +} + +@test "react delete requires authentication" { + run fizzy react delete xyz789 --card 123 --comment abc456 + assert_failure + assert_output_contains "Not authenticated" +} + + +# reactions --help + +@test "reactions --help shows help" { + run fizzy --md reactions --help + assert_success + assert_output_contains "fizzy reactions" + assert_output_contains "List reactions" +} + +@test "reactions -h shows help" { + run fizzy --md reactions -h + assert_success + assert_output_contains "fizzy reactions" +} + +@test "reactions --help --json outputs JSON" { + run fizzy --json reactions --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# reactions requires parameters + +@test "reactions without --card shows error" { + run fizzy reactions + assert_failure + assert_output_contains "card" +} + +@test "reactions without --comment shows error" { + run fizzy reactions --card 123 + assert_failure + assert_output_contains "comment" +} + +@test "reactions requires authentication" { + run fizzy reactions --card 123 --comment abc456 + assert_failure + assert_output_contains "Not authenticated" +} diff --git a/cli/test/auth.bats b/cli/test/auth.bats new file mode 100644 index 0000000000..82bc23d29f --- /dev/null +++ b/cli/test/auth.bats @@ -0,0 +1,503 @@ +#!/usr/bin/env bats +# auth.bats - Tests for lib/auth.sh + +load test_helper + + +# Auth status + +@test "auth status shows unauthenticated when no credentials" { + run fizzy --md auth status + assert_success + assert_output_contains "Not authenticated" +} + +@test "auth status --json shows unauthenticated" { + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "unauthenticated" +} + +@test "auth status shows authenticated with valid credentials" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + create_accounts + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" +} + +@test "auth status --json shows authenticated" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "authenticated" + assert_json_value ".token" "valid" +} + +@test "auth status shows expired token warning" { + create_credentials "test-token" "$(($(date +%s) - 100))" "write" + + run fizzy --md auth status + assert_success + assert_output_contains "Expired" +} + +@test "auth status --json shows expired token" { + create_credentials "test-token" "$(($(date +%s) - 100))" "write" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".token" "expired" +} + + +# Auth logout + +@test "auth logout removes credentials for current origin" { + create_credentials "test-token" + + run fizzy auth logout + assert_success + assert_output_contains "Logged out" + + # File still exists but credentials for current origin are gone + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + local creds + creds=$(jq -r --arg url "$base_url" '.[$url] // empty' "$TEST_HOME/.config/fizzy/credentials.json") + [[ -z "$creds" || "$creds" == "{}" ]] +} + +@test "auth logout when not logged in" { + run fizzy auth logout + assert_success + assert_output_contains "Not logged in" +} + + +# Auth help + +@test "auth --help shows help" { + run fizzy auth --help + assert_success + assert_output_contains "login" + assert_output_contains "logout" + assert_output_contains "status" +} + +@test "auth -h shows help" { + run fizzy auth -h + assert_success + assert_output_contains "login" +} + + +# Auth scope display + +@test "auth status shows write scope" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + + run fizzy --md auth status + assert_success + assert_output_contains "write" + assert_output_contains "read+write" +} + +@test "auth status shows read scope" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "read" + + run fizzy --md auth status + assert_success + assert_output_contains "read" + assert_output_contains "read-only" +} + + +# PKCE helpers (testing internal functions) + +@test "_generate_code_verifier produces 43+ char string" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + result=$(_generate_code_verifier) + [[ ${#result} -ge 43 ]] +} + +@test "_generate_code_challenge produces non-empty string" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + verifier=$(_generate_code_verifier) + result=$(_generate_code_challenge "$verifier") + [[ -n "$result" ]] +} + +@test "_generate_state produces 32 char hex string" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + result=$(_generate_state) + [[ ${#result} -eq 32 ]] + [[ "$result" =~ ^[0-9a-f]+$ ]] +} + + +# Client loading + +@test "_load_client returns failure when no client file" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + ! _load_client +} + +@test "_load_client sets client_id from file" { + create_client + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/auth.sh" + + _load_client + [[ "$client_id" == "test-client-id" ]] +} + + +# Account selection + +@test "account name shown in status with single account" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + create_global_config '{"account_slug": "99999999"}' + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + cat > "$TEST_HOME/.config/fizzy/accounts.json" << EOF +{ + "$base_url": [ + {"id": "test-id", "name": "Test Account", "slug": "/99999999"} + ] +} +EOF + + run fizzy --md auth status + assert_success + assert_output_contains "Test Account" +} + + +# Unknown auth action + +@test "auth unknown action shows error" { + run fizzy auth unknownaction + assert_failure + assert_output_contains "Unknown auth action" +} + + +# Long-lived tokens (Fizzy's token model) + +@test "auth status shows long-lived token" { + create_long_lived_credentials "test-token" "write" + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" + assert_output_contains "Long-lived" +} + +@test "auth status --json shows long-lived token" { + create_long_lived_credentials "test-token" "write" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "authenticated" + assert_json_value ".token" "long-lived" +} + +@test "is_token_expired returns false for long-lived token" { + create_long_lived_credentials "test-token" "write" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + +@test "auth refresh with long-lived token shows informative message" { + create_long_lived_credentials "test-token" "write" + create_accounts + + run fizzy --md auth refresh + assert_success + assert_output_contains "long-lived" + assert_output_contains "doesn't require refresh" +} + +@test "auth status treats expires_at 0 as long-lived" { + # Create credentials with expires_at: 0 (edge case) + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" + assert_output_contains "Long-lived" +} + +@test "auth refresh treats expires_at 0 as long-lived" { + # Create credentials with expires_at: 0 (edge case) + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + create_accounts + + run fizzy --md auth refresh + assert_success + assert_output_contains "long-lived" + assert_output_contains "doesn't require refresh" +} + +@test "is_token_expired returns false for expires_at 0" { + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": 0 + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + +@test "auth refresh without refresh_token but with expiry prompts re-login" { + # Token has expiry but no refresh token - should prompt re-login + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "$base_url": { + "access_token": "test-token", + "refresh_token": "", + "scope": "write", + "expires_at": $(($(date +%s) + 3600)) + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + run fizzy auth refresh + assert_failure + assert_output_contains "No refresh token available" + assert_output_contains "fizzy auth login" +} + +@test "refresh_token uses discovered token endpoint" { + # This test verifies that refresh_token() calls _token_endpoint() from discovery + # rather than hardcoding the endpoint path + + # Create credentials with a refresh token + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + base_url="${base_url%/}" + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "$base_url": { + "access_token": "old-token", + "refresh_token": "test-refresh-token", + "scope": "write", + "expires_at": $(($(date +%s) - 100)) + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" + + # Create client credentials + create_client + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + + # Stub _token_endpoint to return a test URL and verify it's called + _token_endpoint() { + echo "https://discovered.example.com/oauth/token" + } + + # Stub curl to capture the URL it's called with + curl() { + # Find the URL argument (last positional arg after all options) + local url="" + for arg in "$@"; do + if [[ "$arg" == http* ]]; then + url="$arg" + fi + done + echo "CURL_URL=$url" >&2 + # Return a failure response so refresh_token returns 1 + echo '{"error": "test_stub"}' + } + + # Run refresh_token and capture stderr + output=$(refresh_token 2>&1) || true + + # Verify the discovered endpoint was used + [[ "$output" == *"https://discovered.example.com/oauth/token"* ]] +} + +@test "auth refresh when not authenticated fails" { + # No credentials file exists + run fizzy auth refresh + assert_failure + assert_output_contains "Not authenticated" + assert_output_contains "fizzy auth login" +} + + +# FIZZY_TOKEN environment variable + +@test "FIZZY_TOKEN takes precedence over stored credentials" { + create_credentials "stored-token" "$(($(date +%s) + 3600))" "write" + + export FIZZY_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_access_token) + [[ "$result" == "env-token" ]] +} + +@test "get_auth_type returns token_env when FIZZY_TOKEN set" { + export FIZZY_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_auth_type) + [[ "$result" == "token_env" ]] +} + +@test "get_auth_type returns oauth when stored credentials" { + create_credentials "stored-token" "$(($(date +%s) + 3600))" "write" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_auth_type) + [[ "$result" == "oauth" ]] +} + +@test "get_auth_type returns none when no auth" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_auth_type) + [[ "$result" == "none" ]] +} + +@test "is_token_expired returns false for FIZZY_TOKEN" { + export FIZZY_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + +@test "auth status shows FIZZY_TOKEN indicator" { + export FIZZY_TOKEN="env-token" + export FIZZY_ACCOUNT_SLUG="99999999" + + run fizzy --md auth status + assert_success + assert_output_contains "Authenticated" + assert_output_contains "FIZZY_TOKEN" +} + +@test "auth status --json shows env auth type" { + export FIZZY_TOKEN="env-token" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".status" "authenticated" + assert_json_value ".auth" "env" +} + +@test "auth logout warns about FIZZY_TOKEN still set" { + create_credentials "stored-token" + export FIZZY_TOKEN="env-token" + + run fizzy auth logout + assert_success + assert_output_contains "Logged out" + assert_output_contains "FIZZY_TOKEN" + assert_output_contains "still set" +} + +@test "auth refresh fails with FIZZY_TOKEN" { + export FIZZY_TOKEN="env-token" + + run fizzy auth refresh + assert_failure + assert_output_contains "FIZZY_TOKEN does not support refresh" +} + +@test "auth status shows coexistence note when both token sources exist" { + create_credentials "stored-token" + export FIZZY_TOKEN="env-token" + + run fizzy --md auth status + assert_success + assert_output_contains "FIZZY_TOKEN" + assert_output_contains "precedence" + assert_output_contains "Stored OAuth credentials also exist" +} + +@test "auth status --json includes has_stored_credentials when using FIZZY_TOKEN" { + create_credentials "stored-token" + export FIZZY_TOKEN="env-token" + + run fizzy --json auth status + assert_success + is_valid_json + assert_json_value ".has_stored_credentials" "true" +} diff --git a/cli/test/boards.bats b/cli/test/boards.bats new file mode 100644 index 0000000000..84015ca595 --- /dev/null +++ b/cli/test/boards.bats @@ -0,0 +1,188 @@ +#!/usr/bin/env bats +# boards.bats - Tests for board and column commands + +load test_helper + + +# boards --help + +@test "boards --help shows help" { + run fizzy --md boards --help + assert_success + assert_output_contains "fizzy boards" + assert_output_contains "List boards" +} + +@test "boards -h shows help" { + run fizzy --md boards -h + assert_success + assert_output_contains "fizzy boards" +} + +@test "boards --help --json outputs JSON" { + run fizzy --json boards --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# boards requires auth + +@test "boards requires authentication" { + run fizzy boards + assert_failure + assert_output_contains "Not authenticated" +} + + +# boards pagination validation + +@test "boards --page rejects non-numeric value" { + run fizzy boards --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "boards --page rejects zero" { + run fizzy boards --page 0 + assert_failure + assert_output_contains "positive integer" +} + +@test "boards --page rejects negative" { + run fizzy boards --page -1 + assert_failure + assert_output_contains "positive integer" +} + + +# boards --all flag + +@test "boards --help documents --all flag" { + run fizzy --md boards --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} + + +# board --help + +@test "board --help shows help" { + run fizzy --md board --help + assert_success + assert_output_contains "fizzy board" + assert_output_contains "Manage boards" +} + + +# board validation + +@test "board create without name shows error" { + run fizzy board create + assert_failure + assert_output_contains "Board name required" +} + +@test "board update without id shows error" { + run fizzy board update + assert_failure + assert_output_contains "Board ID required" +} + +@test "board delete without id shows error" { + run fizzy board delete + assert_failure + assert_output_contains "Board ID required" +} + +@test "board show without id shows error" { + run fizzy board show + assert_failure + assert_output_contains "Board ID required" +} + + +# columns --help + +@test "columns --help shows help" { + run fizzy --md columns --help + assert_success + assert_output_contains "fizzy columns" + assert_output_contains "List columns" +} + +@test "columns -h shows help" { + run fizzy --md columns -h + assert_success + assert_output_contains "fizzy columns" +} + + +# columns requires board + +@test "columns without board shows error" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + create_global_config '{"account_slug": "12345"}' + + run fizzy columns + assert_failure + assert_output_contains "No board" +} + +@test "columns uses board from config" { + # This would require a mock API, so just test the help for now + run fizzy --md columns --help + assert_success + assert_output_contains "--board" +} + + +# column --help + +@test "column --help shows help" { + run fizzy --md column --help + assert_success + assert_output_contains "fizzy column" + assert_output_contains "Manage columns" +} + + +# column validation + +@test "column create without name shows error" { + run fizzy column create --board abc123 + assert_failure + assert_output_contains "Column name required" +} + +@test "column create without board shows error" { + run fizzy column create "In Progress" + assert_failure + assert_output_contains "No board specified" +} + +@test "column update without id shows error" { + run fizzy column update + assert_failure + assert_output_contains "Column ID required" +} + +@test "column update without board shows error" { + run fizzy column update abc123 + assert_failure + assert_output_contains "No board specified" +} + +@test "column delete without id shows error" { + run fizzy column delete + assert_failure + assert_output_contains "Column ID required" +} + +@test "column show without id shows error" { + run fizzy column show + assert_failure + assert_output_contains "Column ID required" +} diff --git a/cli/test/cards.bats b/cli/test/cards.bats new file mode 100644 index 0000000000..41ce1cfc36 --- /dev/null +++ b/cli/test/cards.bats @@ -0,0 +1,223 @@ +#!/usr/bin/env bats +# cards.bats - Tests for card commands + +load test_helper + + +# cards --help + +@test "cards --help shows help" { + run fizzy --md cards --help + assert_success + assert_output_contains "fizzy cards" + assert_output_contains "List and filter" +} + +@test "cards -h shows help" { + run fizzy --md cards -h + assert_success + assert_output_contains "fizzy cards" +} + +@test "cards --help --json outputs JSON" { + run fizzy --json cards --help + assert_success + is_valid_json + assert_json_not_null ".command" + assert_json_not_null ".options" +} + + +# cards requires auth + +@test "cards requires authentication" { + run fizzy cards + assert_failure + assert_output_contains "Not authenticated" +} + + +# cards option parsing + +@test "cards rejects unknown option" { + run fizzy cards --unknown-option + assert_failure + assert_output_contains "Unknown option" +} + +@test "cards --page rejects non-numeric value" { + run fizzy cards --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "cards --page rejects zero" { + run fizzy cards --page 0 + assert_failure + assert_output_contains "positive integer" +} + +@test "cards --page rejects negative" { + run fizzy cards --page -1 + assert_failure + assert_output_contains "positive integer" +} + + +# show --help + +@test "show --help shows help" { + run fizzy --md show --help + assert_success + assert_output_contains "fizzy show" +} + +@test "show -h shows help" { + run fizzy --md show -h + assert_success + assert_output_contains "fizzy show" +} + +@test "show --help --json outputs JSON" { + run fizzy --json show --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# show requires auth + +@test "show card requires authentication" { + run fizzy show 42 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "show board requires authentication" { + run fizzy show board abc123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# search --help + +@test "search --help shows help" { + run fizzy --md search --help + assert_success + assert_output_contains "fizzy search" +} + +@test "search -h shows help" { + run fizzy --md search -h + assert_success + assert_output_contains "fizzy search" +} + + +# search requires query + +@test "search without query shows error" { + run fizzy search + assert_failure + assert_output_contains "query required" +} + + +# people --help + +@test "people --help shows help" { + run fizzy --md people --help + assert_success + assert_output_contains "fizzy people" + assert_output_contains "List users" +} + +@test "people -h shows help" { + run fizzy --md people -h + assert_success + assert_output_contains "fizzy people" +} + + +# people requires auth + +@test "people requires authentication" { + run fizzy people + assert_failure + assert_output_contains "Not authenticated" +} + + +# tags --help + +@test "tags --help shows help" { + run fizzy --md tags --help + assert_success + assert_output_contains "fizzy tags" + assert_output_contains "List tags" +} + +@test "tags -h shows help" { + run fizzy --md tags -h + assert_success + assert_output_contains "fizzy tags" +} + + +# tags requires auth + +@test "tags requires authentication" { + run fizzy tags + assert_failure + assert_output_contains "Not authenticated" +} + + +# comments --help + +@test "comments --help shows help" { + run fizzy --md comments --help + assert_success + assert_output_contains "fizzy comments" +} + +@test "comments -h shows help" { + run fizzy --md comments -h + assert_success + assert_output_contains "fizzy comments" +} + + +# comments requires card number + +@test "comments without card shows error" { + run fizzy comments + assert_failure + assert_output_contains "Card number required" +} + + +# notifications --help + +@test "notifications --help shows help" { + run fizzy --md notifications --help + assert_success + assert_output_contains "fizzy notifications" +} + +@test "notifications -h shows help" { + run fizzy --md notifications -h + assert_success + assert_output_contains "fizzy notifications" +} + + +# notifications requires auth + +@test "notifications requires authentication" { + run fizzy notifications + assert_failure + assert_output_contains "Not authenticated" +} diff --git a/cli/test/config.bats b/cli/test/config.bats new file mode 100644 index 0000000000..7e5588671d --- /dev/null +++ b/cli/test/config.bats @@ -0,0 +1,547 @@ +#!/usr/bin/env bats +# config.bats - Tests for lib/config.sh + +load test_helper + + +# Config loading + +@test "loads global config" { + create_global_config '{"account_slug": "12345"}' + + # Source the lib directly to test + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "12345" ]] +} + +@test "loads local config" { + create_local_config '{"board_id": "67890"}' + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "67890" ]] +} + +@test "local config overrides global config" { + create_global_config '{"board_id": "global-123"}' + create_local_config '{"board_id": "local-456"}' + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "local-456" ]] +} + +@test "environment variable overrides config file" { + create_global_config '{"account_slug": "from-file"}' + export FIZZY_ACCOUNT_SLUG="from-env" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "from-env" ]] +} + + +# Config defaults + +@test "get_config returns default for missing key" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "nonexistent" "default-value") + [[ "$result" == "default-value" ]] +} + +@test "has_config returns false for missing key" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + ! has_config "nonexistent" +} + +@test "has_config returns true for existing key" { + create_global_config '{"account_slug": "12345"}' + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_GLOBAL_CONFIG_DIR="$HOME/.config/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + has_config "account_slug" +} + + +# Credentials + +@test "loads credentials from file" { + create_credentials "my-test-token" "$(($(date +%s) + 3600))" + unset FIZZY_TOKEN + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_access_token) + [[ "$result" == "my-test-token" ]] +} + +@test "FIZZY_TOKEN overrides stored credentials" { + create_credentials "file-token" + export FIZZY_TOKEN="env-token" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_access_token) + [[ "$result" == "env-token" ]] +} + +@test "is_token_expired returns true for expired token" { + create_credentials "test-token" "$(($(date +%s) - 100))" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + is_token_expired +} + +@test "is_token_expired returns false for valid token" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + ! is_token_expired +} + + +# Account/Board getters + +@test "get_account_slug from config" { + create_global_config '{"account_slug": "99999"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_account_slug) + [[ "$result" == "99999" ]] +} + +@test "get_board_id from config" { + create_local_config '{"board_id": "88888"}' + unset FIZZY_BOARD_ID + unset FIZZY_BOARD + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_board_id) + [[ "$result" == "88888" ]] +} + + +# URL configuration + +@test "loads base_url from config" { + create_global_config '{"base_url": "http://dev.example.com"}' + unset FIZZY_BASE_URL + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://dev.example.com" ]] +} + +@test "environment FIZZY_BASE_URL overrides config" { + create_global_config '{"base_url": "http://from-config.com"}' + export FIZZY_BASE_URL="http://from-env.com" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://from-env.com" ]] +} + +@test "defaults to dev server when no config" { + unset FIZZY_BASE_URL + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] +} + + +# Config Layering + +@test "loads system-wide config" { + create_system_config '{"account_slug": "system-123"}' + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "system-123" ]] +} + +@test "user config overrides system config" { + create_system_config '{"account_slug": "system-123"}' + create_global_config '{"account_slug": "user-456"}' + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "account_slug") + [[ "$result" == "user-456" ]] +} + +@test "repo config detected from git root" { + init_git_repo "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir" + create_repo_config '{"board_id": "repo-789"}' "$TEST_PROJECT" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "repo-789" ]] +} + +@test "repo config overrides user config" { + init_git_repo "$TEST_PROJECT" + create_global_config '{"board_id": "user-config"}' + create_repo_config '{"board_id": "repo-config"}' "$TEST_PROJECT" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "repo-config" ]] +} + +@test "local config overrides repo config" { + init_git_repo "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir/.fizzy" + create_repo_config '{"board_id": "repo-config"}' "$TEST_PROJECT" + echo '{"board_id": "local-config"}' > "$TEST_PROJECT/subdir/.fizzy/config.json" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config "board_id") + [[ "$result" == "local-config" ]] +} + +@test "full config layering priority" { + # Set up all 6 layers + create_system_config '{"account_slug": "system", "board_id": "system", "column_id": "system"}' + create_global_config '{"account_slug": "user", "board_id": "user", "column_id": "user"}' + init_git_repo "$TEST_PROJECT" + create_repo_config '{"account_slug": "repo", "board_id": "repo", "column_id": "repo"}' "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir/.fizzy" + echo '{"board_id": "local", "column_id": "local"}' > "$TEST_PROJECT/subdir/.fizzy/config.json" + export FIZZY_COLUMN_ID="env" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + + # account_slug: local doesn't set, env doesn't set, so repo wins + result=$(get_config "account_slug") + [[ "$result" == "repo" ]] + + # board_id: local sets it + result=$(get_config "board_id") + [[ "$result" == "local" ]] + + # column_id: env overrides all files + result=$(get_config "column_id") + [[ "$result" == "env" ]] +} + + +# Column ID getter + +@test "get_column_id from config" { + create_local_config '{"column_id": "77777"}' + unset FIZZY_COLUMN_ID + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_column_id) + [[ "$result" == "77777" ]] +} + +@test "get_column_id from environment" { + create_local_config '{"column_id": "from-file"}' + export FIZZY_COLUMN_ID="from-env" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_column_id) + [[ "$result" == "from-env" ]] +} + + +# Config source tracking + +@test "get_config_source returns env for environment variable" { + export FIZZY_ACCOUNT_SLUG="from-env" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == "env" ]] +} + +@test "get_config_source returns flag for FIZZY_ACCOUNT" { + export FIZZY_ACCOUNT="from-flag" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == "flag" ]] +} + +@test "get_config_source returns local for cwd config" { + create_local_config '{"board_id": "from-local"}' + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "board_id") + [[ "$result" == *"local"* ]] +} + +@test "get_config_source returns user for global config" { + create_global_config '{"account_slug": "from-user"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == *"user"* ]] +} + +@test "get_config_source returns system for system-wide config" { + create_system_config '{"account_slug": "from-system"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + source "$FIZZY_ROOT/lib/core.sh" + FIZZY_SYSTEM_CONFIG_DIR="$TEST_TEMP_DIR/etc/fizzy" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "account_slug") + [[ "$result" == *"system"* ]] +} + +@test "get_config_source returns repo for git root config" { + init_git_repo "$TEST_PROJECT" + create_repo_config '{"board_id": "from-repo"}' "$TEST_PROJECT" + mkdir -p "$TEST_PROJECT/subdir" + + cd "$TEST_PROJECT/subdir" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "board_id") + [[ "$result" == *"repo"* ]] +} + +@test "get_config_source returns unset for missing key" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + load_config + result=$(get_config_source "nonexistent") + [[ "$result" == "unset" ]] +} + + +# Token scope + +@test "get_token_scope returns scope from credentials" { + create_credentials "test-token" "$(($(date +%s) + 3600))" "write" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + result=$(get_token_scope) + [[ "$result" == "write" ]] +} + +@test "get_token_scope returns unknown when no scope" { + create_credentials "test-token" "$(($(date +%s) + 3600))" + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + # get_token_scope returns "unknown" and exit code 1 when no scope + result=$(get_token_scope 2>/dev/null) || true + [[ "$result" == "unknown" ]] +} + + +# fizzy config command + +@test "config --help shows help" { + run fizzy --md config --help + assert_success + assert_output_contains "fizzy config" + assert_output_contains "Manage configuration" +} + +@test "config -h shows help" { + run fizzy --md config -h + assert_success + assert_output_contains "fizzy config" +} + +@test "config --help --json outputs JSON" { + run fizzy --json config --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + +@test "config list shows configuration" { + create_global_config '{"account_slug": "12345"}' + + run fizzy --md config + assert_success + assert_output_contains "Configuration" +} + +@test "config list --json outputs JSON" { + create_global_config '{"account_slug": "12345"}' + + run fizzy --json config list + assert_success + is_valid_json +} + +@test "config get retrieves value" { + create_global_config '{"account_slug": "test-slug"}' + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_ACCOUNT + + run fizzy config get account_slug + assert_success + assert_output_contains "test-slug" +} + +@test "config get missing key shows error" { + run fizzy config get nonexistent + assert_failure + assert_output_contains "Key not found" +} + +@test "config set creates value" { + run fizzy config set my_key my_value + assert_success + assert_output_contains "Set my_key" + + # Verify it was saved to local config (cwd/.fizzy/config.json) + local config_file="$TEST_PROJECT/.fizzy/config.json" + [[ -f "$config_file" ]] + result=$(jq -r '.my_key' "$config_file") + [[ "$result" == "my_value" ]] +} + +@test "config set --global creates global value" { + run fizzy config set --global my_global_key my_value + assert_success + assert_output_contains "global" + + # Verify it was saved to global config (~/.config/fizzy/config.json) + local config_file="$TEST_HOME/.config/fizzy/config.json" + [[ -f "$config_file" ]] + result=$(jq -r '.my_global_key' "$config_file") + [[ "$result" == "my_value" ]] +} + +@test "config unset removes value" { + create_local_config '{"my_key": "my_value"}' + + run fizzy config unset my_key + assert_success + assert_output_contains "Unset my_key" + + # Verify it was removed from local config + local config_file="$TEST_PROJECT/.fizzy/config.json" + result=$(jq -r '.my_key // empty' "$config_file") + [[ -z "$result" ]] +} + +@test "config path shows paths" { + run fizzy --md config path + assert_success + assert_output_contains "Config Paths" + assert_output_contains "global" + assert_output_contains "local" +} + +@test "config path --json outputs JSON" { + run fizzy --json config path + assert_success + is_valid_json + assert_json_not_null ".global" + assert_json_not_null ".local" +} + +@test "local config base_url overrides global" { + create_global_config '{"base_url": "http://global.example.com"}' + create_local_config '{"base_url": "http://local.example.com"}' + unset FIZZY_BASE_URL + + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + + # After loading full hierarchy, local should win + [[ "$FIZZY_BASE_URL" == "http://local.example.com" ]] +} diff --git a/cli/test/core.bats b/cli/test/core.bats new file mode 100644 index 0000000000..8ad98cebc3 --- /dev/null +++ b/cli/test/core.bats @@ -0,0 +1,249 @@ +#!/usr/bin/env bats +# core.bats - Tests for lib/core.sh + +load test_helper + + +# Version + +@test "fizzy shows version" { + run fizzy version + assert_success + assert_output_contains "fizzy" +} + + +# Quick start + +@test "fizzy with no args shows quick start" { + run fizzy + assert_success + assert_output_contains "fizzy" +} + +@test "fizzy --json with no args outputs JSON" { + run fizzy --json + assert_success + is_valid_json + assert_json_not_null ".version" +} + + +# Help + +@test "fizzy --help shows help" { + run fizzy --md --help + assert_success + assert_output_contains "USAGE" + assert_output_contains "COMMANDS" +} + +@test "fizzy help shows main help" { + run fizzy help + assert_success + assert_output_contains "fizzy" +} + + +# Output format detection + +@test "fizzy defaults to markdown when TTY" { + # This is tricky to test since bats runs in non-TTY + # For now, just verify --md flag works + run fizzy --md + assert_success + assert_output_not_contains '"version"' +} + +@test "fizzy --json forces JSON output" { + run fizzy --json + assert_success + is_valid_json +} + + +# Global flags + +@test "fizzy respects --quiet flag" { + run fizzy --quiet version + assert_success +} + +@test "fizzy respects --verbose flag" { + run fizzy --verbose version + assert_success +} + + +# Error handling + +@test "fizzy unknown command shows error" { + run fizzy notacommand + assert_failure +} + + +# JSON envelope structure + +@test "JSON output has correct envelope structure" { + run fizzy --json + assert_success + is_valid_json + + # Check required fields + assert_json_not_null ".version" + assert_json_not_null ".auth" +} + + +# Exit codes + +@test "unknown command returns exit code 1" { + run fizzy unknowncommand + assert_exit_code 1 +} + + +# Format flag aliases + +@test "fizzy -j is alias for --json" { + run fizzy -j + assert_success + is_valid_json +} + +@test "fizzy -m is alias for --md" { + run fizzy -m + assert_success + assert_output_not_contains '"version"' +} + +@test "fizzy -q is alias for --quiet" { + run fizzy -q version + assert_success +} + +@test "fizzy -v is alias for --verbose" { + run fizzy -v version + assert_success +} + + +# Board and account flags + +@test "fizzy --board sets board context" { + run fizzy --json --board test-board + assert_success + # Should not error even without auth +} + +@test "fizzy --account sets account context" { + run fizzy --json --account 12345 + assert_success +} + +@test "fizzy -b is alias for --board" { + run fizzy --json -b test-board + assert_success +} + +@test "fizzy --in is alias for --board" { + run fizzy --json --in test-board + assert_success +} + +@test "fizzy -a is alias for --account" { + run fizzy --json -a 12345 + assert_success +} + + +# FIZZY_URL convenience env var + +@test "FIZZY_URL with slug sets base + account" { + unset FIZZY_BASE_URL + export FIZZY_URL="http://fizzy.localhost:3006/897362094" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ "$FIZZY_ACCOUNT_SLUG" == "897362094" ]] +} + +@test "FIZZY_URL without slug sets base only" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} + +@test "FIZZY_BASE_URL overrides FIZZY_URL base" { + export FIZZY_BASE_URL="http://override.local" + export FIZZY_URL="http://fizzy.localhost:3006/897362094" + unset FIZZY_ACCOUNT_SLUG + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://override.local" ]] + # Slug not set because bases don't match + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} + +@test "FIZZY_ACCOUNT_SLUG overrides FIZZY_URL slug" { + unset FIZZY_BASE_URL + export FIZZY_ACCOUNT_SLUG="1234567" + export FIZZY_URL="http://fizzy.localhost:3006/897362094" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_ACCOUNT_SLUG" == "1234567" ]] +} + +@test "FIZZY_URL ignores non-numeric path segments" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/boards" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} + +@test "FIZZY_URL with extra path uses first segment as slug" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/897362094/boards/123" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ "$FIZZY_ACCOUNT_SLUG" == "897362094" ]] +} + +@test "FIZZY_URL strips trailing slash" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/897362094/" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + [[ "$FIZZY_ACCOUNT_SLUG" == "897362094" ]] +} + +@test "FIZZY_URL rejects short numeric slugs" { + unset FIZZY_BASE_URL + unset FIZZY_ACCOUNT_SLUG + export FIZZY_URL="http://fizzy.localhost:3006/123456" + + source "$FIZZY_ROOT/lib/core.sh" + + [[ "$FIZZY_BASE_URL" == "http://fizzy.localhost:3006" ]] + # 6 digits is too short + [[ -z "${FIZZY_ACCOUNT_SLUG:-}" ]] +} diff --git a/cli/test/names.bats b/cli/test/names.bats new file mode 100644 index 0000000000..31cfbfd1a2 --- /dev/null +++ b/cli/test/names.bats @@ -0,0 +1,248 @@ +#!/usr/bin/env bats +# names.bats - Tests for name resolution (Phase 4) + +load test_helper + + +# resolve_board_id tests + +@test "resolve_board_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # UUID-like strings should pass through unchanged (25+ alphanumeric) + result=$(resolve_board_id "abc123def456ghi789jkl012mno") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + +@test "resolve_user_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + result=$(resolve_user_id "abc123def456ghi789jkl012mno") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + +@test "resolve_column_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + result=$(resolve_column_id "abc123def456ghi789jkl012mno" "board123") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + +@test "resolve_tag_id returns ID unchanged for UUID-like input" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + result=$(resolve_tag_id "abc123def456ghi789jkl012mno") + [[ "$result" == "abc123def456ghi789jkl012mno" ]] +} + + +# resolve_column_id edge cases + +@test "resolve_column_id requires board_id" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + ! resolve_column_id "My Column" "" + [[ "$RESOLVE_ERROR" == "Board ID required for column resolution" ]] +} + + +# Help text updated to show name support + +@test "cards --help mentions name support" { + run fizzy --md cards --help + assert_success + assert_output_contains "name or ID" +} + +@test "columns --help mentions name support" { + run fizzy --md columns --help + assert_success + assert_output_contains "name or ID" +} + +@test "card --help mentions name support" { + run fizzy --md card --help + assert_success + assert_output_contains "name or ID" +} + +@test "triage --help mentions name support" { + run fizzy --md triage --help + assert_success + assert_output_contains "name or ID" +} + +@test "assign --help mentions name support" { + run fizzy --md assign --help + assert_success + assert_output_contains "name" +} + +@test "tag --help mentions name support" { + run fizzy --md tag --help + assert_success + # Tag only accepts names (API requires tag_title, not tag_id) + assert_output_contains "Tag name" +} + + +# Contract test: tag API uses .title field (not .name) + +@test "resolve_tag_id matches on .title field from API" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # Set up cache with mock tag data matching real API structure + # IMPORTANT: API returns .title, not .name - this is a contract test + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + _set_cache "tags" '[{"id":"tag123","title":"bug"},{"id":"tag456","title":"feature"}]' + + # Should resolve by title + result=$(resolve_tag_id "bug") + [[ "$result" == "tag123" ]] +} + +@test "resolve_tag_id strips leading # from tag name" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + _set_cache "tags" '[{"id":"tag123","title":"bug"}]' + + # Should resolve #bug to bug + result=$(resolve_tag_id "#bug") + [[ "$result" == "tag123" ]] +} + + +# Cache management + +@test "_ensure_cache_dir creates cache directory" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # Set a test cache dir + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + + _ensure_cache_dir + [[ -d "$_FIZZY_CACHE_DIR" ]] +} + +@test "_set_cache and _get_cache work" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + + _set_cache "test" '{"id": "123", "name": "Test"}' + result=$(_get_cache "test") + [[ "$result" == '{"id": "123", "name": "Test"}' ]] +} + +@test "_clear_cache removes cache directory" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + export _FIZZY_CACHE_DIR="$TEST_HOME/fizzy-cache-test" + rm -rf "$_FIZZY_CACHE_DIR" + + _ensure_cache_dir + [[ -d "$_FIZZY_CACHE_DIR" ]] + + _clear_cache + [[ ! -d "$_FIZZY_CACHE_DIR" ]] +} + + +# Suggestion helper + +@test "_suggest_similar returns suggestions" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + local json='[{"name": "Engineering"}, {"name": "Design"}, {"name": "Marketing"}]' + result=$(_suggest_similar "Eng" "$json" "name") + [[ "$result" == *"Engineering"* ]] +} + +@test "_suggest_similar returns multiple suggestions" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + local json='[{"name": "Dev Team"}, {"name": "Dev Ops"}, {"name": "Design"}]' + result=$(_suggest_similar "Dev" "$json" "name") + [[ "$result" == *"Dev Team"* ]] + [[ "$result" == *"Dev Ops"* ]] +} + +@test "_suggest_similar handles regex special characters" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + # Input with regex special chars like [ ] * . should not crash + local json='[{"name": "foo"}, {"name": "bar"}, {"name": "baz"}]' + # This would fail with regex grep: grep -i "^foo[" is invalid + result=$(_suggest_similar "foo[" "$json" "name") + # Should return something (first few names) without erroring + [[ -n "$result" || -z "$result" ]] # Either result, no crash +} + + +# Error message formatting + +@test "format_resolve_error uses RESOLVE_ERROR" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + RESOLVE_ERROR="Board not found: My Board" + result=$(format_resolve_error "board" "My Board") + [[ "$result" == "Board not found: My Board" ]] +} + +@test "format_resolve_error provides default message" { + source "$FIZZY_ROOT/lib/core.sh" + source "$FIZZY_ROOT/lib/config.sh" + source "$FIZZY_ROOT/lib/api.sh" + source "$FIZZY_ROOT/lib/names.sh" + + RESOLVE_ERROR="" + result=$(format_resolve_error "board" "My Board") + [[ "$result" == "Board not found: My Board" ]] +} diff --git a/cli/test/queries.bats b/cli/test/queries.bats new file mode 100644 index 0000000000..7224e7f0b4 --- /dev/null +++ b/cli/test/queries.bats @@ -0,0 +1,148 @@ +#!/usr/bin/env bats +# queries.bats - Tests for query commands (tags, people, notifications) + +load test_helper + + +# tags --help + +@test "tags --help shows help" { + run fizzy --md tags --help + assert_success + assert_output_contains "fizzy tags" + assert_output_contains "List tags" +} + +@test "tags -h shows help" { + run fizzy --md tags -h + assert_success + assert_output_contains "fizzy tags" +} + +@test "tags --help --json outputs JSON" { + run fizzy --json tags --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# tags pagination validation + +@test "tags --page rejects non-numeric value" { + run fizzy tags --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "tags --page rejects zero" { + run fizzy tags --page 0 + assert_failure + assert_output_contains "positive integer" +} + + +# tags --all flag + +@test "tags --help documents --all flag" { + run fizzy --md tags --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} + + +# people --help + +@test "people --help shows help" { + run fizzy --md people --help + assert_success + assert_output_contains "fizzy people" + assert_output_contains "List users" +} + +@test "people -h shows help" { + run fizzy --md people -h + assert_success + assert_output_contains "fizzy people" +} + +@test "people --help --json outputs JSON" { + run fizzy --json people --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# people pagination validation + +@test "people --page rejects non-numeric value" { + run fizzy people --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "people --page rejects zero" { + run fizzy people --page 0 + assert_failure + assert_output_contains "positive integer" +} + + +# people --all flag + +@test "people --help documents --all flag" { + run fizzy --md people --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} + + +# notifications --help + +@test "notifications --help shows help" { + run fizzy --md notifications --help + assert_success + assert_output_contains "fizzy notifications" + assert_output_contains "List" +} + +@test "notifications -h shows help" { + run fizzy --md notifications -h + assert_success + assert_output_contains "fizzy notifications" +} + +@test "notifications --help --json outputs JSON" { + run fizzy --json notifications --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# notifications pagination validation + +@test "notifications --page rejects non-numeric value" { + run fizzy notifications --page abc + assert_failure + assert_output_contains "positive integer" +} + +@test "notifications --page rejects zero" { + run fizzy notifications --page 0 + assert_failure + assert_output_contains "positive integer" +} + + +# notifications --all flag + +@test "notifications --help documents --all flag" { + run fizzy --md notifications --help + assert_success + assert_output_contains "--all" + assert_output_contains "Fetch all pages" +} diff --git a/cli/test/run.sh b/cli/test/run.sh new file mode 100755 index 0000000000..ba66fab734 --- /dev/null +++ b/cli/test/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Test runner for fizzy +# Runs the bats test suite + +set -euo pipefail +cd "$(dirname "$0")" + +if ! command -v bats &>/dev/null; then + echo "Error: bats not found. Install with: brew install bats-core" >&2 + exit 1 +fi + +exec bats "$@" *.bats diff --git a/cli/test/self_update.bats b/cli/test/self_update.bats new file mode 100644 index 0000000000..aeedcce09e --- /dev/null +++ b/cli/test/self_update.bats @@ -0,0 +1,115 @@ +#!/usr/bin/env bats +# self_update.bats - Tests for version, self-update, and uninstall commands + +load test_helper + + +# --version flag + +@test "fizzy --version shows version" { + run fizzy --version + assert_success + assert_output_contains "fizzy" + assert_output_contains "main" +} + +@test "fizzy -V shows version" { + run fizzy -V + assert_success + assert_output_contains "fizzy" +} + +@test "fizzy version shows version" { + run fizzy version + assert_success + assert_output_contains "fizzy" +} + + +# self-update --help + +@test "self-update --help shows help" { + run fizzy --md self-update --help + assert_success + assert_output_contains "fizzy self-update" + assert_output_contains "Update fizzy CLI" +} + +@test "self-update -h shows help" { + run fizzy --md self-update -h + assert_success + assert_output_contains "fizzy self-update" +} + +@test "self-update --help --json outputs JSON" { + run fizzy --json self-update --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy self-update" +} + + +# self-update guards + +@test "self-update detects git checkout and suggests git pull" { + # Create a fake .git directory to simulate git checkout + mkdir -p "$FIZZY_ROOT/.git" + run fizzy --json self-update + rmdir "$FIZZY_ROOT/.git" + assert_failure + assert_output_contains "git checkout" + assert_output_contains "git pull" +} + +@test "self-update --check also detects git checkout" { + mkdir -p "$FIZZY_ROOT/.git" + run fizzy --json self-update --check + rmdir "$FIZZY_ROOT/.git" + assert_failure + assert_output_contains "git checkout" +} + + +# uninstall --help + +@test "uninstall --help shows help" { + run fizzy --md uninstall --help + assert_success + assert_output_contains "fizzy uninstall" + assert_output_contains "Remove fizzy CLI" +} + +@test "uninstall -h shows help" { + run fizzy --md uninstall -h + assert_success + assert_output_contains "fizzy uninstall" +} + +@test "uninstall --help --json outputs JSON" { + run fizzy --json uninstall --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy uninstall" +} + + +# uninstall guards + +@test "uninstall detects git checkout" { + mkdir -p "$FIZZY_ROOT/.git" + run fizzy --json uninstall + rmdir "$FIZZY_ROOT/.git" + assert_failure + assert_output_contains "git checkout" +} + +@test "uninstall without --force shows confirmation" { + # Skip this test if running from git checkout (which it always is in dev) + if [[ -d "$FIZZY_ROOT/.git" ]]; then + skip "Running from git checkout" + fi + run fizzy --md uninstall + assert_success + assert_output_contains "This will remove fizzy" + assert_output_contains "--force" +} diff --git a/cli/test/test_helper.bash b/cli/test/test_helper.bash new file mode 100644 index 0000000000..96b071f206 --- /dev/null +++ b/cli/test/test_helper.bash @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# test_helper.bash - Common test utilities for fizzy tests + + +# Setup/Teardown + +setup() { + # Store original environment + _ORIG_HOME="$HOME" + _ORIG_PWD="$PWD" + + # Create temp directories + TEST_TEMP_DIR="$(mktemp -d)" + TEST_HOME="$TEST_TEMP_DIR/home" + TEST_PROJECT="$TEST_TEMP_DIR/project" + + mkdir -p "$TEST_HOME/.config/fizzy" + mkdir -p "$TEST_PROJECT/.fizzy" + + # Set up test environment + export HOME="$TEST_HOME" + export FIZZY_ROOT="${BATS_TEST_DIRNAME}/.." + export PATH="$FIZZY_ROOT/bin:$PATH" + + # Clear environment variables that might interfere with tests + # Tests can set these as needed + unset FIZZY_URL + unset FIZZY_TOKEN + unset FIZZY_ACCOUNT_SLUG + unset FIZZY_BOARD_ID + unset FIZZY_COLUMN_ID + unset FIZZY_ACCOUNT + unset FIZZY_BOARD + + cd "$TEST_PROJECT" +} + +teardown() { + # Restore original environment + export HOME="$_ORIG_HOME" + cd "$_ORIG_PWD" + + # Clean up temp directory + if [[ -d "$TEST_TEMP_DIR" ]]; then + rm -rf "$TEST_TEMP_DIR" + fi +} + + +# Assertions + +assert_success() { + if [[ "$status" -ne 0 ]]; then + echo "Expected success (0), got $status" + echo "Output: $output" + return 1 + fi +} + +assert_failure() { + if [[ "$status" -eq 0 ]]; then + echo "Expected failure (non-zero), got $status" + echo "Output: $output" + return 1 + fi +} + +assert_exit_code() { + local expected="$1" + if [[ "$status" -ne "$expected" ]]; then + echo "Expected exit code $expected, got $status" + echo "Output: $output" + return 1 + fi +} + +assert_output_contains() { + local expected="$1" + if [[ "$output" != *"$expected"* ]]; then + echo "Expected output to contain: $expected" + echo "Actual output: $output" + return 1 + fi +} + +assert_output_not_contains() { + local unexpected="$1" + if [[ "$output" == *"$unexpected"* ]]; then + echo "Expected output NOT to contain: $unexpected" + echo "Actual output: $output" + return 1 + fi +} + +assert_output_starts_with() { + local expected="$1" + if [[ "${output:0:${#expected}}" != "$expected" ]]; then + echo "Expected output to start with: $expected" + echo "Actual output starts with: ${output:0:20}" + return 1 + fi +} + +assert_json_value() { + local path="$1" + local expected="$2" + local actual + actual=$(echo "$output" | jq -r "$path") + + if [[ "$actual" != "$expected" ]]; then + echo "JSON path $path: expected '$expected', got '$actual'" + echo "Full output: $output" + return 1 + fi +} + +assert_json_not_null() { + local path="$1" + local actual + actual=$(echo "$output" | jq -r "$path") + + if [[ "$actual" == "null" ]] || [[ -z "$actual" ]]; then + echo "JSON path $path: expected non-null value, got '$actual'" + return 1 + fi +} + +assert_json_contains() { + local path="$1" + local expected="$2" + local found + found=$(echo "$output" | jq -e "$path | select(. == \"$expected\")" 2>/dev/null) + + if [[ -z "$found" ]]; then + echo "JSON path $path: expected to contain '$expected'" + echo "Actual values: $(echo "$output" | jq -r "$path" 2>/dev/null)" + return 1 + fi +} + + +# Fixtures + +create_global_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + echo "$content" > "$TEST_HOME/.config/fizzy/config.json" +} + +create_local_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + echo "$content" > "$TEST_PROJECT/.fizzy/config.json" +} + +create_credentials() { + local access_token="${1:-test-token}" + local expires_at="${2:-$(($(date +%s) + 3600))}" + local scope="${3:-}" + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" + + local scope_field="" + if [[ -n "$scope" ]]; then + scope_field="\"scope\": \"$scope\"," + fi + + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "$base_url": { + "access_token": "$access_token", + "refresh_token": "test-refresh-token", + $scope_field + "expires_at": $expires_at + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" +} + +# Creates long-lived credentials without expiration (like Fizzy issues) +create_long_lived_credentials() { + local access_token="${1:-test-token}" + local scope="${2:-write}" + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" + + cat > "$TEST_HOME/.config/fizzy/credentials.json" << EOF +{ + "$base_url": { + "access_token": "$access_token", + "refresh_token": "", + "scope": "$scope", + "expires_at": null + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/credentials.json" +} + +create_accounts() { + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" + + cat > "$TEST_HOME/.config/fizzy/accounts.json" << EOF +{ + "$base_url": [ + {"id": "test-account-id", "name": "Test Account", "slug": "/99999999"} + ] +} +EOF +} + +create_client() { + local base_url="${FIZZY_BASE_URL:-http://fizzy.localhost:3006}" + # Remove trailing slash for consistent keys + base_url="${base_url%/}" + + cat > "$TEST_HOME/.config/fizzy/client.json" << EOF +{ + "$base_url": { + "client_id": "test-client-id", + "client_secret": "" + } +} +EOF + chmod 600 "$TEST_HOME/.config/fizzy/client.json" +} + +create_system_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + mkdir -p "$TEST_TEMP_DIR/etc/fizzy" + echo "$content" > "$TEST_TEMP_DIR/etc/fizzy/config.json" +} + +create_repo_config() { + # Note: Use quoted default to avoid bash parsing issue with closing braces + local content="${1:-"{}"}" + local git_root="${2:-$TEST_PROJECT}" + mkdir -p "$git_root/.fizzy" + echo "$content" > "$git_root/.fizzy/config.json" +} + +init_git_repo() { + local dir="${1:-$TEST_PROJECT}" + git -C "$dir" init --quiet 2>/dev/null || true +} + + +# Mock helpers + +mock_api_response() { + local response="$1" + export FIZZY_MOCK_RESPONSE="$response" +} + + +# Utility + +is_valid_json() { + echo "$output" | jq . &>/dev/null +} diff --git a/cli/test/users.bats b/cli/test/users.bats new file mode 100644 index 0000000000..34c52d4213 --- /dev/null +++ b/cli/test/users.bats @@ -0,0 +1,220 @@ +#!/usr/bin/env bats +# users.bats - Tests for identity and user commands + +load test_helper + + +# Identity command + +@test "identity --help shows help" { + run fizzy identity --help + assert_success + assert_output_contains "identity" + assert_output_contains "accounts" +} + +@test "identity -h shows help" { + run fizzy identity -h + assert_success + assert_output_contains "identity" +} + +@test "identity --help --json outputs JSON" { + run fizzy --json identity --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy identity" +} + +@test "identity requires authentication" { + run fizzy identity + assert_failure + assert_output_contains "Not authenticated" +} + + +# User show command + +@test "user show --help shows help" { + run fizzy user show --help + assert_success + assert_output_contains "user show" + assert_output_contains "id|email|name" +} + +@test "user show -h shows help" { + run fizzy user show -h + assert_success + assert_output_contains "user show" +} + +@test "user show --help --json outputs JSON" { + run fizzy --json user show --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user show" +} + +@test "user show without argument shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user show + assert_failure + assert_output_contains "User ID, email, or name required" +} + +@test "user show requires authentication" { + create_global_config '{"account_slug": "99999999"}' + + run fizzy user show abc123 + assert_failure + assert_output_contains "Not authenticated" +} + +@test "user show requires account_slug" { + create_credentials + + run fizzy user show abc123 + assert_failure + assert_output_contains "Account not configured" +} + + +# User update command + +@test "user update --help shows help" { + run fizzy user update --help + assert_success + assert_output_contains "user update" + assert_output_contains "--name" + assert_output_contains "--avatar" +} + +@test "user update -h shows help" { + run fizzy user update -h + assert_success + assert_output_contains "user update" +} + +@test "user update --help --json outputs JSON" { + run fizzy --json user update --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user update" +} + +@test "user update without argument shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update + assert_failure + assert_output_contains "User ID, email, or name required" +} + +@test "user update without options shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update abc123 + assert_failure + assert_output_contains "Nothing to update" +} + +@test "user update --avatar with missing file shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update abc123 --avatar /nonexistent/file.jpg + assert_failure + assert_output_contains "File not found" +} + +@test "user update requires authentication" { + create_global_config '{"account_slug": "99999999"}' + + run fizzy user update abc123 --name "New Name" + assert_failure + assert_output_contains "Not authenticated" +} + + +# User delete command + +@test "user delete --help shows help" { + run fizzy user delete --help + assert_success + assert_output_contains "user delete" + assert_output_contains "Deactivate" +} + +@test "user delete -h shows help" { + run fizzy user delete -h + assert_success + assert_output_contains "user delete" +} + +@test "user delete --help --json outputs JSON" { + run fizzy --json user delete --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user delete" +} + +@test "user delete without argument shows error" { + create_credentials + create_global_config '{"account_slug": "99999999"}' + + run fizzy user delete + assert_failure + assert_output_contains "User ID, email, or name required" +} + +@test "user delete requires authentication" { + create_global_config '{"account_slug": "99999999"}' + + run fizzy user delete abc123 + assert_failure + assert_output_contains "Not authenticated" +} + + +# User command help + +@test "user --help shows subcommands" { + run fizzy user --help + assert_success + assert_output_contains "show" + assert_output_contains "update" + assert_output_contains "delete" +} + +@test "user -h shows subcommands" { + run fizzy user -h + assert_success + assert_output_contains "show" + assert_output_contains "update" + assert_output_contains "delete" +} + +@test "user without subcommand shows help" { + run fizzy user + assert_success + assert_output_contains "show" + assert_output_contains "update" + assert_output_contains "delete" +} + +@test "user --help --json outputs JSON" { + run fizzy --json user --help + assert_success + is_valid_json + assert_json_value ".command" "fizzy user" +} + +@test "user unknown subcommand shows error" { + run fizzy user unknown + assert_failure + assert_output_contains "Unknown subcommand" +} diff --git a/cli/test/webhooks.bats b/cli/test/webhooks.bats new file mode 100644 index 0000000000..bff040bd24 --- /dev/null +++ b/cli/test/webhooks.bats @@ -0,0 +1,152 @@ +#!/usr/bin/env bats +# webhooks.bats - Tests for webhook commands + +load test_helper + + +# webhook --help + +@test "webhook --help shows help" { + run fizzy --md webhook --help + assert_success + assert_output_contains "fizzy webhook" + assert_output_contains "Manage webhooks" +} + +@test "webhook -h shows help" { + run fizzy --md webhook -h + assert_success + assert_output_contains "fizzy webhook" +} + +@test "webhook --help --json outputs JSON" { + run fizzy --json webhook --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# webhook create --help + +@test "webhook create --help shows help" { + run fizzy --md webhook create --help + assert_success + assert_output_contains "fizzy webhook create" + assert_output_contains "Create a webhook" +} + +@test "webhook create -h shows help" { + run fizzy --md webhook create -h + assert_success + assert_output_contains "fizzy webhook create" +} + +@test "webhook create --help --json outputs JSON" { + run fizzy --json webhook create --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + +@test "webhook create --help lists all available actions" { + # This test locks the available actions to match Webhook::PERMITTED_ACTIONS + run fizzy --md webhook create --help + assert_success + assert_output_contains "card_assigned" + assert_output_contains "card_closed" + assert_output_contains "card_postponed" + assert_output_contains "card_auto_postponed" + assert_output_contains "card_board_changed" + assert_output_contains "card_published" + assert_output_contains "card_reopened" + assert_output_contains "card_sent_back_to_triage" + assert_output_contains "card_triaged" + assert_output_contains "card_unassigned" + assert_output_contains "comment_created" +} + +@test "webhook create --help --json lists all available actions" { + # This test locks the available actions to match Webhook::PERMITTED_ACTIONS + run fizzy --json webhook create --help + assert_success + is_valid_json + + # Verify all 11 permitted actions are present + assert_json_contains ".available_actions[]" "card_assigned" + assert_json_contains ".available_actions[]" "card_closed" + assert_json_contains ".available_actions[]" "card_postponed" + assert_json_contains ".available_actions[]" "card_auto_postponed" + assert_json_contains ".available_actions[]" "card_board_changed" + assert_json_contains ".available_actions[]" "card_published" + assert_json_contains ".available_actions[]" "card_reopened" + assert_json_contains ".available_actions[]" "card_sent_back_to_triage" + assert_json_contains ".available_actions[]" "card_triaged" + assert_json_contains ".available_actions[]" "card_unassigned" + assert_json_contains ".available_actions[]" "comment_created" +} + + +# webhook create validation + +@test "webhook create without --board shows error" { + run fizzy webhook create --name "Test" --url "https://example.com" + assert_failure + assert_output_contains "Board" +} + +@test "webhook create without --name shows error" { + run fizzy webhook create --board "Test" --url "https://example.com" + assert_failure + assert_output_contains "name" +} + +@test "webhook create without --url shows error" { + run fizzy webhook create --board "Test" --name "Test" + assert_failure + assert_output_contains "url" +} + + +# webhook show --help + +@test "webhook show --help shows help" { + run fizzy --md webhook show --help + assert_success + assert_output_contains "fizzy webhook show" +} + +@test "webhook show -h shows help" { + run fizzy --md webhook show -h + assert_success + assert_output_contains "fizzy webhook show" +} + +@test "webhook show --help --json outputs JSON" { + run fizzy --json webhook show --help + assert_success + is_valid_json + assert_json_not_null ".command" +} + + +# webhook delete --help + +@test "webhook delete --help shows help" { + run fizzy --md webhook delete --help + assert_success + assert_output_contains "fizzy webhook delete" +} + +@test "webhook delete -h shows help" { + run fizzy --md webhook delete -h + assert_success + assert_output_contains "fizzy webhook delete" +} + +@test "webhook delete --help --json outputs JSON" { + run fizzy --json webhook delete --help + assert_success + is_valid_json + assert_json_not_null ".command" +} diff --git a/docs/API.md b/docs/API.md index bd44881276..2b5f1eb667 100644 --- a/docs/API.md +++ b/docs/API.md @@ -175,9 +175,24 @@ When a request fails, the API response will communicate the source of the proble | `401 Unauthorized` | Authentication failed or access token is invalid | | `403 Forbidden` | You don't have permission to perform this action | | `404 Not Found` | The requested resource doesn't exist or you don't have access to it | -| `422 Unprocessable Entity` | Validation failed (see error response format above) | +| `422 Unprocessable Entity` | Validation failed (see error response format below) | +| `429 Too Many Requests` | Rate limit exceeded (see endpoint-specific limits) | | `500 Internal Server Error` | An unexpected error occurred on the server | +### Endpoint-Specific Error Codes + +| Endpoint | 403 | 422 | 429 | +|----------|-----|-----|-----| +| `POST /:account_slug/account/exports` | | | ✓ Max 10 concurrent | +| `PUT /:account_slug/users/:id` | ✓ Not your profile or admin | ✓ Validation errors | | +| `DELETE /:account_slug/users/:id` | ✓ Not your profile or admin | | | +| `PUT /:account_slug/users/:id/role` | ✓ Not admin | | | +| `DELETE /:account_slug/cards/:card_number` | ✓ Not card admin | | | +| `POST /:account_slug/cards/:card_number/assignments` | | ✓ Invalid assignee | | +| `DELETE /:account_slug/cards/:card_number/comments/:comment_id` | ✓ Not comment creator | | | +| `DELETE /:account_slug/cards/:card_number/comments/:comment_id/reactions/:reaction_id` | ✓ Not reaction creator | | | +| `PUT /:account_slug/account/join_code` | | ✓ Validation errors | | + If a request contains invalid data for fields, such as entering a string into a number field, in most cases the API will respond with a `500 Internal Server Error`. Clients are expected to perform some validation on their end before making a request. A validation error will produce a `422 Unprocessable Entity` response, which will sometimes be accompanied by details about the error: @@ -451,11 +466,11 @@ Updates a Board. Only board administrators can update a board. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `name` | string | No | The name of the board | -| `all_access` | boolean | No | Whether any user in the account can access this board | -| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed | -| `public_description` | string | No | Rich text description shown on the public board page | -| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (only applicable when `all_access` is `false`) | +| `board[name]` | string | No | The name of the board | +| `board[all_access]` | boolean | No | Whether any user in the account can access this board | +| `board[auto_postpone_period]` | integer | No | Number of days of inactivity before cards are automatically postponed | +| `board[public_description]` | string | No | Rich text description shown on the public board page | +| `user_ids` | array | No | Array of *all* user IDs who should have access to this board (top-level parameter, only applicable when `all_access` is `false`) | __Request:__ @@ -465,12 +480,12 @@ __Request:__ "name": "Updated board name", "auto_postpone_period": 14, "public_description": "This is a **public** description of the board.", - "all_access": false, - "user_ids": [ - "03f5v9zppzlksuj4mxba2nbzn", - "03f5v9zjw7pz8717a4no1h8a7" - ] - } + "all_access": false + }, + "user_ids": [ + "03f5v9zppzlksuj4mxba2nbzn", + "03f5v9zjw7pz8717a4no1h8a7" + ] } ``` @@ -486,6 +501,51 @@ __Response:__ Returns `204 No Content` on success. +### `POST /:account_slug/boards/:board_id/publication` + +Publishes a board publicly. Once published, the board can be viewed by anyone with the shareable link. + +__Response:__ + +```json +{ + "key": "abc123def456", + "url": "https://app.fizzy.do/public/boards/abc123def456" +} +``` + +The `key` is the shareable identifier and `url` is the full public URL. + +### `DELETE /:account_slug/boards/:board_id/publication` + +Unpublishes a board. The board will no longer be accessible via its public link. + +__Response:__ + +Returns `204 No Content` on success. + +### `PUT /:account_slug/boards/:board_id/entropy` + +Updates the auto-postpone settings for a board. Cards on this board that have been inactive for the specified period will be automatically moved to "Not Now". + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `auto_postpone_period` | integer | No | Number of days of inactivity before cards are automatically postponed. Set to `null` to use the account default. | + +__Request:__ + +```json +{ + "board": { + "auto_postpone_period": 14 + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + ## Cards Cards are tasks or items of work on a board. They can be organized into columns, tagged, assigned to users, and have comments. @@ -524,6 +584,7 @@ __Response:__ "description_html": "

Hello, World!

", "image_url": null, "tags": ["programming"], + "closed": false, "golden": false, "last_active_at": "2025-12-05T19:38:48.553Z", "created_at": "2025-12-05T19:38:48.540Z", @@ -553,8 +614,10 @@ __Response:__ "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, + "assignees": [], + "has_more_assignees": false, "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments" - }, + } ] ``` @@ -613,6 +676,18 @@ __Response:__ "created_at": "2025-12-05T19:36:35.401Z", "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjw7pz8717a4no1h8a7" }, + "assignees": [ + { + "id": "03f5v9zjysoy0fqs9yg0ei3hq", + "name": "Jason Fried", + "role": "member", + "active": true, + "email_address": "jason@example.com", + "created_at": "2025-12-05T19:36:35.419Z", + "url": "http://fizzy.localhost:3006/897362094/users/03f5v9zjysoy0fqs9yg0ei3hq" + } + ], + "has_more_assignees": false, "comments_url": "http://fizzy.localhost:3006/897362094/cards/4/comments", "steps": [ { @@ -629,7 +704,7 @@ __Response:__ } ``` -> **Note:** The `closed` field indicates whether the card is in the "Done" state. The `column` field is only present when the card has been triaged into a column; cards in "Maybe?", "Not Now" or "Done" will not have this field. +> **Note:** The `closed` field indicates whether the card is in the "Done" state. The `column` field is only present when the card has been triaged into a column; cards in "Maybe?", "Not Now" or "Done" will not have this field. The `assignees` array includes up to 5 assigned users; when there are more, `has_more_assignees` is `true`. ### `POST /:account_slug/boards/:board_id/cards` @@ -639,12 +714,12 @@ Creates a new card in a board. |-----------|------|----------|-------------| | `title` | string | Yes | The title of the card | | `description` | string | No | Rich text description of the card | -| `status` | string | No | Initial status: `published` (default), `drafted` | | `image` | file | No | Header image for the card | -| `tag_ids` | array | No | Array of tag IDs to apply to the card | | `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) | | `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) | +> **Note:** Cards created via the API are automatically published. To tag a card, use the `POST /:account_slug/cards/:card_number/taggings` endpoint after creation. + __Request:__ ```json @@ -668,9 +743,7 @@ Updates a card. |-----------|------|----------|-------------| | `title` | string | No | The title of the card | | `description` | string | No | Rich text description of the card | -| `status` | string | No | Card status: `drafted`, `published` | | `image` | file | No | Header image for the card | -| `tag_ids` | array | No | Array of tag IDs to apply to the card | | `last_active_at` | datetime | No | Override last activity timestamp (ISO 8601 format) | __Request:__ @@ -803,6 +876,34 @@ __Response:__ Returns `204 No Content` on success. +### `POST /:account_slug/cards/:card_number/publish` + +Publishes a drafted card. Cards with `status: drafted` are only visible to their creator until published. + +__Response:__ + +Returns `204 No Content` on success. + +### `PUT /:account_slug/cards/:card_number/board` + +Moves a card to a different board. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `board_id` | string | Yes | The ID of the target board (top-level parameter) | + +__Request:__ + +```json +{ + "board_id": "03f5v9zkft4hj9qq0lsn9ohcm" +} +``` + +__Response:__ + +Returns `204 No Content` on success. + ## Comments Comments are attached to cards and support rich text. @@ -904,7 +1005,8 @@ Updates a comment. Only the comment creator can update their comments. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `body` | string | Yes | The updated comment body | +| `body` | string | No | The updated comment body | +| `created_at` | datetime | No | Override creation timestamp (ISO 8601 format) | __Request:__ @@ -918,7 +1020,7 @@ __Request:__ __Response:__ -Returns the updated comment. +Returns `204 No Content` on success. ### `DELETE /:account_slug/cards/:card_number/comments/:comment_id` @@ -1101,13 +1203,19 @@ __Response:__ { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "Recording", - "color": "var(--color-card-default)", + "color": { + "name": "Blue", + "value": "var(--color-card-default)" + }, "created_at": "2025-12-05T19:36:35.534Z" }, { "id": "03f5v9zkft4hj9qq0lsn9ohcn", "name": "Published", - "color": "var(--color-card-4)", + "color": { + "name": "Lime", + "value": "var(--color-card-4)" + }, "created_at": "2025-12-05T19:36:35.534Z" } ] @@ -1123,7 +1231,10 @@ __Response:__ { "id": "03f5v9zkft4hj9qq0lsn9ohcm", "name": "In Progress", - "color": "var(--color-card-default)", + "color": { + "name": "Blue", + "value": "var(--color-card-default)" + }, "created_at": "2025-12-05T19:36:35.534Z" } ``` @@ -1183,6 +1294,22 @@ __Response:__ Returns `204 No Content` on success. +### `POST /:account_slug/columns/:column_id/left_position` + +Moves a column one position to the left in the board's column order. + +__Response:__ + +Returns `204 No Content` on success. + +### `POST /:account_slug/columns/:column_id/right_position` + +Moves a column one position to the right in the board's column order. + +__Response:__ + +Returns `204 No Content` on success. + ## Users Users represent people who have access to an account. @@ -1283,6 +1410,28 @@ __Response:__ Returns `204 No Content` on success. +### `PUT /:account_slug/users/:user_id/role` + +Updates a user's role. Only account owners and admins can change user roles. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `role` | string | Yes | The new role: `admin` or `member` | + +__Request:__ + +```json +{ + "user": { + "role": "admin" + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + ## Notifications Notifications inform users about events that happened in the account, such as comments, assignments, and card updates. @@ -1345,3 +1494,359 @@ Marks all unread notifications as read. __Response:__ Returns `204 No Content` on success. + +## Account Settings + +Account settings allow administrators to manage account-wide configuration. + +### `GET /:account_slug/account/settings` + +Returns the account settings. Only administrators can view account settings. + +__Response:__ + +```json +{ + "id": "03f5v9zjskhcii2r45ih3u1rq", + "name": "37signals" +} +``` + +### `PUT /:account_slug/account/settings` + +Updates the account settings. Only administrators can update account settings. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | The account name | + +__Request:__ + +```json +{ + "account": { + "name": "New Account Name" + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +### `PUT /:account_slug/account/entropy` + +Updates the account-wide auto-postpone settings. This sets the default entropy period for all boards in the account. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `auto_postpone_period` | integer | Yes | Number of days of inactivity before cards are automatically postponed | + +__Request:__ + +```json +{ + "entropy": { + "auto_postpone_period": 30 + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +## Join Codes + +Join codes allow new users to join an account without an explicit invitation. + +### `GET /:account_slug/account/join_code` + +Returns the account's join code configuration. + +__Response:__ + +```json +{ + "code": "ABC123", + "usage_limit": 50, + "usage_count": 12 +} +``` + +### `PUT /:account_slug/account/join_code` + +Updates the join code configuration. Only administrators can update join code settings. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `usage_limit` | integer | No | Maximum number of times the join code can be used | + +__Request:__ + +```json +{ + "account_join_code": { + "usage_limit": 100 + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/account/join_code` + +Resets the join code. The existing code is invalidated and a new code is generated. + +__Response:__ + +```json +{ + "code": "XYZ789", + "usage_limit": 100, + "usage_count": 0 +} +``` + +## Exports + +Exports allow users to download a complete archive of their account data. + +### `GET /:account_slug/account/exports/:export_id` + +Returns the status of an export. + +__Response:__ + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "status": "completed", + "created_at": "2025-12-05T19:36:35.534Z" +} +``` + +### `POST /:account_slug/account/exports` + +Starts a new export. The export will be built asynchronously and the user will be notified by email when it's ready for download. + +__Response:__ + +Returns `202 Accepted` with the export details: + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "status": "pending", + "created_at": "2025-12-05T19:36:35.534Z" +} +``` + +## Webhooks + +Webhooks allow you to receive real-time notifications when events happen on a board. + +### `GET /:account_slug/boards/:board_id/webhooks` + +Returns a list of webhooks configured for the board. Only board administrators can view webhooks. + +__Response:__ + +```json +[ + { + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "name": "My Webhook", + "url": "https://example.com/webhook", + "subscribed_actions": ["card_closed", "card_reopened", "comment_created"], + "created_at": "2025-12-05T19:36:35.534Z" + } +] +``` + +### `GET /:account_slug/boards/:board_id/webhooks/:webhook_id` + +Returns a specific webhook. + +__Response:__ + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "name": "My Webhook", + "url": "https://example.com/webhook", + "subscribed_actions": ["card_closed", "card_reopened", "comment_created"], + "created_at": "2025-12-05T19:36:35.534Z", + "updated_at": "2025-12-05T19:36:35.534Z" +} +``` + +### `POST /:account_slug/boards/:board_id/webhooks` + +Creates a new webhook for the board. Only board administrators can create webhooks. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | A descriptive name for the webhook | +| `url` | string | Yes | The URL to receive webhook payloads | +| `subscribed_actions` | array | No | List of event types to subscribe to | + +__Available actions:__ + +- `card_assigned` - A user was assigned to a card +- `card_closed` - A card was closed +- `card_postponed` - A card was manually moved to "Not Now" +- `card_auto_postponed` - A card was automatically postponed due to inactivity +- `card_board_changed` - A card was moved to a different board +- `card_published` - A drafted card was published +- `card_reopened` - A card was reopened +- `card_sent_back_to_triage` - A card was sent back to triage +- `card_triaged` - A card was moved to a column +- `card_unassigned` - A user was unassigned from a card +- `comment_created` - A comment was added + +__Request:__ + +```json +{ + "webhook": { + "name": "CI Integration", + "url": "https://ci.example.com/fizzy/webhook", + "subscribed_actions": ["card_closed", "comment_created"] + } +} +``` + +__Response:__ + +Returns `201 Created` with the webhook details: + +```json +{ + "id": "03f5v9zo9qlcwwpyc0ascnikz", + "name": "CI Integration", + "url": "https://ci.example.com/fizzy/webhook", + "subscribed_actions": ["card_closed", "comment_created"], + "created_at": "2025-12-05T19:36:35.534Z" +} +``` + +### `PUT /:account_slug/boards/:board_id/webhooks/:webhook_id` + +Updates a webhook. The webhook URL cannot be changed after creation. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | A descriptive name for the webhook | +| `subscribed_actions` | array | No | List of event types to subscribe to | + +__Request:__ + +```json +{ + "webhook": { + "name": "Updated Webhook Name", + "subscribed_actions": ["card_closed", "card_reopened", "comment_created"] + } +} +``` + +__Response:__ + +Returns `204 No Content` on success. + +### `DELETE /:account_slug/boards/:board_id/webhooks/:webhook_id` + +Deletes a webhook. + +__Response:__ + +Returns `204 No Content` on success. + +### Webhook Payload Format + +When an event occurs, Fizzy sends a POST request to your webhook URL with a JSON payload: + +```json +{ + "id": "evt_03f5v9zo9qlcwwpyc0ascnikz", + "action": "card_triaged", + "created_at": "2025-12-05T19:36:35.534Z", + "eventable": { + "id": "03f5vaeq985jlvwv3arl4srq2", + "number": 42, + "title": "New feature request", + "url": "http://fizzy.localhost:3006/897362094/cards/42" + }, + "board": { + "id": "03f5v9zkft4hj9qq0lsn9ohcm", + "name": "Fizzy", + "url": "http://fizzy.localhost:3006/897362094/boards/03f5v9zkft4hj9qq0lsn9ohcm" + }, + "creator": { + "id": "03f5v9zjw7pz8717a4no1h8a7", + "name": "David Heinemeier Hansson", + "email_address": "david@example.com" + } +} +``` + +Your endpoint should return a `2xx` status code to acknowledge receipt. Failed deliveries will be retried with exponential backoff. + +--- + +## API Docs Maintenance + +Use this checklist when the API changes: + +### 1. Enumerate JSON endpoints + +```bash +bin/rails routes > /tmp/fizzy_routes.txt +rg -n "format\.json|render json|head :|respond_to :json" app/controllers +rg -n "json.jbuilder" app/views # find implicit JSON responses +``` + +### 2. Normalize paths before diff + +Routes use `:id`/`:card_id`; docs use `:card_number`. Normalize placeholders before comparing. Docs use `/:account_slug` prefix (stripped by middleware). Some nested routes (e.g., column reordering) use `shallow: true`. + +### 3. Validate request params + +Confirm `params.expect/require` and any **top-level params** (e.g., `params[:user_ids]`). Update docs to match exact nesting (top-level vs `resource: { ... }`). + +### 4. Validate responses + +- `head :no_content` → **204 No Content** +- `head :created` + `location:` → **201 + Location header** +- `head :accepted` → **202 Accepted** +- Jbuilder view → verify fields in `app/views/**/*.json.jbuilder` + +### 5. Errors + +```bash +rg -n "unprocessable_entity|forbidden|unauthorized|too_many_requests" app/controllers +``` + +Add 401/403/422/429 notes to each affected endpoint. + +### 6. Query parameters + +For `/cards`, verify `Filter::Params::PERMITTED_PARAMS` and document all `terms[]`, `indexed_by`, `sorted_by`, etc. + +### 7. Webhook actions + +Verify against `Webhook::PERMITTED_ACTIONS` (`app/models/webhook.rb`). Update both docs and CLI help (`cli/lib/commands/webhooks.sh`). + +### 8. CLI alignment + +Ensure CLI help/examples match docs (params, nesting, status codes). + +### 9. Regression check + +```bash +bin/rails test test/controllers/**/*_test.rb +bats cli/test/*.bats +``` diff --git a/test/controllers/boards/involvements_controller_test.rb b/test/controllers/boards/involvements_controller_test.rb index 45587176a0..50e77fff59 100644 --- a/test/controllers/boards/involvements_controller_test.rb +++ b/test/controllers/boards/involvements_controller_test.rb @@ -15,4 +15,15 @@ class Boards::InvolvementsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + + test "update via JSON returns no content" do + board = boards(:writebook) + board.access_for(users(:kevin)).access_only! + + assert_changes -> { board.access_for(users(:kevin)).involvement }, from: "access_only", to: "watching" do + put board_involvement_path(board, involvement: "watching"), as: :json + end + + assert_response :no_content + end end diff --git a/test/controllers/boards/publications_controller_test.rb b/test/controllers/boards/publications_controller_test.rb index 568c215715..3ffe54bd28 100644 --- a/test/controllers/boards/publications_controller_test.rb +++ b/test/controllers/boards/publications_controller_test.rb @@ -49,4 +49,27 @@ class Boards::PublicationsControllerTest < ActionDispatch::IntegrationTest assert_response :forbidden assert @board.reload.published? end + + test "publish via JSON returns key and url" do + assert_not @board.published? + + post board_publication_path(@board, format: :json) + + assert_response :success + assert @board.reload.published? + + json = response.parsed_body + assert_equal @board.publication.key, json["key"] + assert_match %r{/public/boards/#{@board.publication.key}}, json["url"] + end + + test "unpublish via JSON returns no content" do + @board.publish + assert @board.published? + + delete board_publication_path(@board, format: :json) + + assert_response :no_content + assert_not @board.reload.published? + end end diff --git a/test/controllers/cards/pins_controller_test.rb b/test/controllers/cards/pins_controller_test.rb index 3bc5698277..fbe19790ed 100644 --- a/test/controllers/cards/pins_controller_test.rb +++ b/test/controllers/cards/pins_controller_test.rb @@ -28,4 +28,22 @@ class Cards::PinsControllerTest < ActionDispatch::IntegrationTest assert_response :success end + + test "create via JSON returns no content" do + assert_not cards(:layout).pinned_by?(users(:kevin)) + + post card_pin_path(cards(:layout)), as: :json + + assert_response :no_content + assert cards(:layout).pinned_by?(users(:kevin)) + end + + test "destroy via JSON returns no content" do + assert cards(:shipping).pinned_by?(users(:kevin)) + + delete card_pin_path(cards(:shipping)), as: :json + + assert_response :no_content + assert_not cards(:shipping).pinned_by?(users(:kevin)) + end end