From 25d6b997cb9d4e8971feb28d4035506f107b096e Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sat, 7 Nov 2026 17:02:21 -0500 Subject: [PATCH 1/7] feat!: give projects a command line that runs on the API or on SQLite Bare `projects` prints help, and the board moves to `projects tui`. The project verbs sit at the root, and the item verbs under `projects items`, with tasks, dependencies and memberships beneath. Every read takes --json, and each document the tool composes has a schema under cli/schemas. The config file's `api` key, or --api, sends every command to the projects API. `projects auth login ` logs in with the device grant, and PROJECTS_CLIENT_SECRET switches to client credentials. Without `api`, the commands run the API's own routes in process over the SQLite database. A refusal, an exit code and a JSON answer are then the same in both modes. `api` beside `[sync] enabled = true` refuses every command. With sync on, a command that writes refuses, because the write would land in the copy with nothing queued and the next pull would delete it. In API mode the board reads through apiclient.Backend, rereads on each sync interval and on R, and shows API and the host in its status bar. It reads every task in one request rather than one per listed item. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b7cb-45d0-73ce-a2e4-69fe97cd3689 Worklog: 2402.1 --- CLAUDE.md | 107 ++-- README.md | 224 ++++++--- apiclient/backend.go | 375 ++++++++++++++ apiclient/client.go | 169 +++++++ apiclient/project_item_tasks.go | 68 +++ apiclient/project_items.go | 135 +++++ apiclient/projects.go | 90 ++++ apiclient/transports.go | 137 +++++ backend/backend.go | 3 + both_modes_test.go | 144 ++++++ cli/api_errors.go | 117 +++++ cli/api_login.go | 98 ++++ cli/auth.go | 430 ++++++++++++++++ cli/data_source.go | 249 ++++++++++ cli/flags.go | 240 +++++++++ cli/focus_queue.go | 227 +++++++++ cli/item_create.go | 294 +++++++++++ cli/item_dependencies.go | 372 ++++++++++++++ cli/item_memberships.go | 99 ++++ cli/item_tasks.go | 205 ++++++++ cli/items.go | 463 +++++++++++++++++ cli/projects.go | 399 +++++++++++++++ cli/root.go | 174 +++++++ cli/schemas/auth-status.schema.json | 63 +++ cli/schemas/board-snapshot.schema.json | 217 ++++++++ cli/schemas/dependency-tree.schema.json | 60 +++ cli/schemas/focus-queue.schema.json | 278 +++++++++++ .../item-with-tasks-and-blockers.schema.json | 393 +++++++++++++++ cli/schemas/project-with-items.schema.json | 181 +++++++ cli/sync.go | 72 +++ cli/tables.go | 214 ++++++++ cli/terminal_input.go | 143 ++++++ cli/tui.go | 127 +++++ config.example.toml | 23 +- config/config.go | 11 + former_name_test.go | 5 +- go.mod | 14 +- go.sum | 26 +- main.go | 170 +------ main_test.go | 101 ---- prompt/plain.go | 50 ++ prompt/prompt.go | 468 ++++++++++++++++++ prompt/terminal.go | 205 ++++++++ prompt/validate.go | 72 +++ real_binary_flows_test.go | 62 ++- repos/repos.go | 42 +- service/service.go | 8 + sync/backend.go | 4 + .../api-mode/item-added.style | 107 ++++ .../api-mode/item-added.txt | 30 ++ .../TestRealBinaryFlows/api-mode/opened.style | 104 ++++ .../TestRealBinaryFlows/api-mode/opened.txt | 30 ++ .../api-mode/read-again.style | 107 ++++ .../api-mode/read-again.txt | 30 ++ testdata/flows/api-mode.keys | 22 + tui/app.go | 141 +++--- tui/app_test.go | 4 +- tui/realdata_test.go | 2 +- tui/styles.go | 3 + tui/without_terminal.go | 49 ++ 60 files changed, 8015 insertions(+), 442 deletions(-) create mode 100644 apiclient/backend.go create mode 100644 apiclient/client.go create mode 100644 apiclient/project_item_tasks.go create mode 100644 apiclient/project_items.go create mode 100644 apiclient/projects.go create mode 100644 apiclient/transports.go create mode 100644 both_modes_test.go create mode 100644 cli/api_errors.go create mode 100644 cli/api_login.go create mode 100644 cli/auth.go create mode 100644 cli/data_source.go create mode 100644 cli/flags.go create mode 100644 cli/focus_queue.go create mode 100644 cli/item_create.go create mode 100644 cli/item_dependencies.go create mode 100644 cli/item_memberships.go create mode 100644 cli/item_tasks.go create mode 100644 cli/items.go create mode 100644 cli/projects.go create mode 100644 cli/root.go create mode 100644 cli/schemas/auth-status.schema.json create mode 100644 cli/schemas/board-snapshot.schema.json create mode 100644 cli/schemas/dependency-tree.schema.json create mode 100644 cli/schemas/focus-queue.schema.json create mode 100644 cli/schemas/item-with-tasks-and-blockers.schema.json create mode 100644 cli/schemas/project-with-items.schema.json create mode 100644 cli/sync.go create mode 100644 cli/tables.go create mode 100644 cli/terminal_input.go create mode 100644 cli/tui.go delete mode 100644 main_test.go create mode 100644 prompt/plain.go create mode 100644 prompt/prompt.go create mode 100644 prompt/terminal.go create mode 100644 prompt/validate.go create mode 100644 testdata/TestRealBinaryFlows/api-mode/item-added.style create mode 100644 testdata/TestRealBinaryFlows/api-mode/item-added.txt create mode 100644 testdata/TestRealBinaryFlows/api-mode/opened.style create mode 100644 testdata/TestRealBinaryFlows/api-mode/opened.txt create mode 100644 testdata/TestRealBinaryFlows/api-mode/read-again.style create mode 100644 testdata/TestRealBinaryFlows/api-mode/read-again.txt create mode 100644 testdata/flows/api-mode.keys create mode 100644 tui/without_terminal.go diff --git a/CLAUDE.md b/CLAUDE.md index 9fb1a12..e889aad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,22 +25,28 @@ data carries notes one line long. A change about notes needs a real-length markdown note written into the dev API first. ⚠️ **`.envrc` is machine-local and is not in this repo. Check it exists before -running anything here.** Without it nothing is overridden, so `go run .` opens +running anything here.** Without it nothing is overridden, so `go run . tui` opens the production database against the production API — which is the accident the coupled-variables rule below exists to prevent, arrived at from the other direction. `task setup`, which `worktree new` runs, links the primary checkout's `.envrc` into a worktree. **A shell without direnv's hook ignores `.envrc`, and Claude Code's tool shell is -one.** There, a bare `go run .` opens production even with the file in place. +one.** There, a bare `go run . list` reads production even with the file in place. Load it explicitly: ```bash -direnv exec . go run . # TUI against dev.db +direnv exec . go run . tui # the board against dev.db +direnv exec . go run . list # a command against dev.db direnv exec . go run . sync # one reconcile against the dev API direnv exec . env | rg '^PROJECTS' # nothing printed: no .envrc here ``` +`.envrc` turns sync on, so a command that writes refuses there, naming `api`. Reads +answer from `dev.db`. A write is checked in the board, or through `both_modes_test.go`. +A config file that sets `api` beside the `PROJECTS_SYNC=true` from `.envrc` refuses +every command, naming both, so the pair cannot cross into production that way either. + **The former name is handled in one file.** `config/former_name.go` refuses a retired variable that is still set, and a config still at the former path. It moves the old data directory on the first run that opens the default database. A run naming its database moves nothing, so a run from `.envrc` leaves @@ -48,8 +54,8 @@ the real data where it is. An `.envrc` still exporting the old names is refused which keeps it from falling through to the production database. On a machine upgrading, run `projects` once on the default database before editing `.envrc`, so `dev.db` moves with the rest. The `MIGRATION` marker there says when the file retires. Anything that opens the database stays after -`MoveDataUnderFormerName` in `PersistentPreRunE`, because opening first creates an empty database, and -the move refuses to run over one. +`MoveDataUnderFormerName` in `openDatabase` in `cli/data_source.go`, because opening first creates an +empty database, and the move refuses to run over one. Build, vet and unit tests check code correctness, not TUI correctness. A TUI change is verified by driving the built binary, which the flows below do with nobody at the keyboard. @@ -62,18 +68,26 @@ window size is reproducible here and invisible in a manual pass. It does not rep ## The flows drive the real binary -`real_binary_flows_test.go` builds `projects` and replays each `testdata/flows/*.keys` script against it -on a pty with [tuidrive](https://github.com/datapointchris/tuidrive), at a fixed size. Each frame a -script names compares with `testdata/TestRealBinaryFlows//.txt` and `.style`. +`real_binary_flows_test.go` builds `projects` and replays each `testdata/flows/*.keys` script against +`projects tui` on a pty with [tuidrive](https://github.com/datapointchris/tuidrive), at a fixed size. +Each frame a script names compares with `testdata/TestRealBinaryFlows//.txt` and `.style`. ```bash go test -run TestRealBinaryFlows . # compare TUIDRIVE_UPDATE=1 go test -run TestRealBinaryFlows . # accept a change, then read the diff +go test -run TestTheCommandsAnswerTheSameInBothModes . # the commands, in both modes ``` - **`seedFixtureDatabase` writes through `service.Service`**, the path the TUI writes through, so the fixture holds only what the tool's own rules allow. There is no stand-in of the sync API, and sync stays off because no `PROJECTS_SYNC` reaches the program. +- **`XDG_CONFIG_HOME` points under the test's temporary directory**, so the machine's own config file + never reaches the program. +- **The API is `routes.New` over a seeded SQLite database in an `httptest.Server`.** `serveOverHTTP` + starts it and `writeAPIConfig` points the config file's `api` at it, with a `token_command` handing + a stand-in token. The `api-mode` flow drives the board through it, and `both_modes_test.go` runs the + same commands through it and through a database, checking each `--json` answer against + `cli/schemas`. - **`PROJECTS_DB` is relative, and the program's working directory is the test's temporary directory.** The status bar prints the path, and the hints clip against its length. A relative path keeps every frame the same on every machine. @@ -87,22 +101,28 @@ To look at something while developing, write the keys to a script and replay it: ```bash D=$(mktemp -d) -go build -o "$D/projects" . && tuidrive run look.keys --out frames --dir "$D" --env PROJECTS_DB=projects.db -- "$D/projects" +go build -o "$D/projects" . && tuidrive run look.keys --out frames --dir "$D" \ + --env PROJECTS_DB=projects.db --env XDG_CONFIG_HOME="$D/config" -- "$D/projects" tui ``` `tuidrive run --help` states what reaches the program and what `--out` writes. Always name `PROJECTS_DB` -under a temporary directory, whatever it says, because `projects` with none opens the production database. +and `XDG_CONFIG_HOME` under a temporary directory, whatever it says. With neither, `projects tui` opens +the production database, or reads the machine's config and talks to its API. ## Storage, service and entry point are three layers, and every rule is in `service` Ports and adapters at the storage seam, so a second store runs the same rules and a new rule lands once. ```text -tui, main.go api/routes - │ backend.Backend, which │ *service.Service, scoped to each request by WithContext - │ sync.SyncBackend also │ - │ satisfies by wrapping it │ - ▼ ▼ +cli commands tui api/main.go + │ apiclient.Client │ backend.Backend, which │ + │ │ sync.SyncBackend and │ + ▼ │ apiclient.Backend also ▼ +api/routes, in process │ satisfy api/routes + │ in database mode; │ │ *service.Service, scoped + │ across HTTP to │ │ to each request by + │ projects-api otherwise │ │ WithContext + ▼ ▼ ▼ service.Service every rule │ store.Store model types only ▼ @@ -116,6 +136,13 @@ store/postgres the same, with goose migrations and an audit trigger and imports no `database/sql`. - **`service.Service` satisfies `backend.Backend`**, so the TUI reaches every rule through that interface, and outside its tests imports neither `service` nor a store. +- **A command reaches the data only through `apiclient.Client`.** In database mode `openDatabase` in + `cli/data_source.go` serves `routes.New` over the local service through `apiclient.InProcess`. So a + command meets the API's routes, refusals and JSON in both modes, and a new command is written once. +- **The binary links no server dependency.** `go list -deps .` holds no pgx, goose or goapiauth beyond + `goapiauth/clientid`, the import-free client-id prefixes goclilogin shares with the API. `api/routes` + and `api/wire` import none of them. The login settings sit in `cli/api_login.go` rather than + `config`, because `api/routes` reaches `config` through `service` and `repos`. - **`store.Store` in `store/store.go` takes and returns `model` types.** A rule that writes runs inside `InTx(ctx, func(tx store.Operations) error)` and calls `tx`. `Operations` has no `InTx`, so a transaction cannot nest. What a rule decides on, it reads through `tx` too, and `InTx` promises those @@ -137,21 +164,42 @@ Two carve-outs are declared here rather than assumed: always carried, so on a synced database `kind` and `completed_at` are what that database set, and neither arrives from elsewhere. It ends when the sync engine is deleted. `generated` can then move under `store/sqlite/internal`, where the compiler enforces the seam. -- **Local mode links `service` directly.** A client normally depends on the wire contract and never on a - server's types, because importing them couples deploy order. Local mode has no server, so that reason - does not reach it. Anything on the HTTP path keeps its own lean structs. - -## `projects` is a TUI; there is no item CLI - -`projects` is driven through the TUI. Agents and scripts reach the same projects and items through -the API's own clients. The subcommands here are `sync`, `update`, `version` and `completion`, and none -of them reads or writes an item. A verb added here would be a second front door onto rows those -clients already own, spelled independently of them. +- **Database mode links `service` and `api/routes` directly.** A client normally depends on the wire + contract and never on a server's types, because importing them couples deploy order. Database mode + serves itself, so that reason does not reach it. `apiclient` speaks `api/wire` and converts to + `model` for the board, and imports no route, service or store. The sync engine keeps its own lean + structs. + +## `projects` is a command line, and `projects tui` is the board + +Bare `projects` prints help. The project verbs sit at the root, and the item verbs under +`projects items`, with `tasks`, `dependencies` and `memberships` as nouns under it. `cli/root.go` +builds the tree. Help renders through `gotermstyle/cobrahelp`. + +- **The `api` key, or `--api`, chooses the mode, and neither means the database.** `openDataSource` + decides it in each command's `RunE`, so `help`, `completion` and `version` open nothing. +- **`api` beside `[sync] enabled = true` refuses every command**, naming both keys, because they are + two authorities for the same rows. +- **With sync on, a command that writes refuses before it asks anything.** `openForWriting` holds the + sentence, and `refusingWrites` answers 409 with it for any write that reaches the in-process routes + without it. A write there would land in the copy with nothing queued, and the next pull deletes it. +- **Exit codes are 0, 1 for refused or failed, 2 for usage, and 4 for no answer.** `exitCodeFor` in + `cli/root.go` maps them. A 422 is usage. A 429, a 5xx or a transport error after + `apiclient.Attempts` is no answer. A refused credential is 1. +- **A `--json` document this tool composes carries `schema_version`**, and its schema is under + `cli/schemas`. A new shape takes a file there and a row in `both_modes_test.go`. Every other + `--json` answer is the API's own, under `api/schemas`. +- **Login is goclilogin**, configured in `cli/api_login.go`: the keyring service `projects`, the + client id `projects-cli-`, and the client-credentials grant with `projects.items.read` when + `PROJECTS_CLIENT_SECRET` is set. +- **The repo-name ban runs in database mode only.** `LocalService` sets `RefusingRepoNames`, and an API + sets none. An item shows its **number**, the handle the API assigns and answers with. The UUID is the sync key and stops there. -The number is assigned by whichever database is the authority. With sync on that is the API, and +The number is assigned by whichever database is the authority. Through an API, the API numbers it as +it creates it. With sync on that is the API, and `pushCreatedItem` writes the number from the create response into the local row, so the TUI shows it once the push lands rather than after the next pull. With sync off, `AssigningItemNumbers` makes `projects` allocate `max+1` itself, which cannot collide because that database is a disjoint universe. @@ -190,8 +238,9 @@ started this. A missing registry bans nothing. Enforcement is on the service (`RefusingRepoNames`), the one path every create -and rename takes. It is an option `main.go` sets. `api/main.go` does not, because a -container holds no registry, so the API bans nothing, as a missing registry does. +and rename takes. It is an option `cli.LocalService` sets for database mode. `api/main.go` does not, +because a container holds no registry, so the API bans nothing, as a missing registry does. A +command through an API therefore bans nothing either. ## A project has a status, and it is not an `archived` flag @@ -314,7 +363,7 @@ per test. Set `TEST_DATABASE_URL` to use a running server. `-short` skips them, - **A row's every field is always sent: an empty value as `null`, and an empty list as `[]`. Times are UTC with a `Z`.** `wire.Time` prints six fractional digits when there is a fraction, and none when there is not. Existing clients index these by name. -- **`/api/v2` is this API's version signal, and no answer carries `schema_version`.** That is an exception +- **`/api/v1` is this API's version signal, and no answer carries `schema_version`.** That is an exception to the rule that a shape names its version in the document, because a list answers a bare array, which has no place for the field. A field added is not a new version, and a field reshaped or removed is. - **A `PATCH` is read field by field**, by `requestBody`, because a struct cannot tell a field left out diff --git a/README.md b/README.md index c2acbb3..082342d 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,20 @@ # projects -A terminal UI for organizing personal projects. It holds the projects, the items that make each one -up, the dependencies between items, and the sub-tasks on each item. It reads and writes a local -SQLite database, so it works with no network at all. Sync to an API server is optional, and runs in -the background when it is on. +A command line and a terminal UI for organizing personal projects. It holds the projects, the items +that make each one up, the dependencies between items, and the sub-tasks on each item. -`projects` is the TUI and nothing more. Its subcommands are `sync`, `update`, `version` and -`completion`, and none of them reads or writes an item. Scripts and agents reach the same projects -and items through the API the database is bound to. `projects-api`, in this repo, is one server of -that API, on Postgres. +The config file chooses where the data lives. With no `api` key, every command reads and writes a +SQLite database on this machine, so it works with no network at all. With `api` set, every command +reads and writes that API and opens no database. `projects-api`, in this repo, is that API, on +Postgres. Both modes run the API's own routes: in database mode they are served inside the process, +so a refusal, an exit code and a `--json` answer are the same in both. + +```bash +projects # help: every command, grouped +projects next --limit 1 # the one item to work on next +projects items show 42 # one item with its tasks and blockers +projects tui # the board, where everything is a key press +``` ## Install @@ -39,29 +45,96 @@ off. ```bash go install github.com/datapointchris/projects@latest # the binary, and nothing else projects version # proves the install, and opens no database -projects # creates ~/.local/share/projects/projects.db, opens the TUI - -mkdir -p ~/.config/projects # projects reads its config and never writes one -$EDITOR ~/.config/projects/config.toml # [sync] enabled, api_url, token_command -projects sync # binds the database to that API, then pulls -projects # the status bar reads SYNCED and the API's URL +projects create --name "Repaint the fence" # creates ~/.local/share/projects/projects.db +projects tui # the board, reading that database ``` -The first three lines are the whole setup for local use. The right end of the status bar reads -`LOCAL` and the database path, which shows `projects` is running without sync. In the TUI, `a` adds a -project, `Tab` moves to the item pane, and `a` adds an item there. `q` quits. +Those lines are the whole setup for database mode. The right end of the board's status bar reads +`LOCAL` and the database path. In the board, `a` adds a project, `Tab` moves to the item pane, and `a` +adds an item there. `q` quits. + +To work through an API instead: + +```bash +projects auth login https://projects.example.com # writes api, issuer and client_id to the config file +projects list # now reads the API, and opens no database +projects tui # the status bar reads API and the API's host +``` -The second group is only for sync, and it comes before you add anything. Nothing written with sync -off is queued for push. The first pull deletes it as rows the server never returned. The guard under -[A database belongs to one API](#a-database-belongs-to-one-api-and-a-pull-that-would-empty-it-stops) -stops that only once a pull would delete ten projects or ten items. To keep local work and sync as -well, point `PROJECTS_DB` or `db_path` at a second database for the synced one. +`auth login` fills in the config file, and the `api` key it writes is what switches every command to +the API. A database already on the machine is left as it is, and nothing copies it to the API. `config.toml` takes the keys under [Configuration](#configuration--one-toml-file-every-key-overridable-from-the-environment). A key `projects` does not read is an error rather than ignored, so a misspelled key never passes for an unset one. +## The project verbs sit at the root, and the item verbs under `projects items` + +```text +projects list | next | show +projects create | edit | shelve | complete | close | reopen | delete +projects items list | blocked | search | show | blockers +projects items create | edit | complete | reopen | archive | unarchive | delete | reorder +projects items tasks list | add | complete | edit | remove +projects items dependencies add | remove | tree +projects items memberships add | remove +projects tui +projects auth login | logout | status | token +projects sync | version | update | completion +``` + +- `` is a project's name or UUID. `` is an item's number or UUID. +- An answer goes to stdout. A hint, a prompt and an error go to stderr. +- Every read takes `--json`, which prints one JSON document on stdout. A list is a bare array, and an + empty one is `[]`. +- `show`, `items show`, `next`, `items dependencies tree` and `tui --json` print documents that carry + `schema_version`. [cli/schemas](cli/schemas) holds their JSON Schemas. Every other `--json` answer + is the API's own shape, under [api/schemas](api/schemas). +- `next` prints ten items unless `--limit` says otherwise. A list under `projects items` prints every + row unless `--limit` caps it. +- `--repo` takes a name from the repo registry. On a filter, `--repo ""` means untagged work. On + `items edit`, it clears the tag. +- `items create` in a terminal asks for the title and projects the flags left out. Typing `+` at the + project question creates a project on the spot. `--no-input`, or no terminal, fails instead and + names the flag that would have answered. +- `delete` and every `remove` ask first. `--yes` skips the question. With `--no-input`, or no + terminal, they refuse unless `--yes` is passed. +- `projects tui --once` prints one frame at `COLUMNS` by `LINES` and exits. `projects tui --json` + prints what the board is drawn from. With neither and no terminal, `projects tui` refuses. + +| Exit | Meaning | +| ---- | ------- | +| `0` | Done | +| `1` | Refused or failed: not found, not logged in, a rule said no | +| `2` | A usage error: an unknown flag, a missing argument, a value the API refused as input | +| `4` | The API never answered: refused connections, timeouts, `429` and `5xx` after three attempts | + +## Through an API, a login on this machine carries every command + +`projects auth login ` reads the API's protected-resource metadata to find the identity provider, +then shows a code to approve in any browser, on any device. It writes `api`, `issuer` and `client_id` +into the config file and keeps the token in the OS keychain. Where no keychain answers, the token goes +to a file under `$XDG_STATE_HOME/projects/`. `auth status` names which one holds it. + +The client id defaults to `projects-cli-`. Each machine logs in as its own client, and the +API reads that prefix as a person. + +`auth token` prints the access token, refreshed when it has expired. `auth logout` removes it. + +A service has no person to approve a code. With `PROJECTS_CLIENT_SECRET` set, every command +authenticates as `client_id` with the client-credentials grant, asking for `projects.items.read`. +That client is a confidential client registered for the service alone, and its id starts +`projects-svc-`. `auth login` refuses while the secret is set. + +`token_command` in the config file replaces both. It is an argument list, run without a shell. Its +stdout is a token response, a JSON object holding `access_token`, and no login runs. An API that needs no real token, +such as a dev stack whose proxy sets the caller's identity, takes a command that prints a stand-in. + +`--api ` on any command outranks the file's `api` for one run. An address other than the file's +must name the file's issuer in its own metadata, so a token is never sent to an API that does not +trust the provider that issued it. + ## Coming from todoui — the first run moves the data, and the rest is by hand This tool was called `todoui`. Quit any running copy, delete the binary (`rm ~/go/bin/todoui`), then @@ -93,7 +166,7 @@ j / k move through items and their tasks as one list J / K jump item to Tab switch pane Enter read it in full a add a project, or an item to this project space mark done T dependency browser / search every project -u undo the last change R sync now +u undo the last change R sync, or read the API, now ? every key the pane you are in takes q quit ``` @@ -103,8 +176,11 @@ The project pane opens on an `All` row, which shows every item grouped by projec project adds it to a selection. The item pane then shows the selected projects' items, grouped the same way. An item belongs to one project or to several. `A` adds an item to more than one, and `p` changes which projects hold it. Each item carries a title, notes, a checklist of sub-tasks, and the -items it waits on. The reader also names the repo the item is for. That tag is set through the API -and arrives with sync. `projects` has no key that sets it. +items it waits on. The reader also names the repo the item is for. The board has no key that sets +that tag. `projects items edit --repo ` sets it. + +Through an API, the status bar reads `API` and the API's host. The board reads the API again every +`sync.interval`, and `R` reads it at once. `Enter` opens the reader, a full screen holding one item or one project. An item shows its fields, what blocks it, what it blocks, its tasks, and its notes rendered as markdown. A project shows its @@ -132,8 +208,9 @@ project named after a repo becomes the bucket every later papercut falls into, b keeps existing after the work is done. An item's repo tag carries that association instead. The tag crosses project boundaries and outlives any one project. -So creating a project refuses a name the repo registry holds, and so does renaming one. The registry -is a JSON file with a `repos` array, and each entry's `name` is a banned project name: +So creating a project refuses a name the repo registry holds, and so does renaming one. That holds +in database mode only. Through an API it bans nothing, because the API holds no registry. The +registry is a JSON file with a `repos` array, and each entry's `name` is a banned project name: ```json { "repos": [{ "name": "projects" }] } @@ -150,7 +227,8 @@ A missing registry bans nothing. A registry that does not parse fails every crea the parse error. A project's status is `active`, `someday`, `completed` or `dropped`. Reading a project, `c` -completes it, `x` drops it, `S` shelves it for someday, and `r` reopens a shelved or closed one. +completes it, `x` drops it, `S` shelves it for someday, and `r` reopens a shelved or closed one. On +the command line those are `projects complete`, `close --reason`, `shelve` and `reopen`. Completing a project is what takes it out of the list, so there is no separate archive step the way items have one. A shelved project leaves the list too, but records no closing date. `C` shows shelved and closed projects again, each marked with its status. @@ -169,13 +247,24 @@ An item shows its number, the same handle the API answers with. The number is no `projects` creates every id as a UUIDv7, so an item can be created offline and pushed without a round trip to claim one. -With sync on, the number comes from the API's response to the create, and the TUI shows it once the -push lands. With sync off, `projects` allocates the next number itself. An item created while the API is -unreachable shows the last 8 characters of its id until its push lands and earns it a number. +Through an API, the API numbers the item as it creates it. With sync on, the number comes from the +API's response to the create, and the TUI shows it once the push lands. In database mode with sync +off, `projects` allocates the next number itself. An item created while a synced API is unreachable +shows the last 8 characters of its id until its push lands and earns it a number. + +## Sync keeps a database a copy of an API, and only the board writes to the copy -## Sync is automatic, and `projects sync` is for now rather than for correctness +Sync is the older way to reach an API. `[sync] enabled = true` keeps a database on this machine as a +copy of `sync.api_url`, and `projects tui` edits the copy and pushes each edit. With `api` set there +is no copy, so a config file holding both `api` and `[sync] enabled = true` stops every command, +naming both keys. -With sync on, every edit is queued and pushed as you make it. The TUI pulls on launch, then again +With sync on, every read on the command line answers from the copy. A command that writes refuses +with a sentence naming `api`. Its write would land in the copy with nothing queued to push, and the +next pull would delete it. `projects sync` refuses when `api` is set, because there is nothing to +reconcile. + +With sync on, every edit in the board is queued and pushed as you make it. The TUI pulls on launch, then again every `sync.interval` (default two minutes) for as long as it is open. A pull waits while a text input, an overlay, the reader or a move is open, and runs on the next tick instead. @@ -222,7 +311,13 @@ as well as Linux. No config file is a valid configuration: a local database, wit [config.example.toml](config.example.toml) is an annotated copy. ```toml -# repos_registry = "/path/to/repos.json" # a bare key, so it sits above the first [section] +# Bare keys sit above the first [section]. +# repos_registry = "/path/to/repos.json" + +# api = "https://projects.example.com" # set, every command goes to this API and no database opens +# issuer = "https://auth.example.com/application/o/projects/" # auth login writes it from the API +# client_id = "projects-cli-desk" # auth login writes it; default projects-cli- +# token_command = ["printf", '{"access_token":"dev","token_type":"Bearer"}'] # replaces the login [local] # db_path = "/custom/path/projects.db" # default: ~/.local/share/projects/projects.db @@ -234,9 +329,13 @@ enabled = false # interval = "2m" # longest it goes without reconciling; floored at 15s ``` -`enabled = true` without an `api_url` is an error. +`enabled = true` without an `api_url` is an error. So is `enabled = true` beside `api`. + +The bare `token_command` belongs to `api`, and is an argument list printing a token response. The +`[sync]` one belongs to `api_url`, and is a string printing a bare token. The two never stand in for +each other. -`token_command` is how `projects` authenticates to the API without holding a secret itself. It is split +`[sync]`'s `token_command` is how `projects` authenticates to the API without holding a secret itself. It is split on spaces and run without a shell or stdin. Whatever it prints becomes the bearer token. A command that has not finished in 10 seconds fails. `projects` keeps the token until the API refuses it, then runs the command once more and retries once. A failing command's first line of stderr is what the @@ -258,39 +357,46 @@ Each variable overrides the key beside it: | `PROJECTS_SYNC_INTERVAL` | `sync.interval`, as a duration such as `30s` or `5m` | | `PROJECTS_REPOS_REGISTRY` | `repos_registry` | -## Local-first — SQLite is the store, and sync is a layer over it +`api`, `issuer`, `client_id` and the bare `token_command` take no variable. `--api` outranks `api` for +one run. `PROJECTS_CLIENT_SECRET` switches the login to the client-credentials grant. -Every read and write goes to the embedded SQLite database. The optional sync engine pushes mutations -and pulls remote state in the background. +## Every command speaks HTTP to the API's routes, served in the process or across the network + +A command reads and writes through one HTTP client. In database mode its transport hands each +request to the API's own routes inside the process, over the SQLite store. Through an API, the +transport is the network. So a rule, a refusal and a `--json` shape are one thing in both modes. ```text -TUI - | - v -Backend interface - | - +-- service.Service (every rule, always) - | | - | +-- store.Store -> store/sqlite (SQLite) - | - +-- SyncBackend (wraps the service, optional) - | - +-- Push: pending_sync queue -> HTTP to the API - +-- Pull: HTTP from the API -> upsert local SQLite +projects projects tui + | | + v v +apiclient.Client backend.Backend + | | + +-- database mode +-- database mode: service.Service, + | InProcess -> api/routes | in SyncBackend when sync is on + | -> service.Service | + | -> store/sqlite +-- api: apiclient.Backend + | -> apiclient.Client + +-- api: HTTP, three attempts + -> projects-api ``` +The sync engine, when it is on, sits in the board's path alone. It pushes each queued write over +HTTP and pulls the API's state into the local database. + The data model is projects, items, the memberships joining them many to many, dependencies between items, and sub-tasks. -`projects` writes two places. The database at `db_path` holds the data, the queue of pending pushes, and -the API the database is bound to. The update check keeps its last result under -`$XDG_STATE_HOME/projects/`, which defaults to `~/.local/state/projects/`. The config file and the repo -registry are read and never written. +In database mode, the database at `db_path` holds the data, the queue of pending pushes, and the API +the database is bound to. The update check keeps its last result under `$XDG_STATE_HOME/projects/`, +which defaults to `~/.local/state/projects/`. `auth login` is the only command that writes the config +file, and it adds its keys without rewriting the rest. The repo registry is read and never written. ## The API — the same rules over HTTP, on Postgres -`projects-api` serves projects, items, dependencies and tasks under `/api/v1`. It runs the rules the TUI -runs, over a Postgres store instead of SQLite, so a rule behaves the same in both. The one exception is +`projects-api` serves projects, items, dependencies and tasks under `/api/v1`. It runs the routes and +rules the command line runs in database mode, over a Postgres store instead of SQLite, so a rule +behaves the same in both. The one exception is the ban on a project named for a repo, which reads a registry file this server does not hold. ```text @@ -300,7 +406,7 @@ projects-api api/routes decode, call, encode | v -service.Service every rule, shared with the TUI +service.Service every rule, shared with database mode | v store/postgres pgx, goose migrations, a row trigger writing audit_trail diff --git a/apiclient/backend.go b/apiclient/backend.go new file mode 100644 index 0000000..42ca924 --- /dev/null +++ b/apiclient/backend.go @@ -0,0 +1,375 @@ +package apiclient + +import ( + "context" + "slices" + "time" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/backend" + "github.com/datapointchris/projects/model" +) + +// Backend is the TUI's port over the API. Each method is the route the service +// method of the same name answers through, converted to model, so the TUI reads +// the same rows and refusal sentences it reads from a database on this machine. +type Backend struct { + client *Client +} + +var _ backend.Backend = (*Backend)(nil) + +// NewBackend serves the TUI from the API client calls. +func NewBackend(client *Client) *Backend { return &Backend{client: client} } + +// The port takes no context, because no local caller would use one. +func (b *Backend) ctx() context.Context { return context.Background() } + +func (b *Backend) ListProjectsByStatus(status string) ([]model.ProjectWithItemCount, error) { + answered, err := b.client.ListProjects(b.ctx(), ProjectQuery{Status: status}) + if err != nil { + return nil, err + } + projects := wire.Map(answered, countedProjectFrom) + model.SortProjectsForBoard(projects) + return projects, nil +} + +func (b *Backend) GetProject(id string) (*model.ProjectWithItemCount, error) { + answered, err := b.client.GetProject(b.ctx(), id) + if err != nil { + return nil, err + } + project := countedProjectFrom(answered) + return &project, nil +} + +func (b *Backend) CreateProject(input model.CreateProject) (*model.Project, error) { + answered, err := b.client.CreateProject(b.ctx(), NewProject{ + Name: input.Name, Description: input.Description, Kind: input.Kind, Position: input.Position, + }) + if err != nil { + return nil, err + } + project := projectFrom(answered) + return &project, nil +} + +func (b *Backend) UpdateProject(id string, input model.UpdateProject) (*model.Project, error) { + return b.patchProject(id, ProjectChanges{ + Name: input.Name, Description: input.Description, Kind: input.Kind, + Status: input.Status, StatusReason: input.StatusReason, Position: input.Position, + ClearDescription: input.ClearDescription, ClearStatusReason: input.ClearStatusReason, + }) +} + +func (b *Backend) SetProjectStatus(id, status string, reason *string) (*model.Project, error) { + return b.patchProject(id, ProjectChanges{Status: &status, StatusReason: reason, ClearStatusReason: reason == nil}) +} + +func (b *Backend) patchProject(id string, edit ProjectChanges) (*model.Project, error) { + answered, err := b.client.UpdateProject(b.ctx(), id, edit) + if err != nil { + return nil, err + } + project := projectFrom(answered) + return &project, nil +} + +func (b *Backend) DeleteProject(id string) error { return b.client.DeleteProject(b.ctx(), id) } + +func (b *Backend) ReorderProject(projectID string, newPosition int) error { + _, err := b.patchProject(projectID, ProjectChanges{Position: &newPosition}) + return err +} + +func (b *Backend) ListAllItemsIncludingArchived() ([]model.ProjectItem, error) { + answered, err := b.everyItem() + if err != nil { + return nil, err + } + return wire.Map(answered, listedItemFrom), nil +} + +// everyItem is the whole list, archived items included, which carries each +// item's memberships and dependencies as well. +func (b *Backend) everyItem() ([]wire.ProjectItem, error) { + return b.client.ListItems(b.ctx(), ItemQuery{Status: model.StatusAll}) +} + +func (b *Backend) ListItemsByProject(projectID string) ([]model.ProjectItemInProject, error) { + items, err := b.projectItems(projectID, model.StatusAll) + if err != nil { + return nil, err + } + return slices.DeleteFunc(items, func(item model.ProjectItemInProject) bool { return item.Archived }), nil +} + +func (b *Backend) projectItems(projectID, status string) ([]model.ProjectItemInProject, error) { + answered, err := b.client.ListProjectItems(b.ctx(), projectID, ItemQuery{Status: status}) + if err != nil { + return nil, err + } + return wire.Map(answered, heldItemFrom), nil +} + +func (b *Backend) GetItem(id string) (*model.ProjectItemDetail, error) { + answered, err := b.client.GetItem(b.ctx(), id) + if err != nil { + return nil, err + } + detail := detailFrom(answered) + return &detail, nil +} + +func (b *Backend) CreateItem(input model.CreateProjectItem) (*model.ProjectItemDetail, error) { + answered, err := b.client.CreateItem(b.ctx(), NewItem{ + Title: input.Title, Notes: input.Notes, Repo: input.Repo, ProjectIDs: input.ProjectIDs, + }) + if err != nil { + return nil, err + } + detail := detailFrom(answered) + return &detail, nil +} + +func (b *Backend) UpdateItem(id string, input model.UpdateProjectItem) (*model.ProjectItem, error) { + answered, err := b.client.UpdateItem(b.ctx(), id, ItemChanges{ + Title: input.Title, Notes: input.Notes, Repo: input.Repo, + Completed: input.Completed, Archived: input.Archived, + ClearNotes: input.ClearNotes, ClearRepo: input.ClearRepo, + }) + if err != nil { + return nil, err + } + item := listedItemFrom(answered) + return &item, nil +} + +func (b *Backend) DeleteItem(id string) error { return b.client.DeleteItem(b.ctx(), id) } + +func (b *Backend) ReorderItem(itemID, projectID string, newPosition int) error { + _, err := b.client.ReorderItem(b.ctx(), itemID, projectID, newPosition) + return err +} + +func (b *Backend) AddToProject(itemID, projectID string) error { + _, err := b.client.AddItemToProject(b.ctx(), itemID, projectID) + return err +} + +func (b *Backend) RemoveFromProject(itemID, projectID string) error { + return b.client.RemoveItemFromProject(b.ctx(), itemID, projectID) +} + +func (b *Backend) GetItemProjects(itemID string) ([]model.Project, error) { + answered, err := b.client.ListItemProjects(b.ctx(), itemID) + if err != nil { + return nil, err + } + return wire.Map(answered, projectFrom), nil +} + +func (b *Backend) ListMemberships() ([]model.Membership, error) { + answered, err := b.everyItem() + if err != nil { + return nil, err + } + var memberships []model.Membership + for _, item := range answered { + for _, held := range item.Memberships { + memberships = append(memberships, model.Membership{ItemID: item.ID, ProjectID: held.ProjectID, Position: held.Position}) + } + } + return memberships, nil +} + +func (b *Backend) AddDependency(itemID, dependsOn string) error { + _, err := b.client.AddDependency(b.ctx(), itemID, dependsOn) + return err +} + +func (b *Backend) RemoveDependency(itemID, dependsOn string) error { + return b.client.RemoveDependency(b.ctx(), itemID, dependsOn) +} + +func (b *Backend) GetBlockers(itemID string) ([]model.ProjectItem, error) { + return b.items(b.client.ListBlockers(b.ctx(), itemID)) +} + +func (b *Backend) ListDependencies() ([]model.Dependency, error) { + answered, err := b.everyItem() + if err != nil { + return nil, err + } + var edges []model.Dependency + for _, item := range answered { + for _, dependsOn := range item.DependencyIDs { + edges = append(edges, model.Dependency{ItemID: item.ID, DependsOnID: dependsOn}) + } + } + return edges, nil +} + +func (b *Backend) ListAllTasks() ([]model.ProjectItemTask, error) { + answered, err := b.everyItem() + if err != nil { + return nil, err + } + var tasks []model.ProjectItemTask + for _, item := range answered { + tasks = append(tasks, wire.Map(item.Tasks, taskFrom)...) + } + return tasks, nil +} + +func (b *Backend) ListTasks(itemID string) ([]model.ProjectItemTask, error) { + answered, err := b.client.ListTasks(b.ctx(), itemID) + if err != nil { + return nil, err + } + return wire.Map(answered, taskFrom), nil +} + +func (b *Backend) CreateTask(itemID string, input model.CreateProjectItemTask) (*model.ProjectItemTask, error) { + position := input.Position + answered, err := b.client.CreateTask(b.ctx(), itemID, NewTask{Title: input.Title, Position: &position}) + if err != nil { + return nil, err + } + task := taskFrom(answered) + return &task, nil +} + +func (b *Backend) UpdateTask(itemID, taskID string, input model.UpdateProjectItemTask) (*model.ProjectItemTask, error) { + answered, err := b.client.UpdateTask(b.ctx(), itemID, taskID, TaskChanges{ + Title: input.Title, Completed: input.Completed, Position: input.Position, + }) + if err != nil { + return nil, err + } + task := taskFrom(answered) + return &task, nil +} + +func (b *Backend) DeleteTask(itemID, taskID string) error { + return b.client.DeleteTask(b.ctx(), itemID, taskID) +} + +func (b *Backend) Search(query string) ([]model.ProjectItem, error) { + items, err := b.items(b.client.SearchItems(b.ctx(), query, nil)) + if err != nil { + return nil, err + } + return slices.DeleteFunc(items, func(item model.ProjectItem) bool { return item.Archived }), nil +} + +func (b *Backend) ListBlocked() ([]model.ProjectItem, error) { + return b.items(b.client.ListBlockedItems(b.ctx(), nil)) +} + +func (b *Backend) ListArchived(projectID string) ([]model.ProjectItemInProject, error) { + archived, err := b.projectItems(projectID, model.ItemStatusArchived) + if err != nil { + return nil, err + } + slices.SortStableFunc(archived, func(a, b model.ProjectItemInProject) int { return b.UpdatedAt.Compare(a.UpdatedAt) }) + return archived, nil +} + +func (b *Backend) Undo() (*model.UndoResult, error) { + answered, err := b.client.Undo(b.ctx()) + if err != nil { + return nil, err + } + return &model.UndoResult{ + Description: answered.Description, Action: answered.Action, + EntityType: answered.EntityType, EntityID: answered.EntityID, + }, nil +} + +func (b *Backend) CanUndo() (bool, error) { + state, err := b.client.UndoState(b.ctx()) + return state.CanUndo, err +} + +func (b *Backend) items(answered []wire.ProjectItem, err error) ([]model.ProjectItem, error) { + if err != nil { + return nil, err + } + return wire.Map(answered, listedItemFrom), nil +} + +func timeFrom(t *wire.Time) *time.Time { + if t == nil { + return nil + } + return &t.Time +} + +func projectFrom(p wire.Project) model.Project { + return model.Project{ + ID: p.ID, Name: p.Name, Description: p.Description, Kind: p.Kind, + Status: p.Status, StatusReason: p.StatusReason, ClosedAt: timeFrom(p.ClosedAt), + Position: p.Position, CreatedAt: p.CreatedAt.Time, + } +} + +func countedProjectFrom(p wire.ProjectWithItemCount) model.ProjectWithItemCount { + return model.ProjectWithItemCount{ + Project: projectFrom(p.Project), ItemCount: p.ItemCount, OpenCount: p.OpenCount, + CompletedCount: p.CompletedCount, Repos: p.Repos, + } +} + +func listedItemFrom(i wire.ProjectItem) model.ProjectItem { + return model.ProjectItem{ + ID: i.ID, Number: i.Number, Title: i.Title, Notes: i.Notes, Repo: i.Repo, + Completed: i.Completed, CompletedAt: timeFrom(i.CompletedAt), Archived: i.Archived, + CreatedAt: i.CreatedAt.Time, UpdatedAt: i.UpdatedAt.Time, + } +} + +func heldItemFrom(i wire.ProjectItemInProject) model.ProjectItemInProject { + return model.ProjectItemInProject{ + ProjectItem: model.ProjectItem{ + ID: i.ID, Number: i.Number, Title: i.Title, Notes: i.Notes, Repo: i.Repo, + Completed: i.Completed, CompletedAt: timeFrom(i.CompletedAt), Archived: i.Archived, + CreatedAt: i.CreatedAt.Time, UpdatedAt: i.UpdatedAt.Time, + }, + Position: i.Position, ProjectCount: i.ProjectCount, + } +} + +// detailFrom leaves Tasks empty, because a detail's tasks are their own route. +func detailFrom(d wire.ProjectItemDetail) model.ProjectItemDetail { + memberships := make([]model.Membership, len(d.Memberships)) + for i, held := range d.Memberships { + memberships[i] = model.Membership{ItemID: d.ID, ProjectID: held.ProjectID, Position: held.Position} + } + dependsOn := make([]model.ProjectItem, len(d.DependsOn)) + for i, summary := range d.DependsOn { + dependsOn[i] = model.ProjectItem{ + ID: summary.ID, Number: summary.Number, Title: summary.Title, + Completed: summary.Completed, Archived: summary.Archived, + } + } + return model.ProjectItemDetail{ + ProjectItem: model.ProjectItem{ + ID: d.ID, Number: d.Number, Title: d.Title, Notes: d.Notes, Repo: d.Repo, + Completed: d.Completed, CompletedAt: timeFrom(d.CompletedAt), Archived: d.Archived, + CreatedAt: d.CreatedAt.Time, UpdatedAt: d.UpdatedAt.Time, + }, + Projects: wire.Map(d.Projects, projectFrom), + Memberships: memberships, + DependencyIDs: d.DependencyIDs, + DependsOn: dependsOn, + } +} + +func taskFrom(t wire.ProjectItemTask) model.ProjectItemTask { + return model.ProjectItemTask{ + ID: t.ID, ItemID: t.ItemID, Title: t.Title, Completed: t.Completed, + CompletedAt: timeFrom(t.CompletedAt), Position: t.Position, CreatedAt: t.CreatedAt.Time, + } +} diff --git a/apiclient/client.go b/apiclient/client.go new file mode 100644 index 0000000..3e1260b --- /dev/null +++ b/apiclient/client.go @@ -0,0 +1,169 @@ +// Package apiclient is a Go client of the projects API: one method per route, each +// answering in the api/wire types the API encodes. The transport decides where the +// routes run, over the network or inside this process, so the same calls reach the +// API and a database on this machine. +package apiclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/datapointchris/projects/api/wire" +) + +// prefix is where every route the client calls lives. +const prefix = "/api/v1" + +// Client calls one API. Base is its address without the /api/v1 the routes add. +type Client struct { + Base string + http *http.Client +} + +// New returns a client of the API at base, whose requests go through httpClient. +func New(base string, httpClient *http.Client) *Client { + return &Client{Base: strings.TrimRight(base, "/"), http: httpClient} +} + +// APIError is an answer outside 2xx. Message is the API's own sentence, empty when +// the body carried none, as a proxy's error page does. +type APIError struct { + StatusCode int + Message string + Problems []wire.ValidationError +} + +func (e *APIError) Error() string { + if e.Message != "" { + return e.Message + } + return fmt.Sprintf("the API answered %d %s", e.StatusCode, http.StatusText(e.StatusCode)) +} + +// NotFound reports a 404 the API explained, so the subject it names was looked for +// and is not there. A 404 with no sentence never reached a route. +func (e *APIError) NotFound() bool { + return e.StatusCode == http.StatusNotFound && e.Message != "" +} + +func (e *APIError) Unauthorized() bool { return e.StatusCode == http.StatusUnauthorized } + +func (e *APIError) Forbidden() bool { return e.StatusCode == http.StatusForbidden } + +// call sends one request and decodes a 2xx answer into answer, when there is one. +func (c *Client) call(ctx context.Context, method, path string, query url.Values, body, answer any) error { + target := c.Base + prefix + path + if len(query) > 0 { + target += "?" + query.Encode() + } + var sent io.Reader + if body != nil { + encoded, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding the request: %w", err) + } + sent = bytes.NewReader(encoded) + } + request, err := http.NewRequestWithContext(ctx, method, target, sent) + if err != nil { + return err + } + request.Header.Set("Accept", "application/json") + if body != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := c.http.Do(request) + if err != nil { + return err + } + defer func() { _ = response.Body.Close() }() + received, err := io.ReadAll(response.Body) + if err != nil { + return fmt.Errorf("reading the answer to %s %s: %w", method, path, err) + } + if response.StatusCode >= http.StatusMultipleChoices { + return refusalOf(response.StatusCode, received) + } + if answer == nil || len(received) == 0 { + return nil + } + if err := json.Unmarshal(received, answer); err != nil { + return fmt.Errorf("decoding the answer to %s %s: %w", method, path, err) + } + return nil +} + +func refusalOf(status int, body []byte) *APIError { + refused := &APIError{StatusCode: status} + var answer wire.Error + if json.Unmarshal(body, &answer) == nil { + refused.Message = answer.Message + refused.Problems = answer.Errors + } + return refused +} + +// escaped is one path segment. A project name may hold a slash, which the API's +// wildcards take escaped. +func escaped(ref string) string { return url.PathEscape(ref) } + +// ItemQuery narrows an item list. An empty Status leaves the API's default, open. +// A nil Repo is no filter, and an empty one asks for the untagged items. +type ItemQuery struct { + Status string + Repo *string + Since, Until *time.Time + Limit *int +} + +func (q ItemQuery) values() url.Values { + values := url.Values{} + if q.Status != "" { + values.Set("status", q.Status) + } + if q.Repo != nil { + values.Set("repo", *q.Repo) + } + if q.Since != nil { + values.Set("since", q.Since.UTC().Format(time.RFC3339Nano)) + } + if q.Until != nil { + values.Set("until", q.Until.UTC().Format(time.RFC3339Nano)) + } + if q.Limit != nil { + values.Set("limit", strconv.Itoa(*q.Limit)) + } + return values +} + +// changes is a PATCH body read field by field: a key left out keeps, null clears. +type changes map[string]any + +func (c changes) text(key string, value *string, clear bool) { + switch { + case clear: + c[key] = nil + case value != nil: + c[key] = *value + } +} + +func (c changes) number(key string, value *int) { + if value != nil { + c[key] = *value + } +} + +func (c changes) flag(key string, value *bool) { + if value != nil { + c[key] = *value + } +} diff --git a/apiclient/project_item_tasks.go b/apiclient/project_item_tasks.go new file mode 100644 index 0000000..08a4158 --- /dev/null +++ b/apiclient/project_item_tasks.go @@ -0,0 +1,68 @@ +package apiclient + +import ( + "context" + "net/http" + + "github.com/datapointchris/projects/api/wire" +) + +// NewTask is what a task create sends. A nil Position puts it first. +type NewTask struct { + Title string `json:"title"` + Position *int `json:"position,omitempty"` +} + +// TaskChanges is a task edit. A nil field is left as it is. +type TaskChanges struct { + Title *string + Completed *bool + Position *int +} + +func (c TaskChanges) body() changes { + body := changes{} + body.text("title", c.Title, false) + body.flag("completed", c.Completed) + body.number("position", c.Position) + return body +} + +func taskPath(itemRef, taskID string) string { + return itemPath(itemRef) + "/tasks/" + escaped(taskID) +} + +func (c *Client) ListTasks(ctx context.Context, itemRef string) ([]wire.ProjectItemTask, error) { + var tasks []wire.ProjectItemTask + err := c.call(ctx, http.MethodGet, itemPath(itemRef)+"/tasks", nil, nil, &tasks) + return tasks, err +} + +func (c *Client) CreateTask(ctx context.Context, itemRef string, input NewTask) (wire.ProjectItemTask, error) { + var task wire.ProjectItemTask + err := c.call(ctx, http.MethodPost, itemPath(itemRef)+"/tasks", nil, input, &task) + return task, err +} + +func (c *Client) UpdateTask(ctx context.Context, itemRef, taskID string, edit TaskChanges) (wire.ProjectItemTask, error) { + var task wire.ProjectItemTask + err := c.call(ctx, http.MethodPatch, taskPath(itemRef, taskID), nil, edit.body(), &task) + return task, err +} + +func (c *Client) DeleteTask(ctx context.Context, itemRef, taskID string) error { + return c.call(ctx, http.MethodDelete, taskPath(itemRef, taskID), nil, nil, nil) +} + +// Undo reverses the latest write. A 409 says nothing is left to undo. +func (c *Client) Undo(ctx context.Context) (wire.UndoResult, error) { + var result wire.UndoResult + err := c.call(ctx, http.MethodPost, "/undo", nil, nil, &result) + return result, err +} + +func (c *Client) UndoState(ctx context.Context) (wire.UndoState, error) { + var state wire.UndoState + err := c.call(ctx, http.MethodGet, "/undo", nil, nil, &state) + return state, err +} diff --git a/apiclient/project_items.go b/apiclient/project_items.go new file mode 100644 index 0000000..b43b1e5 --- /dev/null +++ b/apiclient/project_items.go @@ -0,0 +1,135 @@ +package apiclient + +import ( + "context" + "net/http" + "net/url" + + "github.com/datapointchris/projects/api/wire" +) + +// NewItem is what an item create sends. ProjectIDs takes names or ids. +type NewItem struct { + Title string `json:"title"` + Notes *string `json:"notes,omitempty"` + Repo *string `json:"repo,omitempty"` + ProjectIDs []string `json:"project_ids"` +} + +// ItemChanges is an item edit. A nil field is left as it is, and a Clear flag +// empties its field. An empty Repo unlinks too. +type ItemChanges struct { + Title, Notes, Repo *string + Completed, Archived *bool + ClearNotes, ClearRepo bool +} + +func (c ItemChanges) body() changes { + body := changes{} + body.text("title", c.Title, false) + body.text("notes", c.Notes, c.ClearNotes) + body.text("repo", c.Repo, c.ClearRepo) + body.flag("completed", c.Completed) + body.flag("archived", c.Archived) + return body +} + +func itemPath(ref string) string { return "/project-items/" + escaped(ref) } + +func repoQuery(repo *string) url.Values { + query := url.Values{} + if repo != nil { + query.Set("repo", *repo) + } + return query +} + +// ListItems is every item the query keeps, newest first. +func (c *Client) ListItems(ctx context.Context, q ItemQuery) ([]wire.ProjectItem, error) { + var items []wire.ProjectItem + err := c.call(ctx, http.MethodGet, "/project-items", q.values(), nil, &items) + return items, err +} + +// ListBlockedItems is every item with an incomplete dependency. +func (c *Client) ListBlockedItems(ctx context.Context, repo *string) ([]wire.ProjectItem, error) { + var items []wire.ProjectItem + err := c.call(ctx, http.MethodGet, "/project-items/blocked", repoQuery(repo), nil, &items) + return items, err +} + +// SearchItems matches titles and notes, archived items included. +func (c *Client) SearchItems(ctx context.Context, words string, repo *string) ([]wire.ProjectItem, error) { + query := repoQuery(repo) + query.Set("q", words) + var items []wire.ProjectItem + err := c.call(ctx, http.MethodGet, "/project-items/search", query, nil, &items) + return items, err +} + +func (c *Client) GetItem(ctx context.Context, ref string) (wire.ProjectItemDetail, error) { + var item wire.ProjectItemDetail + err := c.call(ctx, http.MethodGet, itemPath(ref), nil, nil, &item) + return item, err +} + +func (c *Client) CreateItem(ctx context.Context, input NewItem) (wire.ProjectItemDetail, error) { + var item wire.ProjectItemDetail + err := c.call(ctx, http.MethodPost, "/project-items", nil, input, &item) + return item, err +} + +func (c *Client) UpdateItem(ctx context.Context, ref string, edit ItemChanges) (wire.ProjectItem, error) { + var item wire.ProjectItem + err := c.call(ctx, http.MethodPatch, itemPath(ref), nil, edit.body(), &item) + return item, err +} + +func (c *Client) DeleteItem(ctx context.Context, ref string) error { + return c.call(ctx, http.MethodDelete, itemPath(ref), nil, nil, nil) +} + +// ReorderItem moves an item within one of its projects, and answers it there. +func (c *Client) ReorderItem(ctx context.Context, ref, projectRef string, position int) (wire.ProjectItemInProject, error) { + var item wire.ProjectItemInProject + body := map[string]any{"project_id": projectRef, "position": position} + err := c.call(ctx, http.MethodPatch, itemPath(ref)+"/reorder", nil, body, &item) + return item, err +} + +func (c *Client) ListItemProjects(ctx context.Context, ref string) ([]wire.Project, error) { + var projects []wire.Project + err := c.call(ctx, http.MethodGet, itemPath(ref)+"/projects", nil, nil, &projects) + return projects, err +} + +// AddItemToProject files an item in one more project, and answers that project. +func (c *Client) AddItemToProject(ctx context.Context, ref, projectRef string) (wire.Project, error) { + var project wire.Project + body := map[string]any{"project_id": projectRef} + err := c.call(ctx, http.MethodPost, itemPath(ref)+"/projects", nil, body, &project) + return project, err +} + +func (c *Client) RemoveItemFromProject(ctx context.Context, ref, projectRef string) error { + return c.call(ctx, http.MethodDelete, itemPath(ref)+"/projects/"+escaped(projectRef), nil, nil, nil) +} + +// AddDependency records that ref waits on dependsOn, and answers ref. +func (c *Client) AddDependency(ctx context.Context, ref, dependsOn string) (wire.ProjectItemDetail, error) { + var item wire.ProjectItemDetail + body := map[string]any{"depends_on_id": dependsOn} + err := c.call(ctx, http.MethodPost, itemPath(ref)+"/dependencies", nil, body, &item) + return item, err +} + +func (c *Client) RemoveDependency(ctx context.Context, ref, dependsOn string) error { + return c.call(ctx, http.MethodDelete, itemPath(ref)+"/dependencies/"+escaped(dependsOn), nil, nil, nil) +} + +// ListBlockers is what an item waits on that is not completed. +func (c *Client) ListBlockers(ctx context.Context, ref string) ([]wire.ProjectItem, error) { + var items []wire.ProjectItem + err := c.call(ctx, http.MethodGet, itemPath(ref)+"/blockers", nil, nil, &items) + return items, err +} diff --git a/apiclient/projects.go b/apiclient/projects.go new file mode 100644 index 0000000..bc0208f --- /dev/null +++ b/apiclient/projects.go @@ -0,0 +1,90 @@ +package apiclient + +import ( + "context" + "net/http" + "net/url" + "strconv" + + "github.com/datapointchris/projects/api/wire" +) + +// ProjectQuery narrows the project list. An empty Status leaves the API's +// default, active. +type ProjectQuery struct { + Status string + Repo *string + Limit *int +} + +// NewProject is what a create sends. Nil fields take the API's defaults. +type NewProject struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Kind *string `json:"kind,omitempty"` + Position *int `json:"position,omitempty"` +} + +// ProjectChanges is a project edit. A nil field is left as it is, and a Clear +// flag empties its field. +type ProjectChanges struct { + Name, Description, Kind, Status, StatusReason *string + Position *int + ClearDescription, ClearStatusReason bool +} + +func (c ProjectChanges) body() changes { + body := changes{} + body.text("name", c.Name, false) + body.text("description", c.Description, c.ClearDescription) + body.text("kind", c.Kind, false) + body.text("status", c.Status, false) + body.text("status_reason", c.StatusReason, c.ClearStatusReason) + body.number("position", c.Position) + return body +} + +func (c *Client) ListProjects(ctx context.Context, q ProjectQuery) ([]wire.ProjectWithItemCount, error) { + query := url.Values{} + if q.Status != "" { + query.Set("status", q.Status) + } + if q.Repo != nil { + query.Set("repo", *q.Repo) + } + if q.Limit != nil { + query.Set("limit", strconv.Itoa(*q.Limit)) + } + var projects []wire.ProjectWithItemCount + err := c.call(ctx, http.MethodGet, "/projects", query, nil, &projects) + return projects, err +} + +func (c *Client) GetProject(ctx context.Context, ref string) (wire.ProjectWithItemCount, error) { + var project wire.ProjectWithItemCount + err := c.call(ctx, http.MethodGet, "/projects/"+escaped(ref), nil, nil, &project) + return project, err +} + +func (c *Client) CreateProject(ctx context.Context, input NewProject) (wire.Project, error) { + var project wire.Project + err := c.call(ctx, http.MethodPost, "/projects", nil, input, &project) + return project, err +} + +func (c *Client) UpdateProject(ctx context.Context, ref string, edit ProjectChanges) (wire.Project, error) { + var project wire.Project + err := c.call(ctx, http.MethodPatch, "/projects/"+escaped(ref), nil, edit.body(), &project) + return project, err +} + +func (c *Client) DeleteProject(ctx context.Context, ref string) error { + return c.call(ctx, http.MethodDelete, "/projects/"+escaped(ref), nil, nil, nil) +} + +// ListProjectItems is one project's items in project order. +func (c *Client) ListProjectItems(ctx context.Context, ref string, q ItemQuery) ([]wire.ProjectItemInProject, error) { + var items []wire.ProjectItemInProject + err := c.call(ctx, http.MethodGet, "/projects/"+escaped(ref)+"/items", q.values(), nil, &items) + return items, err +} diff --git a/apiclient/transports.go b/apiclient/transports.go new file mode 100644 index 0000000..6ed03d5 --- /dev/null +++ b/apiclient/transports.go @@ -0,0 +1,137 @@ +package apiclient + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand/v2" + "net" + "net/http" + "net/http/httptest" + "strconv" + "time" +) + +// InProcess serves each request with handler inside this process. A command then +// runs the API's own routes, refusals and JSON against a database on this +// machine, and nothing listens on a socket. +func InProcess(handler http.Handler) http.RoundTripper { + return inProcess{handler: handler} +} + +type inProcess struct{ handler http.Handler } + +func (t inProcess) RoundTrip(request *http.Request) (*http.Response, error) { + if request.Body != nil { + defer func() { _ = request.Body.Close() }() + } + recorder := httptest.NewRecorder() + t.handler.ServeHTTP(recorder, request) + return recorder.Result(), nil +} + +// Attempts is how many times a request that got no answer is sent in all. +const Attempts = 3 + +const longestPause = 20 * time.Second + +// Retrying tries a request again where the API gave no answer, as the AWS CLI's +// standard mode does. A read retries any failure to reach the API, an answer cut +// short, and a 429, 502, 503 or 504. A write retries only a failure before it was +// sent, because a retry after that could do it twice. +func Retrying(next http.RoundTripper) http.RoundTripper { + return retrying{next: next, pause: randomPause} +} + +type retrying struct { + next http.RoundTripper + pause func(attempt int, response *http.Response) time.Duration +} + +func (t retrying) RoundTrip(request *http.Request) (*http.Response, error) { + for attempt := 1; ; attempt++ { + response, err := t.next.RoundTrip(request) + if err == nil { + if err = readWhole(response); err != nil { + response = nil + } + } + if attempt == Attempts || !worthRetrying(request.Method, response, err) { + return response, err + } + pause := t.pause(attempt, response) + if response != nil { + _ = response.Body.Close() + } + select { + case <-time.After(pause): + case <-request.Context().Done(): + return nil, request.Context().Err() + } + request = request.Clone(request.Context()) + if request.GetBody != nil { + body, bodyErr := request.GetBody() + if bodyErr != nil { + return nil, bodyErr + } + request.Body = body + } + } +} + +// answerCutShort is a body that broke after its status arrived. +type answerCutShort struct{ error } + +func (c answerCutShort) Unwrap() error { return c.error } + +func readWhole(response *http.Response) error { + defer func() { _ = response.Body.Close() }() + body, err := io.ReadAll(response.Body) + if err != nil { + return answerCutShort{fmt.Errorf("reading the answer: %w", err)} + } + response.Body = io.NopCloser(bytes.NewReader(body)) + return nil +} + +func worthRetrying(method string, response *http.Response, err error) bool { + read := method == http.MethodGet + if err != nil { + var cut answerCutShort + return neverSent(err) || (read && (unreachable(err) || errors.As(err, &cut))) + } + if !read { + return false + } + switch response.StatusCode { + case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + } + return false +} + +// neverSent is a name that did not resolve or a connection that did not open. +func neverSent(err error) bool { + var lookup *net.DNSError + var op *net.OpError + return errors.As(err, &lookup) || (errors.As(err, &op) && op.Op == "dial") +} + +// unreachable is a reset, a timeout or a connection cut before the whole answer. +func unreachable(err error) bool { + var network net.Error + return errors.As(err, &network) || errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) +} + +// randomPause waits what the server's Retry-After says, or else a random fraction +// of 2^(attempt-1) seconds. +func randomPause(attempt int, response *http.Response) time.Duration { + if response != nil { + if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 { + return min(time.Duration(seconds)*time.Second, longestPause) + } + } + window := time.Second << (attempt - 1) + return min(time.Duration(rand.Int64N(int64(window))), longestPause) +} diff --git a/backend/backend.go b/backend/backend.go index 39e8c7a..31518c5 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -48,6 +48,9 @@ type Backend interface { // Tasks ListTasks(itemID string) ([]model.ProjectItemTask, error) + // ListAllTasks is every item's tasks in one read, so a view counting tasks + // across a list costs one read rather than one per item. + ListAllTasks() ([]model.ProjectItemTask, error) CreateTask(itemID string, input model.CreateProjectItemTask) (*model.ProjectItemTask, error) UpdateTask(itemID, taskID string, input model.UpdateProjectItemTask) (*model.ProjectItemTask, error) DeleteTask(itemID, taskID string) error diff --git a/both_modes_test.go b/both_modes_test.go new file mode 100644 index 0000000..68d621f --- /dev/null +++ b/both_modes_test.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "testing" + + "github.com/datapointchris/tuidrive" + "github.com/santhosh-tekuri/jsonschema/v6" +) + +// Each mode runs the built binary over the same seeded data: a database on this +// machine, served through the API's routes inside the process, and the API's +// routes over HTTP. Each --json read must match its schema, and both modes must +// answer the same. +func TestTheCommandsAnswerTheSameInBothModes(t *testing.T) { + binary := tuidrive.Build(t, ".") + titles := map[string][]string{} + for _, mode := range []string{"database", "api"} { + t.Run(mode, func(t *testing.T) { + dir := t.TempDir() + if mode == "api" { + served := filepath.Join(t.TempDir(), "served.db") + seedFixtureDatabase(t, served) + writeAPIConfig(t, dir, serveOverHTTP(t, served)) + } else { + seedFixtureDatabase(t, filepath.Join(dir, "projects.db")) + } + run := commandRunner(t, binary, dir) + + created := run(0, "items", "create", "--project", "Replace the kitchen faucet", "--title", "Buy plumber's tape", "--json") + var item struct{ Number *int } + if err := json.Unmarshal(created, &item); err != nil || item.Number == nil { + t.Fatalf("create answered %s, want a numbered item: %v", created, err) + } + number := strconv.Itoa(*item.Number) + + for _, read := range []struct { + schema string + args []string + }{ + {"project-with-items", []string{"show", "Replace the kitchen faucet", "--json"}}, + {"item-with-tasks-and-blockers", []string{"items", "show", number, "--json"}}, + {"focus-queue", []string{"next", "--json"}}, + {"dependency-tree", []string{"items", "dependencies", "tree", "--json"}}, + {"board-snapshot", []string{"tui", "--json"}}, + } { + matchesSchema(t, read.schema, run(0, read.args...)) + } + if mode == "api" { + matchesSchema(t, "auth-status", run(0, "auth", "status", "--json")) + } + + var shown struct{ Items []struct{ Title string } } + if err := json.Unmarshal(run(0, "show", "Replace the kitchen faucet", "--json"), &shown); err != nil { + t.Fatal(err) + } + for _, it := range shown.Items { + titles[mode] = append(titles[mode], it.Title) + } + if !slices.Contains(titles[mode], "Buy plumber's tape") { + t.Errorf("show listed %q, want the created item", titles[mode]) + } + + run(2, "items", "create", "--title", "No project given", "--no-input") + run(1, "show", "A project nobody made") + if _, err := os.Stat(filepath.Join(dir, "projects.db")); mode == "api" && err == nil { + t.Error("API mode opened a database") + } + }) + } + if !slices.Equal(titles["database"], titles["api"]) { + t.Errorf("the database listed %q and the API %q", titles["database"], titles["api"]) + } + + t.Run("an API that never answers exits 4", func(t *testing.T) { + dir := t.TempDir() + writeAPIConfig(t, dir, "http://127.0.0.1:1") + commandRunner(t, binary, dir)(4, "list") + }) +} + +// commandRunner runs the binary in dir with its config under dir, and fails the +// test when a run exits other than want. +func commandRunner(t *testing.T, binary, dir string) func(want int, args ...string) []byte { + return func(want int, args ...string) []byte { + t.Helper() + cmd := exec.Command(binary, args...) + cmd.Dir = dir + cmd.Env = []string{ + "PATH=" + os.Getenv("PATH"), + "HOME=" + dir, + "PROJECTS_DB=projects.db", + "XDG_CONFIG_HOME=" + filepath.Join(dir, "config"), + } + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + var exit *exec.ExitError + if err != nil && !errors.As(err, &exit) { + t.Fatal(err) + } + if code := cmd.ProcessState.ExitCode(); code != want { + t.Fatalf("projects %q exited %d, want %d\nstdout: %s\nstderr: %s", args, code, want, stdout.String(), stderr.String()) + } + return stdout.Bytes() + } +} + +func matchesSchema(t *testing.T, name string, answer []byte) { + t.Helper() + path := filepath.Join("cli", "schemas", name+".schema.json") + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer func() { _ = file.Close() }() + document, err := jsonschema.UnmarshalJSON(file) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + compiler := jsonschema.NewCompiler() + compiler.AssertFormat() + if err := compiler.AddResource(name, document); err != nil { + t.Fatal(err) + } + schema, err := compiler.Compile(name) + if err != nil { + t.Fatalf("compiling %s: %v", path, err) + } + value, err := jsonschema.UnmarshalJSON(bytes.NewReader(answer)) + if err != nil { + t.Fatalf("%s answered %s, which is not JSON: %v", name, answer, err) + } + if err := schema.Validate(value); err != nil { + t.Errorf("%s does not match its schema: %v\n%s", name, err, answer) + } +} diff --git a/cli/api_errors.go b/cli/api_errors.go new file mode 100644 index 0000000..306fa6a --- /dev/null +++ b/cli/api_errors.go @@ -0,0 +1,117 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/datapointchris/goclikit" + "github.com/datapointchris/goclilogin" + + "github.com/datapointchris/projects/apiclient" +) + +// noAnswerError is a request the API never judged: it was not reached, timed out, +// or answered 429 or 5xx. It exits 4, because the same command later can succeed. +type noAnswerError struct{ error } + +func (n noAnswerError) Unwrap() error { return n.error } + +// explain words an error from a request for the person reading it. A credential +// failure names its own remedy, a service is never sent to a login it cannot +// complete, and a refusal is the API's sentence alone. +func (s *dataSource) explain(err error) error { + if err == nil { + return nil + } + if errors.Is(err, errNeedsLogin) { + return errors.New("not logged in — run `projects auth login`") + } + var refused *apiclient.APIError + isRefusal := errors.As(err, &refused) + if s.deployment != nil { + if worded := s.credentialFailure(err, refused); worded != nil { + return worded + } + } + switch { + case isRefusal && refused.StatusCode == http.StatusUnprocessableEntity && refused.Message != "": + return goclikit.UsageError(refused) + case isRefusal && (refused.StatusCode == http.StatusTooManyRequests || refused.StatusCode >= http.StatusInternalServerError): + return noAnswerError{s.unanswered(refused)} + case errors.Is(err, context.DeadlineExceeded): + return noAnswerError{s.unanswered(err)} + } + var transport *url.Error + if errors.As(err, &transport) { + return noAnswerError{s.unanswered(transport.Err)} + } + return err +} + +// credentialFailure words a failure to authenticate, which the request never got +// past, or nil when err is not one. +func (s *dataSource) credentialFailure(err error, refused *apiclient.APIError) error { + d := *s.deployment + unauthorized := refused != nil && refused.Unauthorized() + forbidden := refused != nil && refused.Forbidden() && refused.Message != "" + if d.HandsTokens() { + var failed *goclilogin.TokenCommandError + switch { + case errors.As(err, &failed): + return fmt.Errorf("token_command in %s handed no token: it must print an RFC 6749 § 5.1 JSON token response, so run %q yourself to see what it prints: %w", + homeAsTilde(d.Path), d.TokenCommand, failed) + case unauthorized: + return fmt.Errorf("%s rejected the token %q handed", d.API.Value, d.TokenCommand) + case forbidden: + return fmt.Errorf("the token %q handed: %s", d.TokenCommand, refused.Message) + } + return nil + } + if isService() { + switch { + case goclilogin.IsSessionRejected(err): + return fmt.Errorf("%s refused service client %s — check client_id in %s, and %s", + d.Issuer.Value, d.ClientID.Value, homeAsTilde(d.Path), clientSecretEnv) + case unauthorized: + return fmt.Errorf("%s rejected the token issued to service client %s", d.API.Value, d.ClientID.Value) + case forbidden: + return fmt.Errorf("service client %s: %s", d.ClientID.Value, refused.Message) + } + return nil + } + switch { + case goclilogin.IsSessionRejected(err): + return errors.New("session expired — run `projects auth login`") + case unauthorized: + return errors.New("session rejected by the API — run `projects auth login` to log in again") + case forbidden: + return errors.New(refused.Message) + } + return nil +} + +// unanswered names the API and what chose it, as the first thing a person checks. +func (s *dataSource) unanswered(cause error) error { + if s.deployment == nil { + return cause + } + api := s.deployment.API + chosenBy := "--api" + if api.Source == goclilogin.SourceFile { + chosenBy = "api in " + homeAsTilde(api.From) + } + return fmt.Errorf("no answer from the API at %s (%s): %w, %d attempts", api.Value, chosenBy, cause, apiclient.Attempts) +} + +// notFound is the API's sentence for a 404 it explained, which goclikit follows +// with the commands that find a real name or number. +func notFound(err error) (string, bool) { + var refused *apiclient.APIError + if !errors.As(err, &refused) || !refused.NotFound() { + return "", false + } + return refused.Message, true +} diff --git a/cli/api_login.go b/cli/api_login.go new file mode 100644 index 0000000..e952b21 --- /dev/null +++ b/cli/api_login.go @@ -0,0 +1,98 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + + "github.com/datapointchris/goclilogin" +) + +// product names this tool to goclilogin: its config file, keyring service and +// state directory. +const product = "projects" + +// keyringService namespaces this tool's entries in the OS keychain. +const keyringService = product + +// itemsReadScope is what a service client asks for. The API's ServiceScopes +// lists the routes it reaches, and an identity provider refuses the whole token +// request when it names a scope the client does not hold. +const itemsReadScope = "projects.items.read" + +// clientSecretEnv selects the client-credentials grant for the client the config +// file names. It is read from the environment alone, so it is never written to +// the file and no command prints it. +const clientSecretEnv = "PROJECTS_CLIENT_SECRET" + +// loadDeployment reads the API every command talks to, from the config file's +// api, issuer, client_id and token_command. api is the --api flag's value, and +// outranks the file's when it is not empty. +// +// A service missing a value is told which key to write rather than to log in, +// since a service has no login. +func loadDeployment(ctx context.Context, api string) (goclilogin.Deployment, error) { + d, err := goclilogin.LoadDeployment(ctx, product, api) + if d.HandsTokens() && isService() { + return d, fmt.Errorf("%s sets token_command and %s is set, so projects has two credentials; keep one", + d.Path, clientSecretEnv) + } + if !isService() || !errors.Is(err, goclilogin.ErrNotConfigured) { + return d, err + } + var missing []string + for _, key := range []struct { + name string + set goclilogin.Setting + }{{"api", d.API}, {"issuer", d.Issuer}, {"client_id", d.ClientID}} { + if key.set.Value == "" { + missing = append(missing, key.name) + } + } + return d, fmt.Errorf("%w: %s sets no %s; a service names its API, provider and client there, and only %s in its environment", + goclilogin.ErrNotConfigured, d.Path, strings.Join(missing, " or "), clientSecretEnv) +} + +// deploymentForLogin is the deployment `projects auth login` logs in to: the +// file's values, with whatever it lacks filled in from api, the API's RFC 9728 +// metadata and clientID. +func deploymentForLogin(ctx context.Context, api, clientID string) (goclilogin.Deployment, error) { + return goclilogin.DeploymentForLogin(ctx, product, api, clientID) +} + +// configFilePath is the file login writes, whether or not it exists. +func configFilePath() string { return goclilogin.ConfigPath(product) } + +// defaultClientID is the client id login uses when neither the file nor +// --client-id names one: projects-cli-. +func defaultClientID() string { return goclilogin.ClientID(product) } + +// loginConfig is the goclilogin view of a deployment: the provider, the client, and +// where the token and its refresh lock live. The state directory is named +// rather than left to goclilogin's default, so the lock sits beside the tool's +// other state whatever the keyring service is called. +func loginConfig(d goclilogin.Deployment) goclilogin.Config { + return goclilogin.Config{ + Issuer: d.Issuer.Value, + ClientID: d.ClientID.Value, + KeyringService: keyringService, + StateDir: goclilogin.StateDir(product), + } +} + +// clientSecret is PROJECTS_CLIENT_SECRET, set only where a service runs this CLI +// with nobody present to approve a device login. +func clientSecret() string { return os.Getenv(clientSecretEnv) } + +// isService reports whether the CLI authenticates as a confidential service +// client rather than as the person who logged in on this machine. +func isService() bool { return clientSecret() != "" } + +// serviceClient is the goclilogin view of a deployment for the client-credentials +// grant. Nothing it obtains is stored, so it names no keyring or state +// directory. +func serviceClient(d goclilogin.Deployment) goclilogin.ServiceClient { + return goclilogin.ServiceClient{Issuer: d.Issuer.Value, ClientID: d.ClientID.Value, Scopes: []string{itemsReadScope}} +} diff --git a/cli/auth.go b/cli/auth.go new file mode 100644 index 0000000..2455ebf --- /dev/null +++ b/cli/auth.go @@ -0,0 +1,430 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "time" + + "github.com/datapointchris/goclikit" + "github.com/datapointchris/goclilogin" + "github.com/pkg/browser" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + + "github.com/datapointchris/projects/config" +) + +// loginTimeout is a backstop. The device code carries its own expiry, and the +// poll stops there first. +const loginTimeout = 15 * time.Minute + +func newAuthCommand() *cobra.Command { + cmd := goclikit.AsNamespace(&cobra.Command{ + Use: "auth", + Short: "Log in and out of the projects API", + Long: "Only API mode needs a login. With no api in the config file, every command\n" + + "reads this machine's database and nothing here applies.\n" + + "\n" + + "Login uses the OAuth 2.0 device authorization grant. The CLI prints a code and\n" + + "a URL, and you approve it in any browser on any device, including from another\n" + + "machine over SSH. The token is kept in the OS keychain, or in a file only this\n" + + "user can read where there is none.\n" + + "\n" + + "`projects auth status` prints the config file's path and its api, issuer and\n" + + "client_id. `projects auth login ` fills in whatever it lacks.\n" + + "\n" + + "With " + clientSecretEnv + " set, projects authenticates as the service client\n" + + "the config file's client_id names, through the client-credentials grant. It\n" + + "asks for a token each run and stores none, so login and logout refuse.", + Example: " projects auth status who you are, and where projects points\n" + + " projects auth login log in to the config file's api\n" + + " projects auth login https://projects.example.com log in there, filling in the config file", + }) + splitReadingFromChanging(cmd) + cmd.AddCommand(inGroup(groupChanging, withAPIFlag(newAuthLoginCommand()), newAuthLogoutCommand())...) + cmd.AddCommand(inGroup(groupReading, withAPIFlag(newAuthStatusCommand()), withAPIFlag(newAuthTokenCommand()))...) + return cmd +} + +func newAuthLoginCommand() *cobra.Command { + var clientID string + cmd := &cobra.Command{ + Use: "login [url]", + Short: "Log in by approving a code in any browser", + Long: "Logs in to the API at url, or to the config file's api when url is left out.\n" + + "Whatever the file lacks is filled in: api from url, the issuer from the API's\n" + + "RFC 9728 metadata, and the client id from --client-id or this machine's short\n" + + "hostname. Each is printed with where it came from before the login starts, and\n" + + "written to the file once it succeeds. A value already in the file is kept.\n" + + "\n" + + "Writing api to the file is what puts every command in API mode.", + Example: " projects auth login\n" + + " projects auth login https://projects.example.com", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if isService() { + return fmt.Errorf("%s is set, so projects authenticates as a service client with no login; unset it to log in as a person", clientSecretEnv) + } + url := apiOverride + if len(args) == 1 { + if apiOverride != "" && apiOverride != args[0] { + return goclikit.UsageError(fmt.Errorf("--api %s and the url %s name different APIs; give one", apiOverride, args[0])) + } + url = args[0] + } + if err := refuseLoginBesideSync(); err != nil { + return err + } + ctx, cancel := context.WithTimeout(cmd.Context(), loginTimeout) + defer cancel() + + deployment, err := deploymentForLogin(ctx, url, clientID) + if err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Logging in to:") + goclilogin.WriteDeployment(cmd.ErrOrStderr(), deployment) + _, _ = fmt.Fprintln(cmd.ErrOrStderr()) + + login := loginConfig(deployment) + store := goclilogin.NewTokenStore(login) + // pkg/browser hands the launcher this process's streams, which puts a + // browser's startup noise between the code and the URL. A launcher + // that fails is still reported by OpenURL's error. + browser.Stdout = io.Discard + browser.Stderr = io.Discard + + token, err := goclilogin.Login(ctx, login, func(p goclilogin.DevicePrompt) { + goclilogin.WriteInstructions(cmd.ErrOrStderr(), login.ClientID, p) + if openErr := browser.OpenURL(p.BrowserURL()); openErr != nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "(could not open a browser here: %v)\n", openErr) + } + }) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("timed out waiting for approval after %s; run `projects auth login` again", loginTimeout) + } + return err + } + backend, err := store.Save(login.ClientID, token) + if err != nil { + return fmt.Errorf("save token: %w", err) + } + if backend == goclilogin.BackendFile { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), + "\nNo OS keyring on this host — the token is in %s, readable by this user only.\n", store.FilePath()) + } + if err := deployment.Save(); err != nil { + return fmt.Errorf("logged in as %s, but the config file was not written: %w", login.ClientID, err) + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\n✓ Logged in (%s)\n", login.ClientID) + return nil + }, + } + cmd.Flags().StringVar(&clientID, "client-id", "", + "Log in as this OAuth client when the config file names none (default projects-cli-)") + return cmd +} + +// refuseLoginBesideSync refuses a login that would write api into a file that +// keeps a synced database, because the next command would then refuse the pair. +func refuseLoginBesideSync() error { + cfg, err := config.Load() + if err != nil { + return err + } + if cfg.Sync.Enabled { + return fmt.Errorf("%s sets [sync] enabled = true, and logging in writes api beside it, which is two authorities for "+ + "the same rows; remove [sync] first to work through the API", homeAsTilde(configFilePath())) + } + return nil +} + +func newAuthLogoutCommand() *cobra.Command { + return &cobra.Command{ + Use: "logout", + Short: "Remove the stored token from this machine", + Example: " projects auth logout", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if isService() { + return fmt.Errorf("%s is set, so projects authenticates as a service client and stores no token to remove", clientSecretEnv) + } + // Only the client id finds the token, so a file lacking the other keys + // still logs out, and one naming no client falls back to the id login + // uses when the file names none. + deployment, err := loadDeployment(cmd.Context(), "") + if err != nil && !errors.Is(err, goclilogin.ErrNotConfigured) { + return err + } + if deployment.HandsTokens() { + return fmt.Errorf("%s sets token_command, so projects stores no token and there is nothing to log out of", deployment.Path) + } + if deployment.ClientID.Value == "" { + deployment.ClientID.Value = defaultClientID() + } + clientID := deployment.ClientID.Value + if err := goclilogin.NewTokenStore(loginConfig(deployment)).Delete(clientID); err != nil { + if errors.Is(err, goclilogin.ErrNotLoggedIn) { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Not logged in — nothing to remove.") + return nil + } + return fmt.Errorf("remove token from keychain: %w", err) + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Logged out (%s).\n", clientID) + return nil + }, + } +} + +func newAuthTokenCommand() *cobra.Command { + return &cobra.Command{ + Use: "token", + Short: "Print the current access token to stdout", + Long: "Prints a valid access token, refreshing it first if it has expired. Pass it to\n" + + "curl on stdin, as the example does, so the token never appears in another\n" + + "process's arguments. Exits 1 if not logged in. With " + clientSecretEnv + " set\n" + + "it asks for a new service token, and with token_command in the config file it\n" + + "prints the token that command hands.", + Example: " projects auth token\n" + + " printf 'Authorization: Bearer %s\\n' \"$(projects auth token)\" | curl --header @- https://projects.example.com/api/v1/projects", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + deployment, err := loadDeployment(cmd.Context(), apiOverride) + if err != nil { + return err + } + source, err := tokenSource(cmd.Context(), deployment) + if errors.Is(err, errNeedsLogin) { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Not logged in as %s. Run `projects auth login`.\n", deployment.ClientID.Value) + return exitCode(1) + } + if err != nil { + return err + } + token, err := source.Token() + switch { + case err != nil && deployment.HandsTokens(): + return fmt.Errorf("token_command in %s handed no token: run %q yourself to see what it prints: %w", + homeAsTilde(deployment.Path), deployment.TokenCommand, err) + case err != nil && isService(): + return fmt.Errorf("obtain a token for service client %s: %w", deployment.ClientID.Value, err) + case err != nil: + return fmt.Errorf("obtain a valid token (the refresh may have failed — try `projects auth login`): %w", err) + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), token.AccessToken) + return nil + }, + } +} + +// credentialType is whose credential `auth status` checked, spelled as Google's +// credential files spell a person's login, a service's, and one an executable +// supplies. +type credentialType string + +const ( + authorizedUser credentialType = "authorized_user" + serviceAccount credentialType = "service_account" + externalAccount credentialType = "external_account" +) + +// authStatus is `auth status --json`. cli/schemas/auth-status.schema.json is its +// shape. It reports what this machine holds, not what the token claims, because +// the claims are the API's to verify. For a service or a handed token, logged_in +// is true only when a token was granted just now, since neither stores one. +type authStatus struct { + SchemaVersion int `json:"schema_version"` + LoggedIn bool `json:"logged_in"` + Configured bool `json:"configured"` + ConfigFile string `json:"config_file"` + Type credentialType `json:"type"` + API string `json:"api"` + ClientID string `json:"client_id"` + Issuer string `json:"issuer"` + ExpiresAt *string `json:"expires_at"` + Expired bool `json:"expired"` + // Session is what asking the provider for a token found, and null where + // nothing was asked: no config, or no token stored. + Session *goclilogin.SessionState `json:"session"` + // Backend is where the token is kept: the OS keyring, or a mode-600 file on + // a host with none. Null where nothing is stored. + Backend *goclilogin.Backend `json:"backend"` + // KeyringNote is the keyring's own failure, where it is why no token was found. + KeyringNote *string `json:"keyring_note"` +} + +func (r authStatus) session() goclilogin.SessionState { + if r.Session == nil { + return "" + } + return *r.Session +} + +const authStatusVersion = 1 + +func newAuthStatusCommand() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "status", + Short: "Show where projects is pointed and whether it is logged in", + Long: "Shows the api, issuer and client_id API mode uses, where each came from, the\n" + + "config file's path, and whether a token is stored for the client. Exits 1 when\n" + + "the file lacks a value, no token is stored, or the provider rejected the session.", + Example: " projects auth status\n projects auth status --json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + deployment, loadErr := loadDeployment(cmd.Context(), apiOverride) + if loadErr != nil && !errors.Is(loadErr, goclilogin.ErrNotConfigured) { + return loadErr + } + report := authStatus{ + SchemaVersion: authStatusVersion, + Configured: loadErr == nil, + ConfigFile: deployment.Path, + Type: authorizedUser, + API: deployment.API.Value, + ClientID: deployment.ClientID.Value, + Issuer: deployment.Issuer.Value, + } + switch { + case deployment.HandsTokens(): + report.Type = externalAccount + case isService(): + report.Type = serviceAccount + } + switch { + case !report.Configured: + case report.Type == externalAccount: + source, err := goclilogin.CommandTokenSource(cmd.Context(), deployment.TokenCommand.Argv()) + grantedStatus(source, err, &report) + case report.Type == serviceAccount: + source, err := goclilogin.ClientCredentialsTokenSource(cmd.Context(), serviceClient(deployment), clientSecret()) + grantedStatus(source, err, &report) + default: + if err := loginStatus(cmd.Context(), deployment, &report); err != nil { + return err + } + } + if asJSON { + if err := encodeJSON(cmd.OutOrStdout(), report); err != nil { + return err + } + } else { + printAuthStatus(cmd.OutOrStdout(), report, deployment, loadErr) + } + // A rejected session exits 1 for the same reason being logged out does: + // nothing run next works until a login. An unverified login exits 0, + // because its stored token may still work. + if !report.Configured || !report.LoggedIn || report.session() == goclilogin.SessionRejected { + return exitCode(1) + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output status as JSON to stdout") + return cmd +} + +// loginStatus fills report from the token this machine logged in for. +func loginStatus(ctx context.Context, d goclilogin.Deployment, report *authStatus) error { + login := loginConfig(d) + store := goclilogin.NewTokenStore(login) + token, backend, err := store.Load(d.ClientID.Value) + switch { + case errors.Is(err, goclilogin.ErrNotLoggedIn): + // goclilogin wraps the keyring's own failure where there was one. A host + // with no Secret Service is fixed by logging in, and a locked keychain + // is not, so the note is kept. + if detail := strings.TrimPrefix(err.Error(), goclilogin.ErrNotLoggedIn.Error()); detail != "" { + note := strings.Trim(detail, " ()") + report.KeyringNote = ¬e + } + case err != nil: + return fmt.Errorf("read token from keychain: %w", err) + default: + report.LoggedIn = true + report.Backend = &backend + setExpiry(report, token) + // Asking for a token the way a command does is the one thing that tells + // a soft expiry from a revoked grant. + var session goclilogin.SessionState + session, token = goclilogin.VerifySession(ctx, login, store) + report.Session = &session + setExpiry(report, token) + } + return nil +} + +// grantedStatus asks source for a token, because a service or a handed token +// stores none that could say whether the next command gets one. +func grantedStatus(source oauth2.TokenSource, err error, report *authStatus) { + var token *oauth2.Token + if err == nil { + token, err = source.Token() + } + session, token := goclilogin.ClassifySession(token, err) + report.Session = &session + report.LoggedIn = session == goclilogin.SessionLive + if token != nil && !token.Expiry.IsZero() { + expires := token.Expiry.UTC().Format(time.RFC3339) + report.ExpiresAt = &expires + } +} + +func setExpiry(report *authStatus, token *oauth2.Token) { + if token == nil || token.Expiry.IsZero() { + return + } + expires := token.Expiry.UTC().Format(time.RFC3339) + report.ExpiresAt = &expires + report.Expired = time.Now().After(token.Expiry) +} + +// printAuthStatus leads with the verdict on a line of its own, so a script +// checking a login reads the first line alone. +func printAuthStatus(out io.Writer, r authStatus, d goclilogin.Deployment, loadErr error) { + switch { + case !r.Configured: + _, _ = fmt.Fprintf(out, "%v\n", loadErr) + case r.Type == externalAccount && r.LoggedIn: + _, _ = fmt.Fprintln(out, "Handed a token by token_command") + case r.Type == externalAccount: + _, _ = fmt.Fprintf(out, "token_command printed no access_token. Run %q yourself to see what it prints.\n", d.TokenCommand) + case r.Type == serviceAccount && r.session() == goclilogin.SessionRejected: + _, _ = fmt.Fprintf(out, "Service client refused by %s. Check client_id in the config file, and %s.\n", r.Issuer, clientSecretEnv) + case r.Type == serviceAccount && r.session() == goclilogin.SessionUnverified: + _, _ = fmt.Fprintf(out, "Service client configured; %s could not be reached.\n", r.Issuer) + case r.Type == serviceAccount: + _, _ = fmt.Fprintln(out, "Service client (client credentials)") + case !r.LoggedIn: + _, _ = fmt.Fprintf(out, "Not logged in as %s. Run `projects auth login` to authenticate.\n", r.ClientID) + case r.session() == goclilogin.SessionRejected: + _, _ = fmt.Fprintf(out, "Session rejected by %s. Run `projects auth login` to authenticate.\n", r.Issuer) + default: + _, _ = fmt.Fprintln(out, "Logged in") + } + goclilogin.WriteDeployment(out, d) + if r.KeyringNote != nil { + _, _ = fmt.Fprintf(out, " %-11s%s\n", "note", *r.KeyringNote) + } + if r.ExpiresAt != nil { + state := "valid" + switch { + case r.Type == serviceAccount, r.Type == externalAccount: + state = "granted" + case r.Expired: + state = "expired — will refresh on next use" + } + _, _ = fmt.Fprintf(out, " %-11s%s (expires %s)\n", "token", state, *r.ExpiresAt) + } + if r.Backend != nil && *r.Backend == goclilogin.BackendFile { + _, _ = fmt.Fprintf(out, " %-11s%s — no OS keyring on this host\n", "storage", *r.Backend) + } + if r.Type != serviceAccount && r.session() == goclilogin.SessionUnverified { + _, _ = fmt.Fprintf(out, " %-11sunverified — %s could not be reached\n", "session", r.Issuer) + } +} diff --git a/cli/data_source.go b/cli/data_source.go new file mode 100644 index 0000000..9c33478 --- /dev/null +++ b/cli/data_source.go @@ -0,0 +1,249 @@ +package cli + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "github.com/datapointchris/goclilogin" + "github.com/spf13/cobra" + "golang.org/x/oauth2" + + "github.com/datapointchris/projects/api/requestlog" + "github.com/datapointchris/projects/api/routes" + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/apiclient" + "github.com/datapointchris/projects/backend" + "github.com/datapointchris/projects/config" + "github.com/datapointchris/projects/repos" + "github.com/datapointchris/projects/service" + "github.com/datapointchris/projects/store/sqlite" + "github.com/datapointchris/projects/sync" +) + +// inProcessBase is the address the client is given when the routes run inside +// this process. Nothing dials it. +const inProcessBase = "http://projects.invalid" + +// requestTimeout bounds one call to the API, its retries and their pauses +// included. tokenTimeout bounds one token fetch from the login provider. +const ( + requestTimeout = 60 * time.Second + tokenTimeout = 30 * time.Second +) + +// errNeedsLogin is API mode with no token stored for this machine's client. +var errNeedsLogin = errors.New("not logged in") + +// dataSource is where this run's projects live: the API the config names, or the +// database on this machine. Every command reads and writes through client, so the +// routes, refusals and JSON are the API's in both. +type dataSource struct { + client *apiclient.Client + cfg *config.Config + + // API mode. + deployment *goclilogin.Deployment + + // Database mode. + database *sql.DB + local *service.Service + engine *sync.Engine + started bool + + // writeRefusal is set where a write through the routes would be lost. + writeRefusal string +} + +// LocalService is the service over a database this machine owns outright, which +// numbers items itself because nothing upstream will. +func LocalService(database *sql.DB, reposRegistryPath string) *service.Service { + return service.New(sqlite.New(database), service.AssigningItemNumbers(), service.RefusingRepoNames(reposRegistryPath)) +} + +// openDataSource decides the mode and opens it. --api or the config file's api key +// is API mode, and opens no database. Neither is database mode. +func openDataSource(cmd *cobra.Command) (*dataSource, error) { + if err := config.RefuseConfigUnderFormerName(); err != nil { + return nil, err + } + cfg, err := config.Load() + if err != nil { + return nil, err + } + if apiOverride != "" || cfg.API != "" { + return openAPI(cmd.Context(), cfg) + } + return openDatabase(cfg) +} + +// openForWriting is openDataSource for a command that writes, refused before it +// asks anything where the write would be lost. +func openForWriting(cmd *cobra.Command) (*dataSource, error) { + source, err := openDataSource(cmd) + if err != nil { + return nil, err + } + if source.writeRefusal != "" { + _ = source.Close() + return nil, errors.New(source.writeRefusal) + } + return source, nil +} + +func openAPI(ctx context.Context, cfg *config.Config) (*dataSource, error) { + if cfg.Sync.Enabled { + return nil, fmt.Errorf("%s sets api and [sync] enabled = true, which are two authorities for the same rows; "+ + "remove [sync] to work through the API, or remove api to keep the synced database", homeAsTilde(configFilePath())) + } + deployment, err := loadDeployment(ctx, apiOverride) + if err != nil { + return nil, err + } + source := &dataSource{cfg: cfg, deployment: &deployment} + // oauth2 fetches a token through the context its source was built with, so the + // fetch is bounded by that context's client rather than by the request's. + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Timeout: tokenTimeout}) + tokens, err := tokenSource(ctx, deployment) + if err != nil { + return nil, source.explain(err) + } + httpClient := oauth2.NewClient(ctx, tokens) + httpClient.Transport = apiclient.Retrying(httpClient.Transport) + httpClient.Timeout = requestTimeout + source.client = apiclient.New(deployment.API.Value, httpClient) + return source, nil +} + +// tokenSource is the config file's token_command where it names one, the +// client-credentials grant when PROJECTS_CLIENT_SECRET is set, and otherwise the +// token this machine logged in for. +func tokenSource(ctx context.Context, d goclilogin.Deployment) (oauth2.TokenSource, error) { + if d.HandsTokens() { + return goclilogin.CommandTokenSource(ctx, d.TokenCommand.Argv()) + } + if isService() { + return goclilogin.ClientCredentialsTokenSource(ctx, serviceClient(d), clientSecret()) + } + login := loginConfig(d) + source, err := goclilogin.TokenSource(ctx, login, goclilogin.NewTokenStore(login)) + if errors.Is(err, goclilogin.ErrNotLoggedIn) { + return nil, errNeedsLogin + } + return source, err +} + +func openDatabase(cfg *config.Config) (*dataSource, error) { + if err := config.MoveDataUnderFormerName(cfg.Local.DBPath); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(cfg.Local.DBPath), 0o755); err != nil { + return nil, fmt.Errorf("creating data directory: %w", err) + } + database, err := sqlite.Open(cfg.Local.DBPath) + if err != nil { + return nil, fmt.Errorf("opening database: %w", err) + } + source := &dataSource{cfg: cfg, database: database} + if cfg.Sync.Enabled { + source.local = service.New(sqlite.New(database), service.RefusingRepoNames(repos.DefaultPath())) + source.engine = sync.New(database, cfg.Sync.APIURL, cfg.Sync.TokenCommand) + source.writeRefusal = fmt.Sprintf("[sync] enabled = true keeps this database a copy of %s, and a write from a "+ + "command would never reach it; make the change in `projects tui`, or set api in %s and remove [sync] "+ + "to write through the projects API", cfg.Sync.APIURL, homeAsTilde(configFilePath())) + } else { + source.local = LocalService(database, repos.DefaultPath()) + } + + // A fault in a route is logged with its cause, which the answer only names by + // request id. The request line itself is for a server's log, not a terminal. + faults := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + handler := requestlog.Middleware(quiet, routes.New(source.local, faults)) + if source.writeRefusal != "" { + handler = refusingWrites(handler, source.writeRefusal) + } + source.client = apiclient.New(inProcessBase, &http.Client{Transport: apiclient.InProcess(handler)}) + return source, nil +} + +// refusingWrites answers every request but a read with the sentence saying why. +// It backs up openForWriting for any write a command reaches without it. +func refusingWrites(next http.Handler, sentence string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + next.ServeHTTP(w, r) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _ = json.NewEncoder(w).Encode(wire.Error{Error: http.StatusText(http.StatusConflict), Message: sentence}) + }) +} + +// startSync starts the background push loop, for the commands that sync. +func (s *dataSource) startSync() { + if s.engine != nil && !s.started { + s.engine.Start() + s.started = true + } +} + +// boardBackend is what the TUI reads and writes: the API over the client, or the +// database's own service, wrapped to queue each write when sync is on. +func (s *dataSource) boardBackend() backend.Backend { + switch { + case s.deployment != nil: + return apiclient.NewBackend(s.client) + case s.engine != nil: + return sync.NewSyncBackend(s.local, s.engine) + default: + return s.local + } +} + +// apiHost is the API's host, or "" in database mode. +func (s *dataSource) apiHost() string { + if s.deployment == nil { + return "" + } + if parsed, err := url.Parse(s.deployment.API.Value); err == nil && parsed.Host != "" { + return parsed.Host + } + return s.deployment.API.Value +} + +func (s *dataSource) Close() error { + if s.engine != nil && s.started { + s.engine.Stop() + } + if s.database != nil { + return s.database.Close() + } + return nil +} + +// homeAsTilde writes a path under the home directory as ~, the way a person reads it. +func homeAsTilde(path string) string { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return path + } + if path == home { + return "~" + } + if rest, under := strings.CutPrefix(path, home+string(filepath.Separator)); under { + return "~/" + rest + } + return path +} diff --git a/cli/flags.go b/cli/flags.go new file mode 100644 index 0000000..e7a4074 --- /dev/null +++ b/cli/flags.go @@ -0,0 +1,240 @@ +package cli + +import ( + "errors" + "fmt" + "regexp" + "slices" + "strconv" + "strings" + "time" + + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/repos" +) + +// itemStatuses is what an item --status takes. They partition every item, because +// archived beats completed. +var itemStatuses = []string{model.ItemStatusOpen, model.ItemStatusCompleted, model.ItemStatusArchived, model.StatusAll} + +// projectStatuses is what a project --status takes. +var projectStatuses = append(slices.Clone(model.ProjectStatuses), model.StatusAll) + +// limitValue parses --limit and refuses a negative. pflag has no minimum, and a +// -1 reaching a slice expression panics where a usage error was the answer. +type limitValue struct{ target *int } + +func (v limitValue) String() string { return strconv.Itoa(*v.target) } + +func (v limitValue) Type() string { return "int" } + +func (v limitValue) Set(raw string) error { + n, err := strconv.Atoi(raw) + if err != nil { + return fmt.Errorf("invalid limit %q: a row count is a whole number", raw) + } + if n < 0 { + return fmt.Errorf("invalid limit %d: a row count is zero or more", n) + } + *v.target = n + return nil +} + +// addLimitFlag declares --limit/-n on a list the API caps. Unset sends nothing, so +// the read is every row, and --limit 0 asks for none. +func addLimitFlag(cmd *cobra.Command, target *int) { + *target = 0 + cmd.Flags().VarP(limitValue{target: target}, "limit", "n", "Maximum number of rows to return (default: every row)") +} + +// addCapFlag declares --limit/-n on a command that caps what it prints rather than +// what it asks for. +func addCapFlag(cmd *cobra.Command, target *int, fallback int, what string) { + *target = fallback + cmd.Flags().VarP(limitValue{target: target}, "limit", "n", what) +} + +// limitFlag is --limit, or nil when it was not set. +func limitFlag(cmd *cobra.Command) *int { + if !cmd.Flags().Changed("limit") { + return nil + } + found := *cmd.Flags().Lookup("limit").Value.(limitValue).target + return &found +} + +// capRows keeps the first limit rows, and none for a negative. +func capRows[T any](rows []T, limit int) []T { + if limit < 0 || len(rows) == 0 { + return []T{} + } + return rows[:min(limit, len(rows))] +} + +// noteCappedRows says on stderr when a cap cut rows from a table, so a short table +// is never read as the whole of what there is. +func noteCappedRows(cmd *cobra.Command, shown, total int) { + if shown == 0 || shown >= total { + return + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\n%d of %d shown: -n %d shows the rest\n", shown, total, total) +} + +// validateItemStatus refuses an unknown --status before the request, naming the +// words that work. +func validateItemStatus(cmd *cobra.Command, status string) error { + return validateStatus(cmd, status, itemStatuses) +} + +func validateStatus(cmd *cobra.Command, status string, known []string) error { + if !cmd.Flags().Changed("status") || slices.Contains(known, status) { + return nil + } + return goclikit.UsageError(fmt.Errorf("unknown status %q — one of: %s", status, strings.Join(known, ", "))) +} + +// repoFlagValue validates --repo and renders it as a filter. Absent is nil, no +// filter. Present and empty asks for the untagged items, the work that is not +// code, which collapsing the two would leave unreachable. +func repoFlagValue(cmd *cobra.Command, repo string) (*string, error) { + if !cmd.Flags().Changed("repo") { + return nil, nil + } + if err := validateRepoFlag(cmd, repo); err != nil { + return nil, err + } + return &repo, nil +} + +// validateRepoFlag refuses a --repo the registry does not know. On a write a +// mistyped tag drops the item out of every repo view, and on a read it selects +// nothing, which reads the same as a repo with no work. +func validateRepoFlag(cmd *cobra.Command, repo string) error { + if !cmd.Flags().Changed("repo") { + return nil + } + registry, err := repos.Load(repos.DefaultPath()) + if err != nil { + return err + } + if err := registry.Validate(repo); err != nil { + return goclikit.UsageError(err) + } + return nil +} + +// shellQuote quotes a value a hint prints to be typed. Most project names carry +// spaces, so an unquoted one pastes back as several arguments. +func shellQuote(s string) string { + if s != "" && !strings.ContainsAny(s, " \t'\"\\$`") { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// hintHiddenItems says on stderr that the default hid completed and archived +// items, quoting back the command that was run with --status all. Silent when a +// status was asked for, because then nothing was hidden the caller did not choose. +func hintHiddenItems(cmd *cobra.Command, asJSON bool) { + if asJSON || cmd.Flags().Changed("status") { + return + } + widen := cmd.CommandPath() + for _, arg := range cmd.Flags().Args() { + widen += " " + shellQuote(arg) + } + if project, err := cmd.Flags().GetString("project"); err == nil && project != "" { + widen += " --project " + shellQuote(project) + } + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nCompleted and archived items are hidden: %s --status all\n", widen) +} + +// argumentBelongsTo refuses a positional argument on a command that takes its +// subject as a flag, naming the flag. +func argumentBelongsTo(flag, purpose string) cobra.PositionalArgs { + return func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return nil + } + refusal := fmt.Sprintf("%s takes no arguments, and was given %q\n\n%s: %s %s %s", + cmd.CommandPath(), args[0], purpose, cmd.CommandPath(), flag, shellQuote(args[0])) + return goclikit.UsageError(errors.New(refusal)) + } +} + +// windowForms is what --since and --until each take. +const windowForms = "a span back from now (30m, 12h, 7d, 2w), a day (2026-10-01) or an RFC 3339 instant" + +const dayLayout = "2006-01-02" + +var ( + windowSpan = regexp.MustCompile(`^(\d+)([smhdw])$`) + windowMonthSpan = regexp.MustCompile(`^\d+(mo|M|y|Y)$`) +) + +func addWindowFlags(cmd *cobra.Command, rows string) { + cmd.Flags().String("since", "", fmt.Sprintf("Only %s at or after this: %s", rows, windowForms)) + cmd.Flags().String("until", "", fmt.Sprintf("Only %s before this; a day includes all of it", rows)) +} + +// readWindow resolves --since and --until, reading a day in this machine's zone. +func readWindow(cmd *cobra.Command) (since, until *time.Time, err error) { + sinceFlag, _ := cmd.Flags().GetString("since") + untilFlag, _ := cmd.Flags().GetString("until") + now := time.Now() + if sinceFlag != "" { + bound, err := resolveBound(sinceFlag, false, now, time.Local) + if err != nil { + return nil, nil, goclikit.UsageError(fmt.Errorf("--since %q: %w", sinceFlag, err)) + } + since = &bound + } + if untilFlag != "" { + bound, err := resolveBound(untilFlag, true, now, time.Local) + if err != nil { + return nil, nil, goclikit.UsageError(fmt.Errorf("--until %q: %w", untilFlag, err)) + } + until = &bound + } + return since, until, nil +} + +// resolveBound reads one bound as an instant. Days and weeks step the calendar, +// and a day is its first instant on --since and the next day's on --until, so the +// whole day is inside the window. +func resolveBound(value string, until bool, now time.Time, loc *time.Location) (time.Time, error) { + if m := windowSpan.FindStringSubmatch(value); m != nil { + n, err := strconv.Atoi(m[1]) + if err != nil { + return time.Time{}, err + } + switch m[2] { + case "s": + return now.Add(-time.Duration(n) * time.Second), nil + case "m": + return now.Add(-time.Duration(n) * time.Minute), nil + case "h": + return now.Add(-time.Duration(n) * time.Hour), nil + case "d": + return now.AddDate(0, 0, -n), nil + default: + return now.AddDate(0, 0, -7*n), nil + } + } + if windowMonthSpan.MatchString(value) { + return time.Time{}, errors.New("a month or a year has no fixed length; write weeks (26w) or a day") + } + if day, err := time.ParseInLocation(dayLayout, value, loc); err == nil { + if until { + return time.Date(day.Year(), day.Month(), day.Day()+1, 0, 0, 0, 0, loc), nil + } + return day, nil + } + if at, err := time.Parse(time.RFC3339, value); err == nil { + return at, nil + } + return time.Time{}, fmt.Errorf("want %s", windowForms) +} diff --git a/cli/focus_queue.go b/cli/focus_queue.go new file mode 100644 index 0000000..f6305e2 --- /dev/null +++ b/cli/focus_queue.go @@ -0,0 +1,227 @@ +package cli + +import ( + "fmt" + "math" + "sort" + "strings" + + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/apiclient" + "github.com/datapointchris/projects/model" +) + +// defaultNextItemLimit caps the items `projects next` prints; --limit raises it. +const defaultNextItemLimit = 10 + +// focusShown is `projects next --json`. cli/schemas/focus-queue.schema.json is its +// shape. The totals count what the cap left out. +type focusShown struct { + SchemaVersion int `json:"schema_version"` + Project *wire.Project `json:"project"` + Next []wire.ProjectItem `json:"next"` + NextTotal int `json:"next_total"` + Blocked []wire.ProjectItem `json:"blocked"` + BlockedTotal int `json:"blocked_total"` +} + +const focusShownVersion = 1 + +func newProjectsNextCommand() *cobra.Command { + var ( + asJSON bool + kind string + repo string + limit int + ) + cmd := &cobra.Command{ + Use: "next", + Short: "The project in focus and its open items, in the order to take them", + Long: "Projects are worked one at a time. The focus is the highest-ranked active project\n" + + "holding an open item. It is printed with its items in their position order, so\n" + + "the first row is the next thing to do. No other project appears until it is\n" + + "completed or moved down with `projects edit --position `.\n" + + "\n" + + "If every open item in the focus project is blocked, it keeps the focus and its\n" + + "blocked items are listed instead. An item in several projects belongs to the\n" + + "highest-ranked active one.", + Example: " projects next\n" + + " projects next --kind chore\n" + + " projects next --repo photo-sorter\n" + + " projects next --limit 1 --json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + filter, err := repoFlagValue(cmd, repo) + if err != nil { + return err + } + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + // Every open item, uncapped, because --limit caps what is printed here. + all, err := source.client.ListItems(cmd.Context(), apiclient.ItemQuery{Status: model.ItemStatusOpen, Repo: filter}) + if err != nil { + return source.explain(err) + } + // Blocked is unfiltered: a blocker in another repo still blocks, and + // filtering would present blocked work as ready. + blocked, err := source.client.ListBlockedItems(cmd.Context(), nil) + if err != nil { + return source.explain(err) + } + focus := focusQueue(all, blocked, kind) + items := capRows(focus.Next, limit) + if asJSON { + return encodeJSON(cmd.OutOrStdout(), focusShown{ + SchemaVersion: focusShownVersion, + Project: focus.Project, + Next: items, + NextTotal: len(focus.Next), + Blocked: capRows(focus.Blocked, limit), + BlockedTotal: len(focus.Blocked), + }) + } + out := cmd.OutOrStdout() + // The sentences read the uncapped set, so --limit 0 never reports no focus. + if focus.Project == nil { + switch { + case kind != "": + _, _ = fmt.Fprintf(out, "No open items in %s projects.\n", kind) + case filter != nil: + _, _ = fmt.Fprintf(out, "No open items for repo %s.\n", *filter) + default: + _, _ = fmt.Fprintln(out, "No items.") + } + return nil + } + if len(focus.Next) == 0 { + _, _ = fmt.Fprintf(out, "%s: nothing to take, every open item is blocked.\n", focus.Project.Name) + printItemsTable(out, focus.Blocked) + return nil + } + // A zero cap prints nothing, the way `head -n 0` does. + if len(items) == 0 { + return nil + } + _, _ = fmt.Fprintln(out, focus.Project.Name) + printQueueTable(out, items) + noteCappedRows(cmd, len(items), len(focus.Next)) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the project and its items as JSON to stdout") + cmd.Flags().StringVar(&kind, "kind", "", "Only projects of this kind: "+strings.Join(model.ProjectKinds, ", ")) + cmd.Flags().StringVar(&repo, "repo", "", "Only items tagged with this repo (empty string for untagged work)") + addCapFlag(cmd, &limit, defaultNextItemLimit, "Maximum number of items to show") + return cmd +} + +// projectFocus is the one project being worked through and its open items. Next +// can be taken now, and Blocked waits on a dependency, each in the order taken. +// Project is nil when no active project holds an open item. +type projectFocus struct { + Project *wire.Project + Next []wire.ProjectItem + Blocked []wire.ProjectItem +} + +// focusQueue picks the highest-ranked active project holding an open item, and +// returns its open items. A project whose open items are all blocked keeps the +// focus with nothing to take, because handing the focus on would switch work +// without anyone deciding to. A kind narrows which projects rank as well as +// which items count. +func focusQueue(all, blocked []wire.ProjectItem, kind string) projectFocus { + isBlocked := make(map[string]bool, len(blocked)) + for _, item := range blocked { + isBlocked[item.ID] = true + } + var focus projectFocus + var open []wire.ProjectItem + for _, item := range all { + if item.Completed || item.Archived { + continue + } + project, active := primaryProject(item, kind) + if !active { + continue + } + if focus.Project == nil || outranks(project, *focus.Project) { + focus.Project = &project + } + open = append(open, item) + } + if focus.Project == nil { + return focus + } + sort.SliceStable(open, func(a, b int) bool { return takenBefore(open[a], open[b], kind) }) + for _, item := range open { + if project, _ := primaryProject(item, kind); project.ID != focus.Project.ID { + continue + } + if isBlocked[item.ID] { + focus.Blocked = append(focus.Blocked, item) + } else { + focus.Next = append(focus.Next, item) + } + } + return focus +} + +// takenBefore orders by the rank of each item's project, then its place there. +// Creation time and id settle the rest. +func takenBefore(a, b wire.ProjectItem, kind string) bool { + projectA, _ := primaryProject(a, kind) + projectB, _ := primaryProject(b, kind) + if projectA.ID != projectB.ID { + return outranks(projectA, projectB) + } + if queuedA, queuedB := queuePosition(a, projectA.ID), queuePosition(b, projectB.ID); queuedA != queuedB { + return queuedA < queuedB + } + if !a.CreatedAt.Equal(b.CreatedAt.Time) { + return a.CreatedAt.Before(b.CreatedAt.Time) + } + return a.ID < b.ID +} + +func queuePosition(item wire.ProjectItem, projectID string) int { + for _, membership := range item.Memberships { + if membership.ProjectID == projectID { + return membership.Position + } + } + return math.MaxInt +} + +// primaryProject is the highest-ranked active project an item is in, of kind +// where one is named, and whether it has one. +func primaryProject(item wire.ProjectItem, kind string) (wire.Project, bool) { + var primary wire.Project + found := false + for _, project := range item.Projects { + if project.Status != model.StatusActive || (kind != "" && project.Kind != kind) { + continue + } + if !found || outranks(project, primary) { + primary = project + found = true + } + } + return primary, found +} + +// outranks orders projects by position. Creation time then id break a tie, which +// keeps the order stable between reads. +func outranks(a, b wire.Project) bool { + if a.Position != b.Position { + return a.Position < b.Position + } + if !a.CreatedAt.Equal(b.CreatedAt.Time) { + return a.CreatedAt.Before(b.CreatedAt.Time) + } + return a.ID < b.ID +} diff --git a/cli/item_create.go b/cli/item_create.go new file mode 100644 index 0000000..f38a967 --- /dev/null +++ b/cli/item_create.go @@ -0,0 +1,294 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "sort" + "strings" + + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/apiclient" + "github.com/datapointchris/projects/model" + "github.com/datapointchris/projects/prompt" + "github.com/datapointchris/projects/repos" +) + +func newItemsCreateCommand() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "create [flags]", + Short: "Create an item in one or more projects", + Long: "Pass every field as a flag to create in one shot. Leave --title or --project\n" + + "out at a terminal and the rest are asked for one at a time. Tab prints the\n" + + "projects and repos, and typing part of a name first narrows them.\n" + + "\n" + + "Answer Project with + to make a new one without leaving the item. It asks\n" + + "for a name, a description and a kind, then goes on filing.\n" + + "\n" + + "Project repeats until Enter on an empty one, so an item can join several.\n" + + "Notes take as many lines as you want and end on a blank one. Ctrl-C abandons\n" + + "the item.", + Example: " projects items create\n" + + " projects items create --project \"Kitchen remodel\" --title \"Get three quotes\"\n" + + " projects items create --project \"Kitchen remodel\" --title \"Pick tile\" --notes \"$(cat tile.md)\"", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + answers := flagAnswers(cmd, "title", "project", "repo", "notes") + missing := missingFlags(answers, "title", "project") + // Ahead of opening anything, so a caller that cannot be asked is told + // which flag it left out. + if len(missing) > 0 && !interactive(cmd) { + return goclikit.UsageError(fmt.Errorf("%s required — pass them, or run from a terminal to be asked", + strings.Join(missing, " and "))) + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + ctx := cmd.Context() + made := &createdProjects{} + fields, err := itemCreateFields(ctx, source, made) + if err != nil { + return source.explain(err) + } + if err := validateAnswers(answers, fields); err != nil { + return goclikit.UsageError(err) + } + if len(missing) > 0 { + asked, err := runForm(cmd, prompt.Form{ + Intro: "Creating an item. Ctrl-C to abandon it.", + Fields: unanswered(fields, answers), + }) + if errors.Is(err, errAborted) { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nAborted.") + made.report(cmd.ErrOrStderr()) + return nil + } + if err != nil { + made.report(cmd.ErrOrStderr()) + return err + } + answers.Merge(asked) + } + input := apiclient.NewItem{Title: answers.Get("title"), ProjectIDs: answers.All("project")} + if answers.Has("notes") { + notes := answers.Get("notes") + input.Notes = ¬es + } + if answers.Has("repo") { + repo := answers.Get("repo") + input.Repo = &repo + } + item, err := source.client.CreateItem(ctx, input) + if err != nil { + made.report(cmd.ErrOrStderr()) + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), item) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Created item %q (%s)\n", item.Title, handle(item.Number, item.ID)) + return nil + }, + } + cmd.Flags().String("title", "", "Item title (asked for when omitted)") + cmd.Flags().String("notes", "", "Markdown notes for the item") + cmd.Flags().String("repo", "", "Repo this item is work on, by registry name (optional)") + cmd.Flags().StringArray("project", nil, "Project name or id to add the item to (repeatable; asked for when omitted)") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the created item as JSON to stdout") + return cmd +} + +// itemCreateFields is the record `items create` builds, in the order it asks. The +// same list validates the flags, so a bad value reads the same through either +// door. The choices are read rather than declared, because projects and the repo +// registry change without this file changing. +func itemCreateFields(ctx context.Context, source *dataSource, made *createdProjects) ([]prompt.Field, error) { + projects, err := source.client.ListProjects(ctx, apiclient.ProjectQuery{}) + if err != nil { + return nil, err + } + names := make([]string, 0, len(projects)) + for _, project := range projects { + names = append(names, project.Name) + } + sort.Strings(names) + registry, err := repos.Load(repos.DefaultPath()) + if err != nil { + return nil, err + } + return []prompt.Field{ + {Key: "title", Label: "Title"}, + { + Key: "project", + Label: "Project", + Hint: "The project this belongs to. A repo is the Repo tag below, not a project.", + Choices: names, + Repeat: true, + Validate: projectRef(names), + Escape: &prompt.Escape{ + Trigger: newProjectTrigger, + Hint: newProjectTrigger + " makes a new project.", + Run: func(session prompt.Session) (string, error) { + name, err := createProjectFromForm(ctx, source, session) + if name != "" { + made.add(name) + } + return name, err + }, + }, + }, + { + Key: "repo", + Label: "Repo", + Hint: "The repo this is work on. Leave it empty for work that is not code.", + Choices: registry.Names(), + Optional: true, + Validate: repoName(registry), + }, + {Key: "notes", Label: "Notes", Optional: true, Multiline: true}, + }, nil +} + +// newProjectTrigger is the answer to Project that makes one instead of picking +// one. No project is called "+". +const newProjectTrigger = "+" + +// createdProjects is what the Project field's escape made during a form. A +// project is written the moment it is made, and the item can still be abandoned +// after, so the command names each one it leaves behind. +type createdProjects struct{ names []string } + +func (c *createdProjects) add(name string) { c.names = append(c.names, name) } + +func (c *createdProjects) report(out io.Writer) { + for _, name := range c.names { + _, _ = fmt.Fprintf(out, "Project %q was created and kept.\n", name) + } +} + +// createProjectFromForm makes a project without leaving the item being filed, +// and returns its name for the field that ran it. An empty name backs out and +// makes nothing. A refused create says why and asks again from the name, keeping +// the description and kind already given, because the name is what a refusal +// most often names. +func createProjectFromForm(ctx context.Context, source *dataSource, session prompt.Session) (string, error) { + input := apiclient.NewProject{} + for { + name, err := askProjectName(session) + if name == "" || err != nil { + return "", err + } + input.Name = name + answers, err := prompt.Form{Fields: newProjectFields(input)}.Run(session) + if err != nil { + return "", err + } + if description := answers.Get("description"); description != "" { + input.Description = &description + } + if kind := answers.Get("kind"); kind != "" { + input.Kind = &kind + } + project, err := source.client.CreateProject(ctx, input) + if err == nil { + _, _ = fmt.Fprintf(session, " Created project %q.\n", project.Name) + return project.Name, nil + } + _, _ = fmt.Fprintf(session, " %v\n", source.explain(err)) + } +} + +// askProjectName asks for the name alone, so an empty one backs out before the +// description and kind are asked for a project that will not exist. +func askProjectName(session prompt.Session) (string, error) { + answers, err := prompt.Form{ + Intro: "New project. Enter on an empty name goes back to picking one.", + Fields: []prompt.Field{{Key: "name", Label: "Name", Optional: true}}, + }.Run(session) + if err != nil { + return "", err + } + if answers.Get("name") == "" { + _, _ = fmt.Fprintln(session, " No project made.") + return "", nil + } + return answers.Get("name"), nil +} + +// newProjectFields is the rest of the record, with what an earlier attempt +// collected as the defaults, so a retry is Enter twice. +func newProjectFields(input apiclient.NewProject) []prompt.Field { + kind := model.KindLife + if input.Kind != nil { + kind = *input.Kind + } + description := "" + if input.Description != nil { + description = *input.Description + } + return []prompt.Field{ + { + Key: "description", + Label: "Description", + Hint: "What the effort covers. A project without one collects the wrong items later.", + Default: description, + Optional: true, + }, + { + Key: "kind", + Label: "Kind", + Default: kind, + Choices: model.ProjectKinds, + Validate: prompt.OneOf(model.ProjectKinds), + }, + } +} + +// projectRef accepts a project by name, case-insensitively, and returns the name +// as the API spells it. A UUID passes straight through, because only names can be +// offered as choices. +func projectRef(names []string) func(string) (string, error) { + byName := prompt.OneOf(names) + return func(answer string) (string, error) { + if looksLikeUUID(answer) { + return answer, nil + } + return byName(answer) + } +} + +func looksLikeUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, r := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if r != '-' { + return false + } + continue + } + isHex := (r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F') + if !isHex { + return false + } + } + return true +} + +// repoName accepts a registered repo name, and anything where the registry is +// missing, as --repo does. +func repoName(registry *repos.Registry) func(string) (string, error) { + return func(answer string) (string, error) { + if err := registry.Validate(answer); err != nil { + return "", err + } + return answer, nil + } +} diff --git a/cli/item_dependencies.go b/cli/item_dependencies.go new file mode 100644 index 0000000..3b40dd0 --- /dev/null +++ b/cli/item_dependencies.go @@ -0,0 +1,372 @@ +package cli + +import ( + "errors" + "fmt" + "io" + "slices" + "strconv" + "strings" + + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/apiclient" + "github.com/datapointchris/projects/graph" + "github.com/datapointchris/projects/model" +) + +func newItemDependenciesCommand() *cobra.Command { + cmd := goclikit.AsNamespace(&cobra.Command{ + Use: "dependencies", + Short: "Record what an item waits on, and draw the trees that makes", + Long: "An item depends on another when it cannot finish first. An item with an open\n" + + "dependency is blocked: `projects items blocked` lists them.", + Example: " projects items dependencies add 118 --depends-on 42\n" + + " projects items dependencies tree 118", + }) + goclikit.WithRecoveryHints(cmd, itemHints...) + splitReadingFromChanging(cmd) + cmd.AddCommand(inGroup(groupReading, goclikit.WithRecoveryHints(newDependenciesTreeCommand(), itemAndProjectHints...))...) + cmd.AddCommand(inGroup(groupChanging, newDependenciesAddCommand(), newDependenciesRemoveCommand())...) + return cmd +} + +func newDependenciesAddCommand() *cobra.Command { + var ( + dependsOn string + asJSON bool + ) + cmd := &cobra.Command{ + Use: "add --depends-on ", + Short: "Record that an item depends on another", + Long: "An item cannot depend on itself, and a dependency that would close a cycle is\nrefused.", + Example: " projects items dependencies add 118 --depends-on 42", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if dependsOn == "" { + return goclikit.UsageError(errors.New("--depends-on is required")) + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + detail, err := source.client.AddDependency(cmd.Context(), args[0], dependsOn) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), detail) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Item %q now depends on %s (%d dependencies)\n", detail.Title, dependsOn, len(detail.DependencyIDs)) + return nil + }, + } + cmd.Flags().StringVar(&dependsOn, "depends-on", "", "Item number or id this item depends on (required)") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the item detail as JSON to stdout") + return cmd +} + +func newDependenciesRemoveCommand() *cobra.Command { + var ( + dependsOn string + yes bool + ) + cmd := &cobra.Command{ + Use: "remove --depends-on ", + Short: "Remove a dependency between two items", + Example: " projects items dependencies remove 118 --depends-on 42", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if dependsOn == "" { + return goclikit.UsageError(errors.New("--depends-on is required")) + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + if !yes { + ok, err := confirm(cmd, fmt.Sprintf("Remove dependency %s → %s?", args[0], dependsOn)) + if err != nil { + return err + } + if !ok { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Aborted.") + return nil + } + } + if err := source.client.RemoveDependency(cmd.Context(), args[0], dependsOn); err != nil { + return source.explain(err) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed dependency %s → %s\n", args[0], dependsOn) + return nil + }, + } + cmd.Flags().StringVar(&dependsOn, "depends-on", "", "Item number or id to stop depending on (required)") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt") + return cmd +} + +func newDependenciesTreeCommand() *cobra.Command { + var ( + asJSON bool + invert bool + depth int + project string + repo string + itemStatus string + ) + cmd := &cobra.Command{ + Use: "tree []", + Short: "Draw the dependency trees, or the one an item sits in", + Long: "Name an item to draw the tree it belongs to, or nothing to draw every tree.\n" + + "An item with no dependencies either way is in no tree.\n" + + "\n" + + "A child is something its parent waits on. --invert reads the edges the other\n" + + "way, so the children are the work the root unblocks.\n" + + "\n" + + "--status, --project and --repo choose which trees are drawn, not which rows\n" + + "appear in one, because dropping a row orphans everything below it. A tree is\n" + + "kept when any of its items matches. A tree with no open work is hidden by\n" + + "default.\n" + + "\n" + + "A dependency outside its parent's projects is tagged with the project it is\n" + + "in. (*) marks an item whose dependencies were already drawn higher up.", + Example: " projects items dependencies tree\n" + + " projects items dependencies tree 179\n" + + " projects items dependencies tree 179 --invert\n" + + " projects items dependencies tree --repo photo-sorter\n" + + " projects items dependencies tree --status all --json", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateItemStatus(cmd, itemStatus); err != nil { + return err + } + if depth < 0 { + return goclikit.UsageError(errors.New("--depth cannot be negative")) + } + maxDepth := graph.Unlimited + if cmd.Flags().Changed("depth") { + maxDepth = depth + } + repoFilter, err := repoFlagValue(cmd, repo) + if err != nil { + return err + } + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + // Every item, whatever the flags narrow to: a finished item mid-chain + // joins the halves either side of it, so a subset splits one tree. + items, err := source.client.ListItems(cmd.Context(), apiclient.ItemQuery{Status: model.StatusAll}) + if err != nil { + return source.explain(err) + } + byID := make(map[string]wire.ProjectItem, len(items)) + nodes := make([]graph.Node, 0, len(items)) + for _, it := range items { + byID[it.ID] = it + nodes = append(nodes, graph.Node{ID: it.ID, Order: treeOrder(it.Number), Deps: it.DependencyIDs}) + } + tree := graph.BuildTree(nodes) + + var drawings []drawing + if len(args) == 1 { + item, err := findItem(items, args[0]) + if err != nil { + return err + } + members := tree.ComponentOf(item.ID) + if members == nil { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Item %s has no dependencies either way.\n", handle(item.Number, item.ID)) + members = []string{item.ID} + } + drawings = []drawing{{roots: []string{item.ID}, members: members}} + } else { + for _, members := range tree.Components() { + if componentMatches(members, byID, itemStatus, project, repoFilter) { + drawings = append(drawings, drawing{roots: tree.Roots(members, invert), members: members}) + } + } + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), treeShownOf(tree, byID, drawings, invert, maxDepth)) + } + printTrees(cmd.OutOrStdout(), tree, byID, drawings, invert, maxDepth) + if len(args) == 0 && !cmd.Flags().Changed("status") { + _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nTrees with no open work are hidden: %s --status all\n", cmd.CommandPath()) + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the nodes and edges as JSON to stdout") + cmd.Flags().BoolVar(&invert, "invert", false, "Draw what each item unblocks instead of what it waits on") + cmd.Flags().IntVar(&depth, "depth", 0, "Stop drawing below this many levels (default every level)") + cmd.Flags().StringVar(&project, "project", "", "Only trees holding an item in this project") + cmd.Flags().StringVar(&repo, "repo", "", "Only trees holding an item tagged with this repo (empty string for untagged work)") + cmd.Flags().StringVar(&itemStatus, "status", "", "Keep trees holding an item in this state: "+strings.Join(itemStatuses, ", ")+" (default open)") + return cmd +} + +// treeOrder sorts siblings by number, with an unnumbered item last. +func treeOrder(number *int) int { + if number == nil { + return 1 << 30 + } + return *number +} + +// findItem finds the item a reference names among those already read, by number +// or id, so naming the root costs no second request. +func findItem(items []wire.ProjectItem, ref string) (wire.ProjectItem, error) { + if number, err := strconv.Atoi(ref); err == nil { + for _, it := range items { + if it.Number != nil && *it.Number == number { + return it, nil + } + } + } + for _, it := range items { + if it.ID == ref { + return it, nil + } + } + return wire.ProjectItem{}, fmt.Errorf("no item %q — `projects items list --status all` shows the numbers", ref) +} + +// componentMatches keeps a tree when any of its items answers each filter. The +// status default is open, as on every other item read. +func componentMatches(members []string, byID map[string]wire.ProjectItem, itemStatus, project string, repo *string) bool { + if itemStatus == "" { + itemStatus = model.ItemStatusOpen + } + statusOK, projectOK, repoOK := itemStatus == model.StatusAll, project == "", repo == nil + for _, id := range members { + item := byID[id] + statusOK = statusOK || item.Status == itemStatus + projectOK = projectOK || slices.ContainsFunc(item.Projects, func(p wire.Project) bool { + return p.Name == project || p.ID == project + }) + repoOK = repoOK || text(item.Repo) == *repo + } + return statusOK && projectOK && repoOK +} + +// drawing is one tree to draw: where the walk starts, and the items in it. +type drawing struct { + roots []string + members []string +} + +func printTrees(out io.Writer, tree *graph.Tree, byID map[string]wire.ProjectItem, drawings []drawing, invert bool, maxDepth int) { + if len(drawings) == 0 { + _, _ = fmt.Fprintln(out, "No dependency trees.") + return + } + for i, d := range drawings { + if i > 0 { + _, _ = fmt.Fprintln(out) + } + for _, row := range tree.Rows(d.roots, invert, maxDepth) { + _, _ = fmt.Fprintln(out, treeLine(row, byID)) + } + } +} + +func treeLine(row graph.Row, byID map[string]wire.ProjectItem) string { + item := byID[row.ID] + var b strings.Builder + b.WriteString(graph.Prefix(row)) + b.WriteString(statusMark(item.Status)) + b.WriteString(" ") + b.WriteString(handle(item.Number, item.ID)) + b.WriteString(" ") + b.WriteString(item.Title) + if tag := crossProjectTag(item, byID[row.Parent], row.Parent != ""); tag != "" { + b.WriteString(" [" + tag + "]") + } + if row.Repeated { + b.WriteString(" (*)") + } + return b.String() +} + +// crossProjectTag names the project a dependency is in when it shares none with +// the item waiting on it, the edge neither project's own list can show. +func crossProjectTag(item, parent wire.ProjectItem, hasParent bool) string { + if !hasParent || len(item.Projects) == 0 { + return "" + } + for _, p := range item.Projects { + for _, q := range parent.Projects { + if p.ID == q.ID { + return "" + } + } + } + return item.Projects[0].Name +} + +// treeShown is `items dependencies tree --json`: the nodes and edges, never the +// drawing, so a consumer wanting another shape parses no box-drawing. +// cli/schemas/dependency-tree.schema.json is its shape. An edge runs from an item +// to what it depends on whichever way the drawing ran, and only roots follows +// --invert. Edges and roots name items by id, because an item made on a synced +// database has no number until its create reaches the API. +type treeShown struct { + SchemaVersion int `json:"schema_version"` + Nodes []treeNode `json:"nodes"` + Edges []treeEdge `json:"edges"` + Roots []string `json:"roots"` +} + +type treeNode struct { + ID string `json:"id"` + Number *int `json:"number"` + Title string `json:"title"` + Status string `json:"status"` + Repo *string `json:"repo"` + Projects []string `json:"projects"` +} + +type treeEdge struct { + Item string `json:"item"` + DependsOn string `json:"depends_on"` +} + +const treeShownVersion = 1 + +func treeShownOf(tree *graph.Tree, byID map[string]wire.ProjectItem, drawings []drawing, invert bool, maxDepth int) treeShown { + shown := treeShown{SchemaVersion: treeShownVersion, Nodes: []treeNode{}, Edges: []treeEdge{}, Roots: []string{}} + for _, d := range drawings { + drawn := make(map[string]bool) + for _, row := range tree.Rows(d.roots, invert, maxDepth) { + drawn[row.ID] = true + } + for _, id := range d.members { + if !drawn[id] { + continue + } + item := byID[id] + projects := make([]string, 0, len(item.Projects)) + for _, p := range item.Projects { + projects = append(projects, p.Name) + } + shown.Nodes = append(shown.Nodes, treeNode{ + ID: item.ID, Number: item.Number, Title: item.Title, Status: item.Status, Repo: item.Repo, Projects: projects, + }) + for _, dependency := range item.DependencyIDs { + if drawn[dependency] { + shown.Edges = append(shown.Edges, treeEdge{Item: item.ID, DependsOn: dependency}) + } + } + } + shown.Roots = append(shown.Roots, d.roots...) + } + return shown +} diff --git a/cli/item_memberships.go b/cli/item_memberships.go new file mode 100644 index 0000000..7287fa0 --- /dev/null +++ b/cli/item_memberships.go @@ -0,0 +1,99 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" +) + +func newItemMembershipsCommand() *cobra.Command { + cmd := goclikit.AsNamespace(&cobra.Command{ + Use: "memberships", + Short: "Add an item to another project, or take it out of one", + Long: "An item is in one or more projects, and holds a place in each. It is always\n" + + "in at least one, so leaving its last project is refused: delete the item\n" + + "instead.", + Example: " projects items memberships add 118 --project \"Kitchen remodel\"", + }) + goclikit.WithRecoveryHints(cmd, itemAndProjectHints...) + cmd.AddCommand(newMembershipsAddCommand(), newMembershipsRemoveCommand()) + return cmd +} + +func newMembershipsAddCommand() *cobra.Command { + var ( + project string + asJSON bool + ) + cmd := &cobra.Command{ + Use: "add --project ", + Short: "Add an item to another project", + Example: " projects items memberships add 118 --project \"Kitchen remodel\"", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if project == "" { + return goclikit.UsageError(errors.New("--project is required")) + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + joined, err := source.client.AddItemToProject(cmd.Context(), args[0], project) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), joined) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Added item %s to project %q (%s)\n", args[0], joined.Name, joined.ID) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "Project name or id to add the item to (required)") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the project as JSON to stdout") + return cmd +} + +func newMembershipsRemoveCommand() *cobra.Command { + var ( + project string + yes bool + ) + cmd := &cobra.Command{ + Use: "remove --project ", + Short: "Take an item out of a project", + Example: " projects items memberships remove 118 --project \"Kitchen remodel\"", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if project == "" { + return goclikit.UsageError(errors.New("--project is required")) + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + if !yes { + ok, err := confirm(cmd, fmt.Sprintf("Remove item %s from project %s?", args[0], shellQuote(project))) + if err != nil { + return err + } + if !ok { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Aborted.") + return nil + } + } + if err := source.client.RemoveItemFromProject(cmd.Context(), args[0], project); err != nil { + return source.explain(err) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed item %s from project %s\n", args[0], shellQuote(project)) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "Project name or id to take the item out of (required)") + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt") + return cmd +} diff --git a/cli/item_tasks.go b/cli/item_tasks.go new file mode 100644 index 0000000..ece2b78 --- /dev/null +++ b/cli/item_tasks.go @@ -0,0 +1,205 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/apiclient" +) + +func newItemTasksCommand() *cobra.Command { + cmd := goclikit.AsNamespace(&cobra.Command{ + Use: "tasks", + Short: "List and change an item's tasks", + Long: "A task is a checklist line inside one item. An item with an open task cannot\n" + + "be completed. is the id `projects items tasks list ` prints.", + Example: " projects items tasks list 118\n" + + " projects items tasks add 118 --title \"Write the tests\"", + }) + goclikit.WithRecoveryHints(cmd, itemAndTaskHints...) + splitReadingFromChanging(cmd) + cmd.AddCommand(inGroup(groupReading, newTasksListCommand())...) + cmd.AddCommand(inGroup(groupChanging, + newTasksAddCommand(), + newTasksCompleteCommand(), + newTasksEditCommand(), + newTasksRemoveCommand(), + )...) + return cmd +} + +func newTasksListCommand() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "list ", + Short: "List an item's tasks", + Example: " projects items tasks list 118", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + tasks, err := source.client.ListTasks(cmd.Context(), args[0]) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), tasks) + } + printTasksTable(cmd.OutOrStdout(), tasks) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output tasks as JSON to stdout") + return cmd +} + +func newTasksAddCommand() *cobra.Command { + var ( + title string + position int + asJSON bool + ) + cmd := &cobra.Command{ + Use: "add --title ", + Short: "Add a task to an item", + Example: " projects items tasks add 118 --title \"Write the tests\"", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if title == "" { + return goclikit.UsageError(errors.New("--title is required")) + } + input := apiclient.NewTask{Title: title} + if cmd.Flags().Changed("position") { + input.Position = &position + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + task, err := source.client.CreateTask(cmd.Context(), args[0], input) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), task) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Added task %q (%s)\n", task.Title, task.ID) + return nil + }, + } + cmd.Flags().StringVar(&title, "title", "", "Task title (required)") + cmd.Flags().IntVar(&position, "position", 0, "Task position within the item") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the created task as JSON to stdout") + return cmd +} + +func newTasksCompleteCommand() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "complete <item> <task>", + Short: "Mark an item's task completed", + Example: " projects items tasks complete 118 019a2b3c-…", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + completed := true + return runTaskUpdate(cmd, args[0], args[1], apiclient.TaskChanges{Completed: &completed}, asJSON) + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the updated task as JSON to stdout") + return cmd +} + +func newTasksEditCommand() *cobra.Command { + var ( + title string + completed bool + position int + asJSON bool + ) + cmd := &cobra.Command{ + Use: "edit <item> <task> [flags]", + Short: "Change a task's title, completion or position", + Example: " projects items tasks edit 118 019a2b3c-… --title \"Write the tests first\"\n projects items tasks edit 118 019a2b3c-… --completed=false", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + edit := apiclient.TaskChanges{} + if f.Changed("title") { + edit.Title = &title + } + if f.Changed("completed") { + edit.Completed = &completed + } + if f.Changed("position") { + edit.Position = &position + } + if edit == (apiclient.TaskChanges{}) { + return goclikit.UsageError(errors.New("nothing to change — pass --title, --completed or --position")) + } + return runTaskUpdate(cmd, args[0], args[1], edit, asJSON) + }, + } + cmd.Flags().StringVar(&title, "title", "", "New task title") + cmd.Flags().BoolVar(&completed, "completed", false, "Set completion: --completed or --completed=false") + cmd.Flags().IntVar(&position, "position", 0, "New position") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the updated task as JSON to stdout") + return cmd +} + +func runTaskUpdate(cmd *cobra.Command, itemRef, taskID string, edit apiclient.TaskChanges, asJSON bool) error { + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + task, err := source.client.UpdateTask(cmd.Context(), itemRef, taskID, edit) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), task) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Updated task %q → %s (%s)\n", task.Title, taskState(task), task.ID) + return nil +} + +func newTasksRemoveCommand() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "remove <item> <task>", + Short: "Delete a task from an item", + Example: " projects items tasks remove 118 019a2b3c-… --yes", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + if !yes { + ok, err := confirm(cmd, fmt.Sprintf("Delete task %s?", args[1])) + if err != nil { + return err + } + if !ok { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Aborted.") + return nil + } + } + if err := source.client.DeleteTask(cmd.Context(), args[0], args[1]); err != nil { + return source.explain(err) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Deleted task %s\n", args[1]) + return nil + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt") + return cmd +} diff --git a/cli/items.go b/cli/items.go new file mode 100644 index 0000000..10718f2 --- /dev/null +++ b/cli/items.go @@ -0,0 +1,463 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "slices" + "strings" + + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/apiclient" +) + +// itemHints and the lists built on it name the commands that find a valid +// reference for each thing an items verb takes. +var ( + itemHints = []string{ + "Search items by title or notes: projects items search <query>", + "Completed and archived items are hidden: projects items list --status all", + } + itemAndProjectHints = append(slices.Clone(itemHints), projectHints...) + itemAndTaskHints = append(slices.Clone(itemHints), "List an item's tasks: projects items tasks list <item>") +) + +func newItemsCommand() *cobra.Command { + cmd := goclikit.AsNamespace(&cobra.Command{ + Use: "items", + Short: "Work with the items that finish a project", + Long: "An item is one step of a project, with tasks, dependencies on other items,\n" + + "and a place in one or more projects. <item> is an item's number.\n" + + "\n" + + "The nouns under items group what an item holds: `tasks`, `dependencies` and\n" + + "`memberships`, each with its own verbs.", + Example: " projects items list every open item, newest first\n" + + " projects items list --project \"Kitchen remodel\" one project's items in order\n" + + " projects items show 42 one item with its tasks and blockers", + }) + goclikit.WithRecoveryHints(cmd, itemHints...) + splitReadingFromChanging(cmd) + cmd.AddGroup(&cobra.Group{ID: groupItems, Title: "What an item holds:"}) + cmd.AddCommand(inGroup(groupReading, + goclikit.WithRecoveryHints(newItemsListCommand(), itemAndProjectHints...), + newItemsBlockedCommand(), + newItemsSearchCommand(), + newItemsShowCommand(), + newItemsBlockersCommand(), + )...) + cmd.AddCommand(inGroup(groupChanging, + goclikit.WithRecoveryHints(newItemsCreateCommand(), itemAndProjectHints...), + newItemsEditCommand(), + newItemStateCommand("complete", "Mark an item completed", "Completed", itemCompleted(true)), + newItemStateCommand("reopen", "Reopen a completed item", "Reopened", itemCompleted(false)), + newItemStateCommand("archive", "Archive an item, which hides it", "Archived", itemArchived(true)), + newItemStateCommand("unarchive", "Restore an archived item", "Restored", itemArchived(false)), + newItemsDeleteCommand(), + goclikit.WithRecoveryHints(newItemsReorderCommand(), itemAndProjectHints...), + )...) + cmd.AddCommand(inGroup(groupItems, + newItemTasksCommand(), + newItemDependenciesCommand(), + newItemMembershipsCommand(), + )...) + return cmd +} + +func newItemsListCommand() *cobra.Command { + var ( + asJSON bool + project string + repo string + itemStatus string + limit int + ) + cmd := &cobra.Command{ + Use: "list", + Short: "List open items, or one project's items in order", + Long: "Open means not completed and not archived. It is the default because\n" + + "completed items accumulate without bound.\n" + + "\n" + + "--status takes one of: " + strings.Join(itemStatuses, ", ") + ". They partition every\n" + + "item, because archived beats completed.\n" + + "\n" + + "--project selects which rows come back, not which states, so it takes the same\n" + + "--status and defaults to open the same way.\n" + + "\n" + + "--since and --until bound when an item was finished. An item never completed has\n" + + "no such date, so pair them with --status completed to read a week's work.\n" + + "\n" + + "--limit caps what the filters left: newest first unscoped, project order under\n" + + "--project.", + Example: " projects items list\n" + + " projects items list --status completed --since 2026-08-17\n" + + " projects items list --status all --json\n" + + " projects items list --repo photo-sorter --limit 10\n" + + " projects items list --repo \"\"\n" + + " projects items list --project \"Kitchen remodel\" --status all", + Args: argumentBelongsTo("--project", "One project's items"), + RunE: func(cmd *cobra.Command, _ []string) error { + if err := validateItemStatus(cmd, itemStatus); err != nil { + return err + } + since, until, err := readWindow(cmd) + if err != nil { + return err + } + if project != "" && cmd.Flags().Changed("repo") { + return goclikit.UsageError(errors.New("--repo and --project are different questions — pass one")) + } + filter, err := repoFlagValue(cmd, repo) + if err != nil { + return err + } + query := apiclient.ItemQuery{Status: itemStatus, Repo: filter, Since: since, Until: until, Limit: limitFlag(cmd)} + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + if project == "" { + items, err := source.client.ListItems(cmd.Context(), query) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), items) + } + printItemsTable(cmd.OutOrStdout(), items) + } else { + items, err := source.client.ListProjectItems(cmd.Context(), project, query) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), items) + } + printProjectItemsTable(cmd.OutOrStdout(), items) + } + hintHiddenItems(cmd, asJSON) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output items as JSON to stdout") + cmd.Flags().StringVar(&project, "project", "", "Only one project's items, in project order (full name or id)") + cmd.Flags().StringVar(&repo, "repo", "", "Only items tagged with this repo (empty string for untagged work)") + cmd.Flags().StringVar(&itemStatus, "status", "", "One of: "+strings.Join(itemStatuses, ", ")+" (default open)") + addWindowFlags(cmd, "items completed") + addLimitFlag(cmd, &limit) + return cmd +} + +func newItemsBlockedCommand() *cobra.Command { + var ( + asJSON bool + repo string + ) + cmd := &cobra.Command{ + Use: "blocked", + Short: "List items with at least one incomplete dependency", + Example: " projects items blocked\n projects items blocked --repo photo-sorter", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + filter, err := repoFlagValue(cmd, repo) + if err != nil { + return err + } + return listItems(cmd, asJSON, func(ctx context.Context, c *apiclient.Client) ([]wire.ProjectItem, error) { + return c.ListBlockedItems(ctx, filter) + }) + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output items as JSON to stdout") + cmd.Flags().StringVar(&repo, "repo", "", "Only items tagged with this repo (empty string for untagged work)") + return cmd +} + +func newItemsSearchCommand() *cobra.Command { + var ( + asJSON bool + repo string + ) + cmd := &cobra.Command{ + Use: "search <query>", + Short: "Search items by title or notes", + Long: "The query matches as one piece of text anywhere in an item's title or notes.", + Example: " projects items search kitchen\n projects items search \"off-site backup\" --repo photo-sorter", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + filter, err := repoFlagValue(cmd, repo) + if err != nil { + return err + } + return listItems(cmd, asJSON, func(ctx context.Context, c *apiclient.Client) ([]wire.ProjectItem, error) { + return c.SearchItems(ctx, args[0], filter) + }) + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output items as JSON to stdout") + cmd.Flags().StringVar(&repo, "repo", "", "Only items tagged with this repo (empty string for untagged work)") + return cmd +} + +func newItemsBlockersCommand() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "blockers <item>", + Short: "List the incomplete dependencies blocking an item", + Example: " projects items blockers 118", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return listItems(cmd, asJSON, func(ctx context.Context, c *apiclient.Client) ([]wire.ProjectItem, error) { + return c.ListBlockers(ctx, args[0]) + }) + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output blockers as JSON to stdout") + return cmd +} + +// listItems is the body of blocked, search and blockers: one read, then JSON or +// a table. +func listItems(cmd *cobra.Command, asJSON bool, read func(context.Context, *apiclient.Client) ([]wire.ProjectItem, error)) error { + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + items, err := read(cmd.Context(), source.client) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), items) + } + printItemsTable(cmd.OutOrStdout(), items) + return nil +} + +// itemShown is `items show --json`: the item's own fields, then its tasks and +// what blocks it. cli/schemas/item-with-tasks-and-blockers.schema.json is its shape. +type itemShown struct { + SchemaVersion int `json:"schema_version"` + wire.ProjectItemDetail + Tasks []wire.ProjectItemTask `json:"tasks"` + Blockers []wire.ProjectItem `json:"blockers"` +} + +const itemShownVersion = 1 + +func newItemsShowCommand() *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: "show <item>", + Short: "Show an item with its projects, dependencies, tasks and blockers", + Long: "\"Depends on\" lists every item this one waits on, marked open (○), completed (✓)\n" + + "or archived (▪). \"Blocked by\" is the ones not yet completed. Under --json they\n" + + "are depends_on and blockers.\n" + + "\n" + + "`projects items dependencies tree <item>` draws the whole graph it sits in.", + Example: " projects items show 118\n projects items show 118 --json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + ctx := cmd.Context() + detail, err := source.client.GetItem(ctx, args[0]) + if err != nil { + return source.explain(err) + } + tasks, err := source.client.ListTasks(ctx, detail.ID) + if err != nil { + return source.explain(err) + } + blockers, err := source.client.ListBlockers(ctx, detail.ID) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), itemShown{ + SchemaVersion: itemShownVersion, + ProjectItemDetail: detail, + Tasks: tasks, + Blockers: blockers, + }) + } + printItemDetail(cmd.OutOrStdout(), detail, tasks, blockers) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output everything as JSON to stdout") + return cmd +} + +func newItemsEditCommand() *cobra.Command { + var ( + title string + notes string + repo string + asJSON bool + ) + cmd := &cobra.Command{ + Use: "edit <item> [flags]", + Short: "Change an item's title, notes or repo", + Long: "Updates only the fields whose flags you pass. --notes \"\" clears the notes, and\n" + + "--repo \"\" unlinks the item from its repo. complete, reopen, archive and\n" + + "unarchive change its state.", + Example: " projects items edit 118 --notes \"$(cat notes.md)\"\n" + + " projects items edit 118 --title \"New title\"\n" + + " projects items edit 118 --repo photo-sorter --json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + edit := apiclient.ItemChanges{} + if f.Changed("title") { + edit.Title = &title + } + if f.Changed("notes") { + edit.Notes, edit.ClearNotes = ¬es, notes == "" + } + if f.Changed("repo") { + if err := validateRepoFlag(cmd, repo); err != nil { + return err + } + edit.Repo, edit.ClearRepo = &repo, repo == "" + } + if edit == (apiclient.ItemChanges{}) { + return goclikit.UsageError(errors.New("nothing to change — pass --title, --notes or --repo")) + } + return runItemUpdate(cmd, args[0], edit, asJSON, "Updated") + }, + } + cmd.Flags().StringVar(&title, "title", "", "New item title") + cmd.Flags().StringVar(¬es, "notes", "", "New markdown notes (empty string clears them)") + cmd.Flags().StringVar(&repo, "repo", "", "Repo this item is work on, by registry name (empty string unlinks)") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the updated item as JSON to stdout") + return cmd +} + +func itemCompleted(completed bool) apiclient.ItemChanges { + return apiclient.ItemChanges{Completed: &completed} +} + +func itemArchived(archived bool) apiclient.ItemChanges { + return apiclient.ItemChanges{Archived: &archived} +} + +// newItemStateCommand is one of complete, reopen, archive and unarchive, each a +// PATCH of one flag. +func newItemStateCommand(verb, short, done string, edit apiclient.ItemChanges) *cobra.Command { + var asJSON bool + cmd := &cobra.Command{ + Use: verb + " <item>", + Short: short, + Example: " projects items " + verb + " 118", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runItemUpdate(cmd, args[0], edit, asJSON, done) + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the updated item as JSON to stdout") + return cmd +} + +func runItemUpdate(cmd *cobra.Command, ref string, edit apiclient.ItemChanges, asJSON bool, done string) error { + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + item, err := source.client.UpdateItem(cmd.Context(), ref, edit) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), item) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s item %q → %s (%s)\n", done, item.Title, item.Status, handle(item.Number, item.ID)) + return nil +} + +func newItemsDeleteCommand() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "delete <item>", + Short: "Delete an item", + Long: "Deletes an item and its tasks for good. Asks to confirm unless --yes.", + Example: " projects items delete 118 --yes", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + item, err := source.client.GetItem(cmd.Context(), args[0]) + if err != nil { + return source.explain(err) + } + if !yes { + ok, err := confirm(cmd, fmt.Sprintf("Delete item %q? This cannot be undone.", item.Title)) + if err != nil { + return err + } + if !ok { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Aborted.") + return nil + } + } + if err := source.client.DeleteItem(cmd.Context(), item.ID); err != nil { + return source.explain(err) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Deleted item %q (%s)\n", item.Title, handle(item.Number, item.ID)) + return nil + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt") + return cmd +} + +func newItemsReorderCommand() *cobra.Command { + var ( + project string + position int + asJSON bool + ) + cmd := &cobra.Command{ + Use: "reorder <item> --project <project> --position <n>", + Short: "Move an item to a new position within a project", + Example: " projects items reorder 118 --project \"Kitchen remodel\" --position 2", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if project == "" { + return goclikit.UsageError(errors.New("--project is required")) + } + if !cmd.Flags().Changed("position") { + return goclikit.UsageError(errors.New("--position is required")) + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + item, err := source.client.ReorderItem(cmd.Context(), args[0], project, position) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), item) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Moved item %q to position %d (%s)\n", item.Title, item.Position, handle(item.Number, item.ID)) + return nil + }, + } + cmd.Flags().StringVar(&project, "project", "", "Project name or id the item moves within (required)") + cmd.Flags().IntVar(&position, "position", 0, "New position (required)") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the item as JSON to stdout") + return cmd +} diff --git a/cli/projects.go b/cli/projects.go new file mode 100644 index 0000000..79fdbb6 --- /dev/null +++ b/cli/projects.go @@ -0,0 +1,399 @@ +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/apiclient" + "github.com/datapointchris/projects/model" +) + +func newProjectsListCommand() *cobra.Command { + var ( + asJSON bool + repo string + projectStatus string + limit int + ) + cmd := &cobra.Command{ + Use: "list", + Short: "List the active projects", + Long: "The active projects, with the repos their items touch. --repo narrows to the\n" + + "projects holding work on one repo. Shelved, completed and dropped projects are\n" + + "hidden until --status asks for them.\n" + + "\n" + + "--limit caps what the filters left, so it takes the first projects in\n" + + "position order rather than filtering a capped slice.", + Example: " projects list\n projects list --repo photo-sorter\n" + + " projects list --limit 5\n" + + " projects list --status completed\n projects list --status all --json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := validateStatus(cmd, projectStatus, projectStatuses); err != nil { + return err + } + filter, err := repoFlagValue(cmd, repo) + if err != nil { + return err + } + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + projects, err := source.client.ListProjects(cmd.Context(), + apiclient.ProjectQuery{Status: projectStatus, Repo: filter, Limit: limitFlag(cmd)}) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), projects) + } + // "No projects yet, create one" is true of an empty database and false of a + // filter that matched nothing. + out := cmd.OutOrStdout() + switch { + case len(projects) == 0 && filter != nil: + _, _ = fmt.Fprintf(out, "No projects hold work on repo %s.\n", *filter) + return nil + case len(projects) == 0 && projectStatus != "" && projectStatus != model.StatusAll: + _, _ = fmt.Fprintf(out, "No %s projects.\n", projectStatus) + return nil + } + // The status column earns its width only where the rows can differ in it. + printProjectsTable(out, projects, projectStatus != "") + if projectStatus == "" { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nShelved, completed and dropped projects are hidden: projects list --status all") + } + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output projects as JSON to stdout") + cmd.Flags().StringVar(&repo, "repo", "", "Only projects holding work on this repo") + cmd.Flags().StringVar(&projectStatus, "status", "", "One of: "+strings.Join(projectStatuses, ", ")+" (default active)") + addLimitFlag(cmd, &limit) + return cmd +} + +func newProjectsShowCommand() *cobra.Command { + var ( + asJSON bool + itemStatus string + ) + cmd := &cobra.Command{ + Use: "show <project>", + Short: "Show a project and its open items", + Example: " projects show \"Kitchen remodel\"\n projects show \"Kitchen remodel\" --status all --json", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateItemStatus(cmd, itemStatus); err != nil { + return err + } + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + project, err := source.client.GetProject(cmd.Context(), args[0]) + if err != nil { + return source.explain(err) + } + items, err := source.client.ListProjectItems(cmd.Context(), args[0], apiclient.ItemQuery{Status: itemStatus}) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), projectShown{ + SchemaVersion: projectShownVersion, + ProjectWithItemCount: project, + Items: items, + }) + } + printProjectDetail(cmd.OutOrStdout(), project, items) + hintHiddenItems(cmd, asJSON) + return nil + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the project and items as JSON to stdout") + cmd.Flags().StringVar(&itemStatus, "status", "", "One of: "+strings.Join(itemStatuses, ", ")+" (default open)") + return cmd +} + +// projectShown is `projects show --json`: the project's own fields, then its items. +// cli/schemas/project-with-items.schema.json is its shape. +type projectShown struct { + SchemaVersion int `json:"schema_version"` + wire.ProjectWithItemCount + Items []wire.ProjectItemInProject `json:"items"` +} + +const projectShownVersion = 1 + +func newProjectsCreateCommand() *cobra.Command { + var ( + name string + description string + kind string + position int + asJSON bool + ) + cmd := &cobra.Command{ + Use: "create --name <name> [flags]", + Short: "Create a new project", + Long: "A project names bounded work, something that ends. With no api in the config\n" + + "file, a name the repo registry knows is refused, because a repo never ends:\n" + + "tag the items with --repo instead.", + Example: " projects create --name \"Kitchen remodel\"\n" + + " projects create --name \"Sell unused gear\" --kind chore\n" + + " projects create --name \"Learn to sail\" --description \"$(cat plan.md)\" --json", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if name == "" { + return goclikit.UsageError(errors.New("--name is required")) + } + input := apiclient.NewProject{Name: name} + if cmd.Flags().Changed("description") { + input.Description = &description + } + if cmd.Flags().Changed("kind") { + input.Kind = &kind + } + if cmd.Flags().Changed("position") { + input.Position = &position + } + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + project, err := source.client.CreateProject(cmd.Context(), input) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), project) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Created project %q (%s)\n", project.Name, project.ID) + return nil + }, + } + cmd.Flags().StringVar(&name, "name", "", "Project name (required)") + cmd.Flags().StringVar(&description, "description", "", "Project description") + cmd.Flags().StringVar(&kind, "kind", "", "What sort of work this is: "+strings.Join(model.ProjectKinds, ", ")+" (default "+model.KindLife+")") + cmd.Flags().IntVar(&position, "position", 0, "Sort position among projects") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the created project as JSON to stdout") + return cmd +} + +func newProjectsEditCommand() *cobra.Command { + var ( + name string + description string + kind string + position int + asJSON bool + ) + cmd := &cobra.Command{ + Use: "edit <project> [flags]", + Short: "Change fields on an existing project", + Long: "Updates only the fields whose flags you pass. --description \"\" clears the\n" + + "description. A status is changed by shelve, complete, close and reopen.", + Example: " projects edit \"Kitchen remodel\" --name \"Bathroom remodel\" --position 2\n" + + " projects edit \"Kitchen remodel\" --kind chore", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + f := cmd.Flags() + edit := apiclient.ProjectChanges{} + if f.Changed("name") { + edit.Name = &name + } + if f.Changed("description") { + edit.Description = &description + edit.ClearDescription = description == "" + } + if f.Changed("kind") { + edit.Kind = &kind + } + if f.Changed("position") { + edit.Position = &position + } + if edit == (apiclient.ProjectChanges{}) { + return goclikit.UsageError(errors.New("nothing to change — pass at least one of --name, --description, --kind, --position")) + } + return runProjectUpdate(cmd, args[0], edit, asJSON, "Updated") + }, + } + cmd.Flags().StringVar(&name, "name", "", "New project name") + cmd.Flags().StringVar(&description, "description", "", "New project description") + cmd.Flags().StringVar(&kind, "kind", "", "New kind: "+strings.Join(model.ProjectKinds, ", ")) + cmd.Flags().IntVar(&position, "position", 0, "New sort position") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the updated project as JSON to stdout") + return cmd +} + +// The four status verbs are each a PATCH of status, so one write path validates +// every transition and derives closed_at from it. +func newProjectsShelveCommand() *cobra.Command { + return newProjectStatusCommand(statusVerb{ + use: "shelve <project>", + short: "Set a project aside for someday, which hides it", + long: "For a project you still mean to do, just not now. It moves to --status\n" + + "someday, out of the active list, and no close is recorded. Its items keep\n" + + "their state. One in no other active project leaves `projects next` until this\n" + + "one returns. It also gives up its name, so a new project may take it, and\n" + + "reopening this one is then refused until one of the two is renamed.", + example: " projects shelve \"Garden beds\"", + status: model.StatusSomeday, + done: "Shelved", + }) +} + +func newProjectsCompleteCommand() *cobra.Command { + return newProjectStatusCommand(statusVerb{ + use: "complete <project>", + short: "Mark a project finished, which hides it", + long: "A project is a finite effort with a definition of done, so completing it is\n" + + "what takes it out of the list. Its items are left exactly as they are: an item\n" + + "still open when the project finished was still open, and that is worth knowing.", + example: " projects complete \"Kitchen remodel\"", + status: model.StatusCompleted, + done: "Completed", + }) +} + +func newProjectsCloseCommand() *cobra.Command { + return newProjectStatusCommand(statusVerb{ + use: "close <project> --reason <why>", + short: "Close a project you are not going to do", + long: "For a project that ends without being finished. Its status becomes dropped.\n" + + "--reason is required: \"deferred\" invites the same idea back next month, and\n" + + "closed-and-here-is-why settles it.", + example: " projects close \"Rewrite in Rust\" --reason \"Go covers it and I know Go\"", + status: model.StatusDropped, + done: "Closed", + reason: true, + }) +} + +func newProjectsReopenCommand() *cobra.Command { + return newProjectStatusCommand(statusVerb{ + use: "reopen <project>", + short: "Return a shelved or closed project to the active list", + long: "Clears the closing reason and date along with the status. Refused if an\n" + + "active project has taken the name in the meantime, because only the live\n" + + "project holds a name.", + example: " projects reopen \"Kitchen remodel\"", + status: model.StatusActive, + done: "Reopened", + }) +} + +type statusVerb struct { + use, short, long, example string + status, done string + reason bool +} + +func newProjectStatusCommand(verb statusVerb) *cobra.Command { + var ( + reason string + asJSON bool + ) + cmd := &cobra.Command{ + Use: verb.use, + Short: verb.short, + Long: verb.long, + Example: verb.example, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + status := verb.status + edit := apiclient.ProjectChanges{Status: &status, ClearStatusReason: true} + if verb.reason { + if strings.TrimSpace(reason) == "" { + return goclikit.UsageError(errors.New("--reason is required — say why it is closed rather than shelved")) + } + edit.StatusReason, edit.ClearStatusReason = &reason, false + } + return runProjectUpdate(cmd, args[0], edit, asJSON, verb.done) + }, + } + if verb.reason { + cmd.Flags().StringVar(&reason, "reason", "", "Why it is closed rather than shelved (required)") + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the updated project as JSON to stdout") + return cmd +} + +func runProjectUpdate(cmd *cobra.Command, ref string, edit apiclient.ProjectChanges, asJSON bool, done string) error { + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + project, err := source.client.UpdateProject(cmd.Context(), ref, edit) + if err != nil { + return source.explain(err) + } + if asJSON { + return encodeJSON(cmd.OutOrStdout(), project) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s project %q (%s)\n", done, project.Name, project.ID) + return nil +} + +func newProjectsDeleteCommand() *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: "delete <project>", + Short: "Delete a project", + Long: "Deletes a project for good. Completed items in no other project go with it.\n" + + "An open item that would be left in no project refuses the delete, so move it\n" + + "first. Asks to confirm unless --yes.", + Example: " projects delete \"Kitchen remodel\"\n projects delete \"Kitchen remodel\" --yes", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + // Read first, so the question names the project it destroys. + project, err := source.client.GetProject(cmd.Context(), args[0]) + if err != nil { + return source.explain(err) + } + if !yes { + question := fmt.Sprintf("Delete project %q (%d items)? This cannot be undone.", project.Name, project.ItemCount) + ok, err := confirm(cmd, question) + if err != nil { + return err + } + if !ok { + _, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Aborted.") + return nil + } + } + if err := source.client.DeleteProject(cmd.Context(), project.ID); err != nil { + return source.explain(err) + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Deleted project %q (%s)\n", project.Name, project.ID) + return nil + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip the confirmation prompt") + return cmd +} + +// encodeJSON writes v as indented JSON, the shape every --json prints. +func encodeJSON(out io.Writer, v any) error { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/cli/root.go b/cli/root.go new file mode 100644 index 0000000..670498a --- /dev/null +++ b/cli/root.go @@ -0,0 +1,174 @@ +// Package cli is the projects command tree. Every command reaches its data through +// the API's client: over the network when the config names an API, and through the +// API's own routes served inside this process when it does not. +package cli + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/datapointchris/goclikit" + "github.com/datapointchris/goselfupdate/autoupdate" + "github.com/datapointchris/gotermstyle/cobrahelp" + "github.com/spf13/cobra" +) + +// noInput is bound to --no-input, which forces the non-interactive path from a +// terminal. pflag resets it on each NewRootCommand, so a test never inherits it. +var noInput bool + +// apiOverride is bound to --api, the one value that outranks the config file. +var apiOverride string + +// withAPIFlag declares --api on cmd and everything under it. Only the commands +// that reach data or log in carry it. +func withAPIFlag(cmd *cobra.Command) *cobra.Command { + cmd.PersistentFlags().StringVar(&apiOverride, "api", "", + "Use this API instead of the config file's; it must trust the file's login server") + return cmd +} + +// The root's help sections. +const ( + groupReading = "reading" + groupChanging = "changing" + groupItems = "items" + groupScreen = "screen" + groupSetup = "setup" +) + +// splitReadingFromChanging gives a namespace the two sections its verbs go under, +// so the half that writes is visible without reading each Short. +func splitReadingFromChanging(cmd *cobra.Command) { + cmd.AddGroup( + &cobra.Group{ID: groupReading, Title: "Reading commands:"}, + &cobra.Group{ID: groupChanging, Title: "Changing commands:"}, + ) +} + +func inGroup(id string, cmds ...*cobra.Command) []*cobra.Command { + for _, cmd := range cmds { + cmd.GroupID = id + } + return cmds +} + +// exitCode chooses the process's exit code with nothing printed, for a status +// command reporting a valid state that is not success. +type exitCode int + +func (e exitCode) Error() string { return "" } + +// statusNoAnswer is the one failure where the same command run later can succeed: +// the API was not reached, timed out, or answered 429 or 5xx. +const statusNoAnswer = 4 + +// projectHints is the command that finds a valid project name. A name resolves +// whatever the project's status, and the API names the projects near a miss. +var projectHints = []string{ + "A project is named in full or by its id; every project: projects list --status all", +} + +func NewRootCommand() *cobra.Command { + root := goclikit.AsNamespace(&cobra.Command{ + Use: "projects", + Short: "Projects, the items that finish them, and the order to take them in", + Long: "A project is bounded work, and its items are the steps that finish it. Each item\n" + + "has tasks, dependencies on other items, and a place in one or more projects.\n" + + "\n" + + "The project verbs sit at the root, and the item verbs under `projects items`:\n" + + "`projects list` becomes `projects items list` by adding the noun. Every read\n" + + "takes --json. `projects tui` opens the board, where everything here can also be\n" + + "done by key. Run any partial command bare to see what comes next.\n" + + "\n" + + "Where the data lives is the config file's choice. With an api key there, every\n" + + "command reads and writes that API, after `projects auth login <url>`. With none,\n" + + "they read and write a database on this machine, and need no server.", + Example: " projects next --limit 1\n" + + " projects show \"Kitchen remodel\"\n" + + " projects items create --project \"Kitchen remodel\" --title \"Get three quotes\"\n" + + " projects items show 42\n" + + " projects tui", + Version: Version(), + SilenceUsage: true, + SilenceErrors: true, + // An unknown word reaches the namespace's own refusal, which exits 2, rather + // than cobra's root validator, which exits 1. + Args: cobra.ArbitraryArgs, + }) + root.SetFlagErrorFunc(func(_ *cobra.Command, err error) error { + return goclikit.UsageError(err) + }) + cobrahelp.Install(root) + goclikit.WithRecoveryHints(root, projectHints...) + + root.PersistentFlags().BoolVar(&noInput, "no-input", false, + "Never prompt; fail naming the flag that would have answered") + + // -v stays free for a verbose flag, so --version keeps no shorthand. + root.InitDefaultVersionFlag() + if f := root.Flags().Lookup("version"); f != nil { + f.Shorthand = "" + } + + root.AddGroup( + &cobra.Group{ID: groupReading, Title: "Reading projects:"}, + &cobra.Group{ID: groupChanging, Title: "Changing projects:"}, + &cobra.Group{ID: groupItems, Title: "Items:"}, + &cobra.Group{ID: groupScreen, Title: "The board:"}, + &cobra.Group{ID: groupSetup, Title: "Setup:"}, + ) + root.SetCompletionCommandGroupID(groupSetup) + root.AddCommand(inGroup(groupReading, + withAPIFlag(newProjectsListCommand()), + withAPIFlag(newProjectsNextCommand()), + withAPIFlag(newProjectsShowCommand()), + )...) + root.AddCommand(inGroup(groupChanging, + withAPIFlag(newProjectsCreateCommand()), + withAPIFlag(newProjectsEditCommand()), + withAPIFlag(newProjectsShelveCommand()), + withAPIFlag(newProjectsCompleteCommand()), + withAPIFlag(newProjectsCloseCommand()), + withAPIFlag(newProjectsReopenCommand()), + withAPIFlag(newProjectsDeleteCommand()), + )...) + root.AddCommand(inGroup(groupItems, withAPIFlag(newItemsCommand()))...) + root.AddCommand(inGroup(groupScreen, withAPIFlag(newTUICommand()))...) + root.AddCommand(inGroup(groupSetup, newAuthCommand(), newSyncCommand(), VersionCmd(), UpdateCmd())...) + return root +} + +// Execute runs the command tree, prints any error to stderr, and returns the +// process exit code. +func Execute() int { + err := goclikit.Execute(context.Background(), NewRootCommand(), autoupdate.Config{Update: UpdateConfig()}, + goclikit.WithNotFound(notFound)) + var code exitCode + if err != nil && !errors.As(err, &code) && !errors.Is(err, goclikit.ErrReported) { + _, _ = fmt.Fprintln(os.Stderr, "error:", err) + } + return exitCodeFor(err) +} + +// exitCodeFor is 0 for success, 2 for a usage mistake, 4 where no answer came, +// and 1 for anything the API or the command refused. +func exitCodeFor(err error) int { + if err == nil { + return 0 + } + var code exitCode + if errors.As(err, &code) { + return int(code) + } + if errors.Is(err, goclikit.ErrUsage) { + return 2 + } + var silent noAnswerError + if errors.As(err, &silent) { + return statusNoAnswer + } + return 1 +} diff --git a/cli/schemas/auth-status.schema.json b/cli/schemas/auth-status.schema.json new file mode 100644 index 0000000..3749baa --- /dev/null +++ b/cli/schemas/auth-status.schema.json @@ -0,0 +1,63 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:auth-status:v1", + "title": "auth-status", + "description": "`projects auth status --json`: where API mode points and whether this machine holds a credential for it. It reports what the machine holds, not what a token claims.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "logged_in", + "configured", + "config_file", + "type", + "api", + "client_id", + "issuer", + "expires_at", + "expired", + "session", + "backend", + "keyring_note" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "The version of this document's shape. A field added keeps it, and a field reshaped or removed raises it." + }, + "logged_in": { + "type": "boolean", + "description": "A token is stored for this machine's client. For a service or a handed token, a token was granted just now." + }, + "configured": { + "type": "boolean", + "description": "The config file sets every value its credential type needs." + }, + "config_file": { "type": "string" }, + "type": { + "type": "string", + "enum": ["authorized_user", "service_account", "external_account"], + "description": "A person's device login, the client-credentials grant PROJECTS_CLIENT_SECRET selects, or a token token_command hands." + }, + "api": { "type": "string", "description": "Empty where the config file sets none." }, + "client_id": { "type": "string" }, + "issuer": { "type": "string" }, + "expires_at": { "type": ["string", "null"], "format": "date-time" }, + "expired": { "type": "boolean" }, + "session": { + "type": ["string", "null"], + "enum": ["live", "rejected", "unverified", null], + "description": "What asking the provider for a token found. Null where nothing was asked." + }, + "backend": { + "type": ["string", "null"], + "enum": ["OS keyring", "file", null], + "description": "Where the token is kept. Null where none is stored." + }, + "keyring_note": { + "type": ["string", "null"], + "description": "The keyring's own failure, where it is why no token was found." + } + } +} diff --git a/cli/schemas/board-snapshot.schema.json b/cli/schemas/board-snapshot.schema.json new file mode 100644 index 0000000..b9b3a65 --- /dev/null +++ b/cli/schemas/board-snapshot.schema.json @@ -0,0 +1,217 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:board-snapshot:v1", + "title": "board-snapshot", + "description": "`projects tui --json`: what the board shows as it opens. The project pane lists the active projects, and the item pane the open items of the project under the cursor.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "view", + "projects", + "project", + "items" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "The version of this document's shape. A field added keeps it, and a field reshaped or removed raises it." + }, + "view": { + "type": "string", + "enum": [ + "projects" + ], + "description": "The tab the snapshot is of." + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/project-with-item-count" + } + }, + "project": { + "description": "The project under the cursor, or null with no projects.", + "anyOf": [ + { + "$ref": "#/$defs/project-with-item-count" + }, + { + "type": "null" + } + ] + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/project-item-in-project" + } + } + }, + "$defs": { + "project-with-item-count": { + "type": "object", + "description": "A project and what its items add up to. A field added is not a new version, and a field reshaped or removed is.", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at", + "item_count", + "open_count", + "completed_count", + "repos" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "item_count": { + "type": "integer", + "description": "Every membership, archived items included." + }, + "open_count": { + "type": "integer" + }, + "completed_count": { + "type": "integer" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "project-item-in-project": { + "type": "object", + "description": "An item as one project holds it. A field added is not a new version, and a field reshaped or removed is.", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "position", + "project_count" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "project_count": { + "type": "integer", + "description": "How many projects hold the item." + } + } + } + } +} diff --git a/cli/schemas/dependency-tree.schema.json b/cli/schemas/dependency-tree.schema.json new file mode 100644 index 0000000..5399199 --- /dev/null +++ b/cli/schemas/dependency-tree.schema.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:dependency-tree:v1", + "title": "dependency-tree", + "description": "`projects items dependencies tree --json`: the items the trees draw and the edges between them, never the drawing. An edge runs from an item to what it depends on whichever way the drawing ran, and only roots follows --invert.", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "nodes", "edges", "roots"], + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "The version of this document's shape. A field added keeps it, and a field reshaped or removed raises it." + }, + "nodes": { + "type": "array", + "items": { "$ref": "#/$defs/node" } + }, + "edges": { + "type": "array", + "items": { "$ref": "#/$defs/edge" } + }, + "roots": { + "type": "array", + "description": "The id of each item a tree is drawn from.", + "items": { "type": "string", "format": "uuid" } + } + }, + "$defs": { + "node": { + "type": "object", + "additionalProperties": false, + "required": ["id", "number", "title", "status", "repo", "projects"], + "properties": { + "id": { "type": "string", "format": "uuid" }, + "number": { + "type": ["integer", "null"], + "description": "The item's handle. Null only on a synced database whose API has not assigned one yet." + }, + "title": { "type": "string" }, + "status": { "type": "string", "enum": ["open", "completed", "archived"] }, + "repo": { "type": ["string", "null"] }, + "projects": { + "type": "array", + "description": "The name of each project the item is in.", + "items": { "type": "string" } + } + } + }, + "edge": { + "type": "object", + "additionalProperties": false, + "required": ["item", "depends_on"], + "properties": { + "item": { "type": "string", "format": "uuid" }, + "depends_on": { "type": "string", "format": "uuid" } + } + } + } +} diff --git a/cli/schemas/focus-queue.schema.json b/cli/schemas/focus-queue.schema.json new file mode 100644 index 0000000..56bcfc8 --- /dev/null +++ b/cli/schemas/focus-queue.schema.json @@ -0,0 +1,278 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:focus-queue:v1", + "title": "focus-queue", + "description": "`projects next --json`: the active project in focus and its open items in the order to take them. next and blocked are capped by --limit, and the totals count every one.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "project", + "next", + "next_total", + "blocked", + "blocked_total" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "The version of this document's shape. A field added keeps it, and a field reshaped or removed raises it." + }, + "project": { + "description": "The project in focus, or null when no active project holds an open item.", + "anyOf": [ + { + "$ref": "#/$defs/project" + }, + { + "type": "null" + } + ] + }, + "next": { + "type": "array", + "items": { + "$ref": "#/$defs/project-item" + } + }, + "next_total": { + "type": "integer", + "minimum": 0 + }, + "blocked": { + "type": "array", + "items": { + "$ref": "#/$defs/project-item" + } + }, + "blocked_total": { + "type": "integer", + "minimum": 0 + } + }, + "$defs": { + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "membership": { + "type": "object", + "additionalProperties": false, + "required": [ + "project_id", + "position" + ], + "properties": { + "project_id": { + "type": "string", + "format": "uuid" + }, + "position": { + "type": "integer" + } + } + }, + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "item_id", + "title", + "completed", + "completed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "item_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "project-item": { + "type": "object", + "description": "An item with what is filed against it, as a list returns it. A field added is not a new version, and a field reshaped or removed is.", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "projects", + "memberships", + "dependency_ids", + "tasks" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/project" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/$defs/membership" + } + }, + "dependency_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/task" + } + } + } + } + } +} diff --git a/cli/schemas/item-with-tasks-and-blockers.schema.json b/cli/schemas/item-with-tasks-and-blockers.schema.json new file mode 100644 index 0000000..b5d8d62 --- /dev/null +++ b/cli/schemas/item-with-tasks-and-blockers.schema.json @@ -0,0 +1,393 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:item-with-tasks-and-blockers:v1", + "title": "item-with-tasks-and-blockers", + "description": "`projects items show --json`: one item's fields with what it depends on, then its tasks and the open items blocking it.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "projects", + "memberships", + "dependency_ids", + "depends_on", + "tasks", + "blockers" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "The version of this document's shape. A field added keeps it, and a field reshaped or removed raises it." + }, + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/project" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/$defs/membership" + } + }, + "dependency_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "depends_on": { + "type": "array", + "items": { + "$ref": "#/$defs/summary" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/task" + } + }, + "blockers": { + "type": "array", + "items": { + "$ref": "#/$defs/project-item" + } + } + }, + "$defs": { + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "membership": { + "type": "object", + "additionalProperties": false, + "required": [ + "project_id", + "position" + ], + "properties": { + "project_id": { + "type": "string", + "format": "uuid" + }, + "position": { + "type": "integer" + } + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "completed", + "archived", + "status" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + } + } + }, + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "item_id", + "title", + "completed", + "completed_at", + "position", + "created_at" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "item_id": { + "type": "string", + "format": "uuid" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } + }, + "project-item": { + "type": "object", + "description": "An item with what is filed against it, as a list returns it. A field added is not a new version, and a field reshaped or removed is.", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "projects", + "memberships", + "dependency_ids", + "tasks" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "projects": { + "type": "array", + "items": { + "$ref": "#/$defs/project" + } + }, + "memberships": { + "type": "array", + "items": { + "$ref": "#/$defs/membership" + } + }, + "dependency_ids": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "tasks": { + "type": "array", + "items": { + "$ref": "#/$defs/task" + } + } + } + } + } +} diff --git a/cli/schemas/project-with-items.schema.json b/cli/schemas/project-with-items.schema.json new file mode 100644 index 0000000..79100b0 --- /dev/null +++ b/cli/schemas/project-with-items.schema.json @@ -0,0 +1,181 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:projects:project-with-items:v1", + "title": "project-with-items", + "description": "`projects show --json`: one project's fields with its item counts and repos, then its items in project order.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "id", + "name", + "description", + "kind", + "status", + "status_reason", + "closed_at", + "position", + "created_at", + "item_count", + "open_count", + "completed_count", + "repos", + "items" + ], + "properties": { + "schema_version": { + "type": "integer", + "const": 1, + "description": "The version of this document's shape. A field added keeps it, and a field reshaped or removed raises it." + }, + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "type": "string", + "description": "One of the names in the project_kinds table." + }, + "status": { + "type": "string", + "description": "One of the names in the project_statuses table." + }, + "status_reason": { + "type": [ + "string", + "null" + ] + }, + "closed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "item_count": { + "type": "integer", + "description": "Every membership, archived items included." + }, + "open_count": { + "type": "integer" + }, + "completed_count": { + "type": "integer" + }, + "repos": { + "type": "array", + "items": { + "type": "string" + } + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/project-item-in-project" + } + } + }, + "$defs": { + "project-item-in-project": { + "type": "object", + "description": "An item as one project holds it. A field added is not a new version, and a field reshaped or removed is.", + "additionalProperties": false, + "required": [ + "id", + "number", + "title", + "notes", + "repo", + "completed", + "completed_at", + "archived", + "status", + "created_at", + "updated_at", + "position", + "project_count" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "number": { + "type": [ + "integer", + "null" + ], + "description": "The item's handle. Null only on a store whose server has not assigned one yet." + }, + "title": { + "type": "string" + }, + "notes": { + "type": [ + "string", + "null" + ] + }, + "repo": { + "type": [ + "string", + "null" + ] + }, + "completed": { + "type": "boolean" + }, + "completed_at": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "archived": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "open", + "completed", + "archived" + ] + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "position": { + "type": "integer" + }, + "project_count": { + "type": "integer", + "description": "How many projects hold the item." + } + } + } + } +} diff --git a/cli/sync.go b/cli/sync.go new file mode 100644 index 0000000..2a058f7 --- /dev/null +++ b/cli/sync.go @@ -0,0 +1,72 @@ +package cli + +import ( + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/datapointchris/projects/config" + "github.com/datapointchris/projects/sync" +) + +func newSyncCommand() *cobra.Command { + var adoptOrigin, forceSweep bool + cmd := &cobra.Command{ + Use: "sync", + Short: "Reconcile a synced database with its API now", + Long: "For a database with [sync] enabled = true in the config file. `projects tui` on\n" + + "such a database pushes each edit as it is made and pulls on every interval, so\n" + + "this is for when the API's state is wanted now: it pushes anything queued, then\n" + + "pulls.\n" + + "\n" + + "A pull deletes everything the server did not return, so the database is bound to\n" + + "the API it was first pulled from and refuses to reconcile against any other.\n" + + "--adopt rebinds it; --force allows a pull that would delete most of what is here.\n" + + "\n" + + "With api in the config file there is no copy to reconcile, and this refuses.", + Example: " projects sync push queued edits, then pull from the API now\n" + + " projects sync --adopt bind this database to the configured API, then sync\n" + + " projects sync --force sync even when the pull would delete most local rows", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if err := config.RefuseConfigUnderFormerName(); err != nil { + return err + } + cfg, err := config.Load() + if err != nil { + return err + } + if cfg.API != "" { + return fmt.Errorf("api in %s sends every command to %s, so there is no database copy to sync", + homeAsTilde(configFilePath()), cfg.API) + } + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + if source.engine == nil { + return errors.New("sync is disabled — enable it under [sync] in " + homeAsTilde(configFilePath())) + } + // Named on the way in: --adopt rewrites which API owns this database, + // and doing that silently is the mistake the binding exists to prevent. + if adoptOrigin { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Binding %s to %s\n", source.cfg.Local.DBPath, source.engine.APIURL()) + } + if err := source.engine.PullWith(cmd.Context(), sync.PullOptions{ + Adopt: adoptOrigin, + AllowLargeSweep: forceSweep, + }); err != nil { + return err + } + _, _ = fmt.Fprintln(cmd.OutOrStdout(), "Synced.") + return nil + }, + } + cmd.Flags().BoolVar(&adoptOrigin, "adopt", false, + "bind this database to the configured API, replacing whatever it was bound to") + cmd.Flags().BoolVar(&forceSweep, "force", false, + "allow a pull that would delete most of the local projects or items") + return cmd +} diff --git a/cli/tables.go b/cli/tables.go new file mode 100644 index 0000000..ef1bd59 --- /dev/null +++ b/cli/tables.go @@ -0,0 +1,214 @@ +package cli + +import ( + "fmt" + "io" + "slices" + "strconv" + "strings" + "text/tabwriter" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/model" +) + +func newTable(out io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) +} + +func printProjectsTable(out io.Writer, projects []wire.ProjectWithItemCount, withStatus bool) { + if len(projects) == 0 { + _, _ = fmt.Fprintln(out, "No projects yet. Create one with `projects create --name ...`.") + return + } + tw := newTable(out) + header := []string{"ID", "NAME", "KIND", "OPEN", "DONE", "ITEMS", "POS", "REPOS"} + if withStatus { + header = slices.Insert(header, 3, "STATUS") + } + _, _ = fmt.Fprintln(tw, strings.Join(header, "\t")) + for _, p := range projects { + fields := []string{p.ID, p.Name, p.Kind} + if withStatus { + fields = append(fields, p.Status) + } + fields = append(fields, strconv.Itoa(p.OpenCount), strconv.Itoa(p.CompletedCount), strconv.Itoa(p.ItemCount), + strconv.Itoa(p.Position), repoList(p.Repos)) + _, _ = fmt.Fprintln(tw, strings.Join(fields, "\t")) + } + _ = tw.Flush() +} + +func printProjectDetail(out io.Writer, p wire.ProjectWithItemCount, items []wire.ProjectItemInProject) { + _, _ = fmt.Fprintf(out, "%s\n", p.Name) + _, _ = fmt.Fprintf(out, " id: %s\n", p.ID) + if d := text(p.Description); d != "" { + _, _ = fmt.Fprintf(out, " desc: %s\n", d) + } + _, _ = fmt.Fprintf(out, " kind: %s\n", p.Kind) + _, _ = fmt.Fprintf(out, " status: %s%s\n", p.Status, closedSuffix(p.Project)) + if reason := text(p.StatusReason); reason != "" { + _, _ = fmt.Fprintf(out, " reason: %s\n", reason) + } + _, _ = fmt.Fprintf(out, " position: %d\n", p.Position) + _, _ = fmt.Fprintf(out, " repos: %s\n", repoList(p.Repos)) + _, _ = fmt.Fprintf(out, " items: %d (%d open, %d done)\n", p.ItemCount, p.OpenCount, p.CompletedCount) + + _, _ = fmt.Fprintf(out, "\nItems (%d):\n", len(items)) + if len(items) == 0 { + _, _ = fmt.Fprintln(out, " (none)") + return + } + printProjectItemsTable(out, items) +} + +func printProjectItemsTable(out io.Writer, items []wire.ProjectItemInProject) { + if len(items) == 0 { + _, _ = fmt.Fprintln(out, "No items.") + return + } + tw := newTable(out) + _, _ = fmt.Fprintln(tw, " #\tSTATUS\tTITLE\tID") + for _, it := range items { + _, _ = fmt.Fprintf(tw, " %d\t%s\t%s\t%s\n", it.Position, it.Status, it.Title, handle(it.Number, it.ID)) + } + _ = tw.Flush() +} + +func printItemsTable(out io.Writer, items []wire.ProjectItem) { + if len(items) == 0 { + _, _ = fmt.Fprintln(out, "No items.") + return + } + tw := newTable(out) + _, _ = fmt.Fprintln(tw, "STATUS\tTITLE\tID") + for _, it := range items { + _, _ = fmt.Fprintf(tw, "%s\t%s\t%s\n", it.Status, it.Title, handle(it.Number, it.ID)) + } + _ = tw.Flush() +} + +// printQueueTable is the focus project's queue. Every row is open and in the one +// project named above it, so neither a status nor a project column is printed. +func printQueueTable(out io.Writer, items []wire.ProjectItem) { + tw := newTable(out) + _, _ = fmt.Fprintln(tw, "TITLE\tID") + for _, it := range items { + _, _ = fmt.Fprintf(tw, "%s\t%s\n", it.Title, handle(it.Number, it.ID)) + } + _ = tw.Flush() +} + +func printItemDetail(out io.Writer, d wire.ProjectItemDetail, tasks []wire.ProjectItemTask, blockers []wire.ProjectItem) { + _, _ = fmt.Fprintf(out, "%s\n", d.Title) + _, _ = fmt.Fprintf(out, " id: %s\n", handle(d.Number, d.ID)) + _, _ = fmt.Fprintf(out, " status: %s\n", d.Status) + if r := text(d.Repo); r != "" { + _, _ = fmt.Fprintf(out, " repo: %s\n", r) + } + if n := text(d.Notes); n != "" { + _, _ = fmt.Fprintf(out, " notes: %s\n", n) + } + names := make([]string, 0, len(d.Projects)) + for _, p := range d.Projects { + names = append(names, p.Name) + } + _, _ = fmt.Fprintf(out, " projects: %s\n", orNone(strings.Join(names, ", "))) + + if len(d.DependsOn) > 0 { + _, _ = fmt.Fprintf(out, "\nDepends on (%d):\n", len(d.DependsOn)) + for _, dependency := range d.DependsOn { + _, _ = fmt.Fprintf(out, " %s %s %s\n", statusMark(dependency.Status), handle(dependency.Number, dependency.ID), dependency.Title) + } + } + + _, _ = fmt.Fprintf(out, "\nTasks (%d):\n", len(tasks)) + if len(tasks) == 0 { + _, _ = fmt.Fprintln(out, " (none)") + } else { + printTasksTable(out, tasks) + } + + if len(blockers) > 0 { + _, _ = fmt.Fprintf(out, "\nBlocked by (%d):\n", len(blockers)) + for _, b := range blockers { + _, _ = fmt.Fprintf(out, " %s %s\n", handle(b.Number, b.ID), b.Title) + } + } +} + +func printTasksTable(out io.Writer, tasks []wire.ProjectItemTask) { + if len(tasks) == 0 { + _, _ = fmt.Fprintln(out, "No tasks.") + return + } + tw := newTable(out) + _, _ = fmt.Fprintln(tw, " #\tSTATUS\tTITLE\tID") + for _, t := range tasks { + _, _ = fmt.Fprintf(tw, " %d\t%s\t%s\t%s\n", t.Position, taskState(t), t.Title, t.ID) + } + _ = tw.Flush() +} + +// taskState is the item vocabulary minus archived, which a task cannot be. +func taskState(t wire.ProjectItemTask) string { + if t.Completed { + return model.ItemStatusCompleted + } + return model.ItemStatusOpen +} + +// statusMark draws an item's status as one character: open ○, completed ✓, +// archived ▪. +func statusMark(status string) string { + switch status { + case model.ItemStatusArchived: + return "▪" + case model.ItemStatusCompleted: + return "✓" + default: + return "○" + } +} + +// handle is an item's number, or the tail of its id while it has none: an item +// made on a synced database before its create reached the API. +func handle(number *int, id string) string { + if number != nil { + return strconv.Itoa(*number) + } + if len(id) > 8 { + return id[len(id)-8:] + } + return id +} + +// closedSuffix dates a terminal status, and is empty for a project with no close. +func closedSuffix(p wire.Project) string { + if p.ClosedAt == nil { + return "" + } + return " (" + p.ClosedAt.Local().Format(dayLayout) + ")" +} + +// repoList renders a project's derived repos, with a dash where its items touch none. +func repoList(repos []string) string { + if len(repos) == 0 { + return "—" + } + return strings.Join(repos, ",") +} + +func text(s *string) string { + if s == nil { + return "" + } + return *s +} + +func orNone(s string) string { + if s == "" { + return "(none)" + } + return s +} diff --git a/cli/terminal_input.go b/cli/terminal_input.go new file mode 100644 index 0000000..0f7aad6 --- /dev/null +++ b/cli/terminal_input.go @@ -0,0 +1,143 @@ +package cli + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" + "golang.org/x/term" + + "github.com/datapointchris/projects/prompt" +) + +// confirm asks a yes/no question on stderr and reads the answer from stdin. With +// no terminal it refuses rather than read an answer nobody typed. +func confirm(cmd *cobra.Command, question string) (bool, error) { + if !interactive(cmd) { + return false, errors.New("refusing to prompt without an interactive terminal; pass --yes to confirm") + } + return readConfirmation(cmd.ErrOrStderr(), cmd.InOrStdin(), question) +} + +// interactive is whether a person can answer: stdin is a terminal and --no-input +// was not passed. A command that prompts a caller which cannot answer blocks on a +// stdin that never closes. +func interactive(cmd *cobra.Command) bool { + if noInput { + return false + } + file, ok := cmd.InOrStdin().(*os.File) + return ok && term.IsTerminal(int(file.Fd())) +} + +func readConfirmation(out io.Writer, in io.Reader, question string) (bool, error) { + _, _ = fmt.Fprintf(out, "%s [y/N] ", question) + line, err := bufio.NewReader(in).ReadString('\n') + if err != nil && err != io.EOF { + return false, err + } + answer := strings.ToLower(strings.TrimSpace(line)) + return answer == "y" || answer == "yes", nil +} + +// errAborted is a form the person canceled. Abandoning a record half typed is a +// choice, so the command reports it and exits 0. +var errAborted = errors.New("aborted") + +// runForm asks the fields the flags left out and returns what was answered. Call +// it only behind interactive(cmd). +func runForm(cmd *cobra.Command, form prompt.Form) (prompt.Answers, error) { + file, ok := cmd.InOrStdin().(*os.File) + if !ok { + return nil, errors.New("stdin is not a terminal") + } + session, err := prompt.NewTerminalSession(file, cmd.ErrOrStderr()) + if err != nil { + return nil, err + } + defer func() { _ = session.Close() }() + answers, err := form.Run(session) + if errors.Is(err, io.EOF) { + return nil, errAborted + } + return answers, err +} + +// flagAnswers collects the named flags the caller passed, in the shape a form +// returns, so nothing downstream asks which door a field came in through. A flag +// set to "" counts as unset. +func flagAnswers(cmd *cobra.Command, keys ...string) prompt.Answers { + answers := prompt.Answers{} + for _, key := range keys { + flag := cmd.Flags().Lookup(key) + if flag == nil || !flag.Changed { + continue + } + if values := flagValues(flag); len(values) > 0 { + answers[key] = values + } + } + return answers +} + +func flagValues(flag *pflag.Flag) []string { + raw := []string{flag.Value.String()} + if slice, ok := flag.Value.(pflag.SliceValue); ok { + raw = slice.GetSlice() + } + var values []string + for _, value := range raw { + if value != "" { + values = append(values, value) + } + } + return values +} + +// unanswered is the fields nothing has answered yet, which is the form to ask. +func unanswered(fields []prompt.Field, answers prompt.Answers) []prompt.Field { + var ask []prompt.Field + for _, field := range fields { + if !answers.Has(field.Key) { + ask = append(ask, field) + } + } + return ask +} + +// missingFlags is the flag spelling of each required key nothing answered, so a +// caller with no terminal is told which flag to pass. +func missingFlags(answers prompt.Answers, keys ...string) []string { + var missing []string + for _, key := range keys { + if !answers.Has(key) { + missing = append(missing, "--"+key) + } + } + return missing +} + +// validateAnswers runs each field's Validate over every value that answered it, +// so a bad value reads the same through a flag as through the form. +func validateAnswers(answers prompt.Answers, fields []prompt.Field) error { + for _, field := range fields { + if field.Validate == nil || !answers.Has(field.Key) { + continue + } + values := append([]string(nil), answers.All(field.Key)...) + for i, value := range values { + canonical, err := field.Validate(value) + if err != nil { + return fmt.Errorf("--%s: %w", field.Key, err) + } + values[i] = canonical + } + answers[field.Key] = values + } + return nil +} diff --git a/cli/tui.go b/cli/tui.go new file mode 100644 index 0000000..c236719 --- /dev/null +++ b/cli/tui.go @@ -0,0 +1,127 @@ +package cli + +import ( + "errors" + "fmt" + "os" + "strconv" + + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/datapointchris/goclikit" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/datapointchris/projects/api/wire" + "github.com/datapointchris/projects/tui" +) + +// tabProjects is the board's one tab, named for what it lists. +const tabProjects = "projects" + +// The frame --once draws where COLUMNS or LINES is unset. +const ( + onceFallbackWidth = 120 + onceFallbackHeight = 40 +) + +// tuiShown is `projects tui --json`: what the board holds as it opens. +// cli/schemas/board-snapshot.schema.json is its shape. +type tuiShown struct { + SchemaVersion int `json:"schema_version"` + View string `json:"view"` + Projects []wire.ProjectWithItemCount `json:"projects"` + Project *wire.ProjectWithItemCount `json:"project"` + Items []wire.ProjectItemInProject `json:"items"` +} + +const tuiShownVersion = 1 + +func newTUICommand() *cobra.Command { + var ( + view string + once, asJSON bool + ) + cmd := &cobra.Command{ + Use: "tui", + Short: "Open the board: projects on the left, the selected one's items on the right", + Long: "Everything the commands do can be done here by key; ? lists the keys. The status\n" + + "line names where the data lives: LOCAL and the database's path, API and its host,\n" + + "or the sync state of a synced database. In API mode the board reads the API again\n" + + "every [sync] interval, and R reads it now.\n" + + "\n" + + "--once prints one frame at COLUMNS by LINES, and --json what that frame is drawn\n" + + "from, so an agent reads what a person reads. With no terminal and neither flag it\n" + + "refuses.", + Example: " projects tui\n" + + " COLUMNS=160 LINES=50 projects tui --once > frame.txt\n" + + " projects tui --json | jq '.project.name'", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + if view != "" && view != tabProjects { + return goclikit.UsageError(fmt.Errorf("--view takes %s, not %q", tabProjects, view)) + } + if once && asJSON { + return goclikit.UsageError(errors.New("--once prints a frame and --json what it is drawn from, so give one of them")) + } + printing := once || asJSON + if !printing && (!term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd()))) { + return goclikit.UsageError(errors.New("projects tui needs a terminal; --once prints one frame and --json what it is drawn from")) + } + source, err := openDataSource(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + where := tui.Where{DatabasePath: source.cfg.Local.DBPath, APIHost: source.apiHost()} + if !printing { + source.startSync() + } + app := tui.NewApp(source.boardBackend(), source.engine, where, source.cfg.Sync.Interval) + if !printing { + _, err := tea.NewProgram(app).Run() + return err + } + if asJSON { + if err := app.Read(); err != nil { + return source.explain(err) + } + return encodeJSON(cmd.OutOrStdout(), boardShown(app)) + } + width, height := envSize("COLUMNS", onceFallbackWidth), envSize("LINES", onceFallbackHeight) + frame, err := app.Once(width, height) + if err != nil { + return source.explain(err) + } + _, err = lipgloss.Fprintln(cmd.OutOrStdout(), frame) + return err + }, + } + cmd.Flags().StringVar(&view, "view", "", "Open on this tab: "+tabProjects) + cmd.Flags().BoolVar(&once, "once", false, "Print one frame at COLUMNS by LINES, then exit") + cmd.Flags().BoolVar(&asJSON, "json", false, "Output what the board shows as it opens as JSON to stdout") + return cmd +} + +func boardShown(app *tui.App) tuiShown { + projects, selected, items := app.Shown() + shown := tuiShown{ + SchemaVersion: tuiShownVersion, + View: tabProjects, + Projects: wire.Map(projects, wire.NewProjectWithItemCount), + Items: wire.Map(items, wire.NewProjectItemInProject), + } + if selected != nil { + project := wire.NewProjectWithItemCount(*selected) + shown.Project = &project + } + return shown +} + +// envSize reads COLUMNS or LINES, the size a caller with no terminal asks for. +func envSize(name string, fallback int) int { + if n, err := strconv.Atoi(os.Getenv(name)); err == nil && n > 0 { + return n + } + return fallback +} diff --git a/config.example.toml b/config.example.toml index 831f156..d4f43b3 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1,16 +1,30 @@ # projects configuration # Place at ~/.config/projects/config.toml -# All values can be overridden with environment variables. +# The keys under [local] and [sync], and repos_registry, can be overridden with +# environment variables. Bare keys must stay above the first [section]. -# Repo registry, whose names a project may not take. +# Repo registry, whose names a project may not take in database mode. # Set this when the registry is maintained outside the tool's data directory and -# shared with other tools. A bare key, so it must stay above the first [section]. +# shared with other tools. # Env: PROJECTS_REPOS_REGISTRY # Default: ~/.local/share/projects/repos.json # repos_registry = "/path/to/repos.json" +# The projects API every command reads and writes. Set, no database is opened. +# Unset, the database under [local] is the store. `projects auth login <url>` +# writes it, with issuer and client_id. --api outranks it for one run. +# api = "https://projects.example.com" +# The identity provider the API trusts. auth login reads it from the API. +# issuer = "https://auth.example.com/application/o/projects/" +# This machine's client. Default: projects-cli-<hostname> +# client_id = "projects-cli-desk" +# A command printing a token response, as an argument list run without a shell. +# Set, no login runs. For an API that needs no real token, print a stand-in. +# token_command = ["printf", '{"access_token":"dev","token_type":"Bearer"}'] + [sync] -# Enable background sync with ichrisbirch API +# Keep the database a copy of an API, for the board. Refused beside api. +# With it on, a command that writes refuses: make the change in `projects tui`. # Env: PROJECTS_SYNC enabled = false # The API server's host, required when enabled. The tool adds /api/v1 itself. @@ -22,6 +36,7 @@ enabled = false # Env: PROJECTS_SYNC_TOKEN_COMMAND # token_command = "<cli> auth token" # Longest the tool goes without reconciling: the TUI's background pull period. +# Through api, how often the board reads the API again. # A duration such as "30s" or "5m". Anything below 15s is raised to 15s. # Env: PROJECTS_SYNC_INTERVAL # Default: 2m diff --git a/config/config.go b/config/config.go index 10bbd04..dec4a48 100644 --- a/config/config.go +++ b/config/config.go @@ -24,6 +24,17 @@ type Config struct { // the data directory below it are composed by repos.DefaultPath, so the whole // resolution order reads in one place rather than half here and half there. ReposRegistry string `mapstructure:"repos_registry"` + + // API is the projects API every command reads and writes through. Set, no + // database is opened. Unset, the database under local is the store, so a + // fresh install works with no server. goclilogin reads it beside issuer, + // client_id and token_command, and `projects auth login <url>` writes the + // first three. They are declared here because ErrorUnused refuses a key the + // struct has no home for. + API string `mapstructure:"api"` + Issuer string `mapstructure:"issuer"` + ClientID string `mapstructure:"client_id"` + TokenCommand []string `mapstructure:"token_command"` } // SyncConfig holds settings for background sync with the remote API. diff --git a/former_name_test.go b/former_name_test.go index 43a1a6a..817e955 100644 --- a/former_name_test.go +++ b/former_name_test.go @@ -11,6 +11,7 @@ import ( "github.com/datapointchris/tuidrive" + "github.com/datapointchris/projects/cli" "github.com/datapointchris/projects/model" "github.com/datapointchris/projects/store/sqlite" ) @@ -42,7 +43,7 @@ func TestFormerNameOnFirstRun(t *testing.T) { t.Fatal(err) } defer func() { _ = database.Close() }() - projects, err := localOnlyBackend(database, "").ListProjectsByStatus(model.StatusAll) + projects, err := cli.LocalService(database, "").ListProjectsByStatus(model.StatusAll) if err != nil { t.Fatal(err) } @@ -183,7 +184,7 @@ func seedFormerDatabaseWithUnflushedWAL(t *testing.T, dir string) { t.Fatal(err) } defer func() { _ = database.Close() }() - if _, err := localOnlyBackend(database, "").CreateProject(model.CreateProject{Name: "Held only in the WAL"}); err != nil { + if _, err := cli.LocalService(database, "").CreateProject(model.CreateProject{Name: "Held only in the WAL"}); err != nil { t.Fatal(err) } for _, suffix := range []string{"", "-wal", "-shm"} { diff --git a/go.mod b/go.mod index 7a6ac42..91d9785 100644 --- a/go.mod +++ b/go.mod @@ -12,18 +12,24 @@ require ( github.com/charmbracelet/x/ansi v0.11.9 github.com/datapointchris/goapiauth v0.2.0 github.com/datapointchris/goclikit v0.5.3 + github.com/datapointchris/goclilogin v0.8.0 github.com/datapointchris/goselfupdate v0.11.0 + github.com/datapointchris/gotermstyle v0.2.1 github.com/datapointchris/tuidrive v0.1.0 github.com/go-viper/mapstructure/v2 v2.4.0 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.11.0 + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pressly/goose/v3 v3.26.0 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/testcontainers/testcontainers-go v0.44.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.44.0 github.com/yuin/goldmark v1.7.17 + golang.org/x/oauth2 v0.37.0 + golang.org/x/term v0.47.0 modernc.org/sqlite v1.54.0 ) @@ -34,6 +40,7 @@ require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -60,6 +67,7 @@ require ( github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/creack/pty v1.1.24 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect + github.com/danieljoos/wincred v1.2.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect @@ -76,6 +84,8 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gofrs/flock v0.13.0 // indirect github.com/google/cel-go v0.26.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -122,7 +132,6 @@ require ( github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect github.com/sqlc-dev/sqlc v1.30.0 // indirect github.com/stoewer/go-strcase v1.2.0 // indirect github.com/stretchr/testify v1.11.1 // indirect @@ -135,6 +144,7 @@ require ( github.com/xo/terminfo v1.0.0 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zalando/go-keyring v0.2.8 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect @@ -148,7 +158,7 @@ require ( golang.org/x/image v0.39.0 // indirect golang.org/x/net v0.57.0 // indirect golang.org/x/sync v0.24.0 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.49.0 // indirect golang.org/x/text v0.41.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect diff --git a/go.sum b/go.sum index b12b0c8..dd9eccf 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= @@ -81,12 +83,18 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/cubicdaiya/gonp v1.0.4 h1:ky2uIAJh81WiLcGKBVD5R7KsM/36W6IqqTy6Bo6rGws= github.com/cubicdaiya/gonp v1.0.4/go.mod h1:iWGuP/7+JVTn02OWhRemVbMmG1DOUnmrGTYYACpOI0I= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/datapointchris/goapiauth v0.2.0 h1:Bqo+F5X70YnmRgWNIpqohFT5brScxt82888iqPS3a/U= github.com/datapointchris/goapiauth v0.2.0/go.mod h1:R5j+QfvEqu0mTwap4nz13fq5RRuXwKGUnAH6VvfhLmQ= github.com/datapointchris/goclikit v0.5.3 h1:6ec9nuswLXeIqZLa0YVEQeqsCnrjkqF/3gDtCBSowNQ= github.com/datapointchris/goclikit v0.5.3/go.mod h1:hEbplQyvrWq5m5UepXVNKfv8jvvlmQshNMrWdoHBb6Y= +github.com/datapointchris/goclilogin v0.8.0 h1:pHeTUzgl++YT+jI0iSir9Wtow+L5u+JM1cVZ3p8Fsbk= +github.com/datapointchris/goclilogin v0.8.0/go.mod h1:BKusVBeatnZ7i7rx6xM7B9lADkTlfer9efrvuYjRIOI= github.com/datapointchris/goselfupdate v0.11.0 h1:aUJ27Cm9ZNGMfC7+kKChBsnm8offBE99wwUa10lFo2w= github.com/datapointchris/goselfupdate v0.11.0/go.mod h1:YcgyvM+wH6nh9zo9xioUqTHeH5HTYeCHevv4U+G7INc= +github.com/datapointchris/gotermstyle v0.2.1 h1:TQnavpeSmNVR08vTMNfPWKhxjHiy+a8ggme02NYBgI0= +github.com/datapointchris/gotermstyle v0.2.1/go.mod h1:59Nmf3Ty+izIaiIxFaJDxSkWZLWmx7RAQDGp58ohiXo= github.com/datapointchris/tuidrive v0.1.0 h1:s6esDWL9P8EkQYzdvrPwvBxYjPF98u0ukpnZbfBIGOc= github.com/datapointchris/tuidrive v0.1.0/go.mod h1:lbRNaZkLTfPvfVqkJlcCzfuItgbePNp1flvS70mf/gI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -128,6 +136,10 @@ github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1 github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= +github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= @@ -224,6 +236,8 @@ github.com/pingcap/log v1.1.0 h1:ELiPxACz7vdo1qAvvaWJg1NrYFoY6gqAh/+Uo6aXdD8= github.com/pingcap/log v1.1.0/go.mod h1:DWQW5jICDR7UJh4HtxXSM20Churx4CQL0fwL/SoOSA4= github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0 h1:W3rpAI3bubR6VWOcwxDIG0Gz9G5rl5b3SL116T0vBt0= github.com/pingcap/tidb/pkg/parser v0.0.0-20250324122243-d51e00e5bbf0/go.mod h1:+8feuexTKcXHZF/dkDfvCwEyBAmgb4paFc3/WeYV2eE= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -301,6 +315,8 @@ github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= +github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= @@ -346,6 +362,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= @@ -354,10 +372,10 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= -golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= +golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= +golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= diff --git a/main.go b/main.go index d339703..22c8851 100644 --- a/main.go +++ b/main.go @@ -1,26 +1,11 @@ package main import ( - "context" - "database/sql" - "errors" "fmt" "os" - "path/filepath" - tea "charm.land/bubbletea/v2" - "github.com/datapointchris/goclikit" - "github.com/datapointchris/goselfupdate/autoupdate" - "github.com/spf13/cobra" - - "github.com/datapointchris/projects/backend" "github.com/datapointchris/projects/cli" "github.com/datapointchris/projects/config" - "github.com/datapointchris/projects/repos" - "github.com/datapointchris/projects/service" - "github.com/datapointchris/projects/store/sqlite" - "github.com/datapointchris/projects/sync" - "github.com/datapointchris/projects/tui" ) func main() { @@ -28,158 +13,5 @@ func main() { fmt.Fprintf(os.Stderr, "error: %s\n", refusal) os.Exit(2) } - autoConfig := autoupdate.Config{Update: cli.UpdateConfig()} - if err := goclikit.Execute(context.Background(), rootCmd(), autoConfig); err != nil { - if !errors.Is(err, goclikit.ErrReported) { - fmt.Fprintf(os.Stderr, "error: %v\n", err) - } - // 2 says the command line was wrong rather than the run, which is the - // only failure a caller should retry with different arguments. - if errors.Is(err, goclikit.ErrUsage) { - os.Exit(2) - } - os.Exit(1) - } -} - -// localOnlyBackend is the backend with sync off, where this database is the -// authority that numbers items. -func localOnlyBackend(d *sql.DB, reposRegistryPath string) *service.Service { - return service.New(sqlite.New(d), service.AssigningItemNumbers(), service.RefusingRepoNames(reposRegistryPath)) -} - -// needsBackend reports whether a command reads or writes the tool's own data. -// -// PersistentPreRunE opens the database and runs migrations, and cobra runs it -// for the shell-completion callback like any other command — so every TAB press -// opened the database, and on a new machine created it. `version` is what a -// script calls to find out what is installed, `help` answers from the command -// tree, and `update` replaces the binary. None of them has anything to read. -// -// The ancestry is walked rather than only the leaf, because `completion` owns a -// subcommand per shell: `projects completion zsh` has leaf name "zsh". -// -// `--help` never reaches here at all — cobra answers the flag before running -// this — which is why the list names the command and not the flag. -func needsBackend(cmd *cobra.Command) bool { - for current := cmd; current != nil; current = current.Parent() { - switch current.Name() { - case "help", "version", "update", "completion", - cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd: - return false - } - } - return true -} - -func rootCmd() *cobra.Command { - var b backend.Backend - var database *sql.DB - var syncEngine *sync.Engine - var cfg *config.Config - - root := &cobra.Command{ - Use: "projects", - Short: "Personal project organization", - Long: "Personal project organization.\n\n" + - "Run `projects` to open the TUI, where everything happens. With sync enabled it\n" + - "pushes each edit as you make it and pulls in the background on every interval,\n" + - "so it never needs a sync by hand.\n\n" + - "Reach for `projects sync` when you want the API's state now rather than at the\n" + - "next interval, or need --adopt or --force: it pushes anything queued, then pulls.", - Example: " projects open the TUI, syncing in the background when sync is enabled\n" + - " projects sync push queued edits and pull now, without waiting for the interval", - Version: cli.Version(), - PersistentPreRunE: func(cmd *cobra.Command, _ []string) error { - if !needsBackend(cmd) { - return nil - } - if err := config.RefuseConfigUnderFormerName(); err != nil { - return err - } - var err error - cfg, err = config.Load() - if err != nil { - return err - } - if err := config.MoveDataUnderFormerName(cfg.Local.DBPath); err != nil { - return err - } - - if err := os.MkdirAll(filepath.Dir(cfg.Local.DBPath), 0o755); err != nil { - return fmt.Errorf("creating data directory: %w", err) - } - d, err := sqlite.Open(cfg.Local.DBPath) - if err != nil { - return fmt.Errorf("opening database: %w", err) - } - database = d - - if cfg.Sync.Enabled { - local := service.New(sqlite.New(d), service.RefusingRepoNames(repos.DefaultPath())) - syncEngine = sync.New(d, cfg.Sync.APIURL, cfg.Sync.TokenCommand) - syncEngine.Start() - b = sync.NewSyncBackend(local, syncEngine) - } else { - b = localOnlyBackend(d, repos.DefaultPath()) - } - return nil - }, - RunE: func(_ *cobra.Command, _ []string) error { - app := tui.NewApp(b, syncEngine, cfg.Local.DBPath, cfg.Sync.Interval) - _, err := tea.NewProgram(app).Run() - return err - }, - PersistentPostRunE: func(_ *cobra.Command, _ []string) error { - if syncEngine != nil { - syncEngine.Stop() - } - if database != nil { - return database.Close() - } - return nil - }, - SilenceUsage: true, - SilenceErrors: true, - } - - var adoptOrigin, forceSweep bool - syncCmd := &cobra.Command{ - Use: "sync", - Short: "Reconcile with the ichrisbirch API now", - Long: "Pushes anything queued, then pulls, regardless of how recently the last pull ran.\n\n" + - "A pull deletes everything the server did not return, so the database is bound to\n" + - "the API it was first pulled from and refuses to reconcile against any other.\n" + - "--adopt rebinds it; --force allows a pull that would delete most of what is here.", - Example: " projects sync push queued edits, then pull from the API now\n" + - " projects sync --adopt bind this database to the configured API, then sync\n" + - " projects sync --force sync even when the pull would delete most local rows", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - if syncEngine == nil { - return fmt.Errorf("sync is disabled — enable it in ~/.config/projects/config.toml") - } - // Named on the way in, not after: --adopt rewrites which API owns - // this database, and doing that silently is the mistake the binding - // exists to prevent. - if adoptOrigin { - fmt.Printf("Binding %s to %s\n", cfg.Local.DBPath, syncEngine.APIURL()) - } - if err := syncEngine.PullWith(cmd.Context(), sync.PullOptions{ - Adopt: adoptOrigin, - AllowLargeSweep: forceSweep, - }); err != nil { - return err - } - fmt.Println("Synced.") - return nil - }, - } - syncCmd.Flags().BoolVar(&adoptOrigin, "adopt", false, - "bind this database to the configured API, replacing whatever it was bound to") - syncCmd.Flags().BoolVar(&forceSweep, "force", false, - "allow a pull that would delete most of the local projects or items") - root.AddCommand(syncCmd, cli.UpdateCmd(), cli.VersionCmd()) - - return root + os.Exit(cli.Execute()) } diff --git a/main_test.go b/main_test.go deleted file mode 100644 index 03b8b5e..0000000 --- a/main_test.go +++ /dev/null @@ -1,101 +0,0 @@ -package main - -import ( - "context" - "errors" - "io" - "os" - "testing" - - "github.com/datapointchris/goclikit" - "github.com/datapointchris/goselfupdate/autoupdate" - "github.com/spf13/cobra" -) - -// cobra builds `completion` and `help` during Execute, not construction. -func testRoot(t *testing.T) *cobra.Command { - t.Helper() - root := rootCmd() - root.InitDefaultCompletionCmd() - root.InitDefaultHelpCmd() - return root -} - -func find(t *testing.T, root *cobra.Command, path ...string) *cobra.Command { - t.Helper() - cmd, _, err := root.Find(path) - if err != nil { - t.Fatalf("finding %v: %v", path, err) - } - if cmd.Name() != path[len(path)-1] { - t.Fatalf("finding %v resolved to %q", path, cmd.Name()) - } - return cmd -} - -// The shells source `projects completion zsh` from their rc files, so opening the -// database here is paid by every shell that starts, and on a new machine it -// creates one. cobra gives `completion` a subcommand per shell and executes the -// leaf, so a check on the leaf's name alone misses it. -func TestNeedsBackendSkipsCommandsWithNothingToRead(t *testing.T) { - root := testRoot(t) - - for _, path := range [][]string{ - {"completion", "zsh"}, - {"completion", "bash"}, - {"completion", "fish"}, - {"help"}, - {"version"}, - {"update"}, - } { - if needsBackend(find(t, root, path...)) { - t.Errorf("%v opened the database, want it skipped", path) - } - } -} - -// cobra adds the completion-protocol commands from an unexported initialiser, so -// mirror how it parents them. These run on every TAB press. -func TestNeedsBackendSkipsCompletionProtocolCommands(t *testing.T) { - root := testRoot(t) - - for _, name := range []string{cobra.ShellCompRequestCmd, cobra.ShellCompNoDescRequestCmd} { - cmd := &cobra.Command{Use: name} - root.AddCommand(cmd) - if needsBackend(cmd) { - t.Errorf("%s opened the database, want it skipped", name) - } - } -} - -// A positional on a leaf is a typing mistake, so it exits 2 like a bad flag. -// cobra validates arguments before PersistentPreRunE, so `sync` refuses the -// word before it opens a database. goclikit.Execute resolves the command from -// os.Args, so the line goes there rather than through SetArgs. -func TestAPositionalOnALeafIsAUsageError(t *testing.T) { - original := os.Args - t.Cleanup(func() { os.Args = original }) - - for _, line := range [][]string{{"sync", "bogus"}, {"version", "bogus"}} { - root := rootCmd() - root.SetOut(io.Discard) - root.SetErr(io.Discard) - os.Args = append([]string{"projects"}, line...) - - err := goclikit.Execute(context.Background(), root, autoupdate.Config{Suppress: true}) - if !errors.Is(err, goclikit.ErrUsage) { - t.Errorf("%v answered %v, want a usage error", line, err) - } - } -} - -func TestNeedsBackendOpensTheDatabaseForTheTUIAndSync(t *testing.T) { - root := testRoot(t) - - if !needsBackend(root) { - t.Error("bare `projects` skipped the database, want the TUI to have one") - } - if !needsBackend(find(t, root, "sync")) { - t.Error("sync skipped the database, want it opened") - } -} diff --git a/prompt/plain.go b/prompt/plain.go new file mode 100644 index 0000000..5465578 --- /dev/null +++ b/prompt/plain.go @@ -0,0 +1,50 @@ +package prompt + +import ( + "bufio" + "errors" + "fmt" + "io" + "strings" +) + +// PlainSession is a [Session] over ordinary streams: one line at a time, with no +// editing and no completion. It is what the tests drive a form with, which keeps +// the retry loop testable without a pty. +type PlainSession struct { + in *bufio.Reader + out io.Writer +} + +// NewPlainSession returns a session reading lines from in and writing to out. +func NewPlainSession(in io.Reader, out io.Writer) *PlainSession { + return &PlainSession{in: bufio.NewReader(in), out: out} +} + +func (s *PlainSession) Write(p []byte) (int, error) { return s.out.Write(p) } + +// Completes reports that Tab does nothing here. +func (s *PlainSession) Completes() bool { return false } + +// Width is the terminal width a stream does not have. Eighty is what every tool +// falls back to and what a captured transcript is read at. +func (s *PlainSession) Width() int { return 80 } + +// ReadLine writes the label and reads one line. A stream cannot be edited, so +// the seed is shown in the prompt rather than placed on the line — the value +// being corrected is still in front of the reader either way. +func (s *PlainSession) ReadLine(q Question) (string, error) { + label := q.Label + if q.Seed != "" { + label = fmt.Sprintf("%s(was %q) ", label, q.Seed) + } + _, _ = fmt.Fprint(s.out, label) + line, err := s.in.ReadString('\n') + if err != nil && !errors.Is(err, io.EOF) { + return "", err + } + if line == "" && errors.Is(err, io.EOF) { + return "", io.EOF + } + return strings.TrimRight(line, "\r\n"), nil +} diff --git a/prompt/prompt.go b/prompt/prompt.go new file mode 100644 index 0000000..3a0903e --- /dev/null +++ b/prompt/prompt.go @@ -0,0 +1,468 @@ +// Package prompt asks for a record's fields one at a time, on a terminal. +// +// A form is a list of [Field]s. Each is asked in turn; a field with a fixed set +// of values lists them and completes on Tab; a rejected answer comes back in the +// editable buffer instead of being thrown away, so a typo in the last field +// never costs the four already typed. +// +// Nothing here imports the rest of this repo and the only dependency is +// golang.org/x/term, so copying the directory is the whole port to another +// project. +package prompt + +import ( + "errors" + "fmt" + "io" + "slices" + "strings" + "text/tabwriter" +) + +// Session is where a form asks its questions — a writer for everything the form +// says, and a line reader for what it asks. [TerminalSession] is the real one; +// [PlainSession] drives a form from ordinary streams. +type Session interface { + io.Writer + + // ReadLine asks one [Question] and returns the line typed after it. A + // canceled read returns io.EOF. + ReadLine(Question) (string, error) + + // Completes reports whether ReadLine honors choices, so a form only + // advertises Tab where pressing it does something. + Completes() bool + + // Width is how many columns a listing has to lay itself out in. A project + // name is a phrase rather than a word, so a fixed column count either wraps + // the long lists or wastes the screen on the short ones. + Width() int +} + +// Question is one ask: what to print, what to put on the line, and what Tab +// does about the choices. +type Question struct { + // Label is the text the answer is typed after. + Label string + + // Seed goes in the editable buffer before the first keystroke, which is how + // a rejected answer comes back for correction rather than being retyped. + Seed string + + // Choices complete on Tab when non-empty. + Choices []string + + // ListChoices asks Tab to print the matches before it starts walking them. + // + // The form settles this once, before the first ask, because it is a fact + // about how the field introduced itself rather than about how many choices + // it currently holds. An [Escape] grows Choices mid-form, and a field that + // printed its list up front must not start reprinting it because the count + // crossed a threshold between two answers. + ListChoices bool +} + +// Field is one question in a form. +type Field struct { + // Key names this field's answer in the returned [Answers]. + Key string + + // Label is the word the question is asked with — "Category". + Label string + + // Hint is one line printed above the first ask, for a constraint the label + // cannot carry: "Blank takes the category's window." + Hint string + + // Default is taken when the answer is empty, and is shown in the prompt. + Default string + + // Choices are listed before the first ask and cycled on Tab. A list too long + // to read is summarized instead, since scrolling one is the hunt the form + // exists to end. Listing is all this does — what the field accepts is + // Validate's decision. + Choices []string + + // Optional lets an empty answer through. The field is then absent from + // [Answers] rather than present and empty. + Optional bool + + // Repeat asks again after every answer and keeps them all, in order, until + // an empty one ends the run. A repeating field that is not Optional still + // needs its first answer. + Repeat bool + + // Multiline reads until a blank line and joins what it read with newlines, + // for a field carrying prose rather than a value. Choices, Default, and + // Validate do not apply. + Multiline bool + + // Validate rejects a bad answer and returns the canonical spelling of a good + // one, so "chore" can be stored as "Chore". Its error is printed as-is and + // the answer is offered back for editing, which makes the error text the + // only place the field explains itself. + Validate func(string) (string, error) + + // Escape is a second way to answer, for the case the choices do not cover. + Escape *Escape + + // made is what Escape has produced so far, which Validate is not asked + // about — see escaped. + made []string + + // listChoices is settled once in ask and carried to every Question the + // field asks — see [Question.ListChoices]. + listChoices bool +} + +// Escape turns a field's dead end into a way forward: the answer the choices +// should have held is made on the spot instead of costing a second command and +// the half-typed record already on screen. +// +// It is a callback rather than anything this package understands, so a form can +// offer "make a new one" without prompt knowing what is being made. +type Escape struct { + // Trigger is the answer that runs it. Punctuation is what keeps it out of + // the way of real values — nobody means a project called "+". + Trigger string + + // Hint is printed with the field's choices. A trigger nothing mentions is a + // trigger nobody presses. + Hint string + + // Run makes the new value, asking on s for whatever that takes. What it + // returns is the answer, unvalidated: it made the value, so it is the + // authority on it, and the field's own choices grow by it. + // + // An empty return means nothing was made and the field is asked again, + // which is how Run offers a way back out of an escape started by mistake. + Run func(Session) (string, error) +} + +// escaped returns the canonical spelling of a value this field's [Escape] made, +// and reports whether it made one. +// +// A repeating field asks again after an escape, so the value just made is on +// the Tab list for the next answer. Validate was built before it existed and +// would reject it, which would leave the field offering a choice it refuses. +// The escape is the authority on what it made, on the second answer as much as +// on the first. +func (f Field) escaped(answer string) (string, bool) { + for _, value := range f.made { + if strings.EqualFold(answer, value) { + return value, true + } + } + return "", false +} + +// prompt is the text the answer is typed after. collected is how many answers +// the field already holds, which only a repeating field ever has. +// +// A default is worth showing because Enter takes it; "(optional)" is worth +// showing because Enter skips it; and on a repeating field Enter does a third +// thing, so it says which. +func (f Field) prompt(collected int) string { + switch { + case collected > 0: + return fmt.Sprintf("%s (another, or Enter to move on): ", f.Label) + case f.Default != "": + return fmt.Sprintf("%s [%s]: ", f.Label, f.Default) + case f.Optional: + return fmt.Sprintf("%s (optional): ", f.Label) + default: + return f.Label + ": " + } +} + +// Answers holds one form's answers, keyed by [Field.Key]. A skipped optional +// field is absent rather than present and empty, so a caller can tell "left +// alone" from "set to nothing". +// +// Every key carries a list because a repeating field and a repeatable flag both +// produce several values. Get returns the first, which is the whole story for +// every field that does not repeat. +type Answers map[string][]string + +// Get returns the first answer for key, or "" when the field was skipped. +func (a Answers) Get(key string) string { + if values := a[key]; len(values) > 0 { + return values[0] + } + return "" +} + +// All returns every answer for key, in the order they were given. +func (a Answers) All(key string) []string { return a[key] } + +// Has reports whether key was answered. +func (a Answers) Has(key string) bool { return len(a[key]) > 0 } + +// Merge copies other's answers in without overwriting anything already present. +// Flags outrank prompts on purpose: a form only ever asks for what the flags +// left out, so anything already here came from the caller. +func (a Answers) Merge(other Answers) { + for key, values := range other { + if !a.Has(key) { + a[key] = values + } + } +} + +// Form is an ordered set of fields asked as one pass. +type Form struct { + // Intro is printed once, before the first field. + Intro string + + Fields []Field +} + +// Run asks every field in order and returns the answers. Canceling (Ctrl-C, +// Ctrl-D, or a closed stream) returns io.EOF and no answers — a half-filled +// record is not a record. +func (f Form) Run(s Session) (Answers, error) { + if f.Intro != "" { + _, _ = fmt.Fprintf(s, "%s\n\n", f.Intro) + } + answers := Answers{} + // By value, and ask takes it by value too. That is what keeps an Escape's + // mutation of Choices and made inside the run that made it, so a Form value + // can be run twice. TestForm_RunningOneFormTwiceDoesNotCarryTheFirstRunsEscapeOver + // is what holds it. + for _, field := range f.Fields { + values, err := ask(s, field) + if err != nil { + return nil, err + } + if len(values) > 0 { + answers[field.Key] = values + } + } + return answers, nil +} + +// ask puts one field to the session and returns everything it collected — one +// value for an ordinary field, several for a repeating one, none for a skipped +// optional one. +func ask(s Session, field Field) ([]string, error) { + if field.Hint != "" { + _, _ = fmt.Fprintln(s, field.Hint) + } + if len(field.Choices) > 0 { + writeChoices(s, field) + } + if field.Escape != nil && field.Escape.Hint != "" { + _, _ = fmt.Fprintf(s, " %s\n", field.Escape.Hint) + } + // Settled here, ahead of every answer, so an Escape growing Choices cannot + // change what Tab does half way through the field. + field.listChoices = tooManyToList(field.Choices) + if field.Multiline { + return askProse(s, field) + } + var values []string + for { + value, err := askValue(s, &field, len(values)) + if err != nil { + return nil, err + } + if value == "" { + return values, nil + } + values = append(values, value) + if !field.Repeat { + return values, nil + } + } +} + +// askValue reads one answer, re-asking until it validates. The rejected answer +// is seeded into the next read, so a typo is corrected in place and every field +// already answered stays answered. +// +// collected is how many answers the field already holds. It is what makes an +// empty line mean "no more" on a repeating field that has one, and "you have to +// answer this" on a required field that has none. +// +// field is a pointer because an [Escape] grows Choices, and a repeating field +// asks again straight afterwards — a value made a moment ago has to be one Tab +// away like every other. +func askValue(s Session, field *Field, collected int) (string, error) { + seed := "" + for { + answer, err := s.ReadLine(Question{ + Label: field.prompt(collected), + Seed: seed, + Choices: field.Choices, + ListChoices: field.listChoices, + }) + if err != nil { + return "", err + } + answer = strings.TrimSpace(answer) + switch { + case answer != "": + case field.Default != "" && collected == 0: + answer = field.Default + case field.Optional || collected > 0: + return "", nil + default: + _, _ = fmt.Fprintf(s, " %s is required.\n", field.Label) + seed = "" + continue + } + if field.Escape != nil && answer == field.Escape.Trigger { + value, err := runEscape(s, field) + if err != nil || value != "" { + return value, err + } + seed = "" + continue + } + if value, ok := field.escaped(answer); ok { + return value, nil + } + if field.Validate == nil { + return answer, nil + } + value, err := field.Validate(answer) + if err != nil { + _, _ = fmt.Fprintf(s, " %v\n", err) + seed = answer + continue + } + return value, nil + } +} + +// runEscape makes a new value and adds it to what the field offers. It returns +// an empty value and no error when nothing was made, which sends the field back +// to asking. +// +// A cancel travels: abandoning the record is abandoning it, however deep the +// question being answered when it happened. Anything else the escape failed at +// is printed and the field is asked again, because the answer already on the +// list is still a good answer. +func runEscape(s Session, field *Field) (string, error) { + value, err := field.Escape.Run(s) + switch { + case errors.Is(err, io.EOF): + return "", err + case err != nil: + _, _ = fmt.Fprintf(s, " %v\n", err) + return "", nil + case value == "": + return "", nil + } + field.Choices = append(slices.Clone(field.Choices), value) + field.made = append(field.made, value) + return value, nil +} + +// proseGutter marks the lines of a multiline field, so it is clear which ones +// are being collected and that Enter on an empty one ends the field rather than +// the form. +const proseGutter = " > " + +// askProse reads lines until a blank one ends the field, and returns them joined. +func askProse(s Session, field Field) ([]string, error) { + for { + _, _ = fmt.Fprintf(s, "%s — one or more lines, blank line ends it:\n", field.Label) + var lines []string + for { + line, err := s.ReadLine(Question{Label: proseGutter}) + if err != nil { + return nil, err + } + if strings.TrimSpace(line) == "" { + break + } + lines = append(lines, line) + } + if len(lines) > 0 { + return []string{strings.Join(lines, "\n")}, nil + } + if field.Optional { + return nil, nil + } + _, _ = fmt.Fprintf(s, " %s is required.\n", field.Label) + } +} + +// maxListedChoices is where a list stops being a glance and becomes a scroll. +// Past it the field says how many there are instead, because printing eighty +// repo names unasked is the wall of text this form exists to replace. +// +// It is also what decides whether Tab lists: what the field could not show up +// front is what Tab is there to show. See tooManyToList. +const maxListedChoices = 24 + +// tooManyToList reports that a list is past the point of printing unasked, which +// is the same fact as Tab being the only way to see it. +func tooManyToList(choices []string) bool { return len(choices) > maxListedChoices } + +// choiceIndent sets a listing in from the prompts around it. +const choiceIndent = " " + +// choiceGap is the space between two columns of choices. +const choiceGap = 2 + +// writeChoices says what the field accepts, above the first ask. +func writeChoices(s Session, field Field) { + if tooManyToList(field.Choices) { + writeChoiceCount(s, field) + return + } + _, _ = fmt.Fprintf(s, "%s is one of:\n", field.Label) + writeColumns(s, s.Width(), field.Choices) + if s.Completes() { + _, _ = fmt.Fprintln(s, choiceIndent+"Tab cycles the matches.") + } +} + +// writeChoiceCount stands in for a list too long to print unasked. Tab is what +// reaches those choices, so a session without it gets the count and nothing else. +func writeChoiceCount(s Session, field Field) { + if s.Completes() { + // Kept inside 80 columns: it prints directly above the prompt, and a + // field whose introduction wraps to two lines is a poor advertisement + // for a change about fitting output to the width. + _, _ = fmt.Fprintf(s, "%s — %d to choose from. Tab lists them; type part of one to narrow.\n", + field.Label, len(field.Choices)) + return + } + _, _ = fmt.Fprintf(s, "%s — %d to choose from.\n", field.Label, len(field.Choices)) +} + +// writeColumns lays choices out in as many columns as the longest of them fits +// into width, reading across each row. +// +// The column count is measured rather than fixed because the two kinds of +// vocabulary are nothing alike: a dozen one-word categories waste most of a +// screen under a fixed count, and a project called "Rewrite the backup scripts +// in Go" wraps under the same one. +// +// The last cell on a row carries no trailing tab. tabwriter pads every celled +// column to its widest entry, so a trailing one spends real columns on the +// right margin — which is where a row stops fitting. +func writeColumns(w io.Writer, width int, choices []string) { + longest := 0 + for _, choice := range choices { + if n := len([]rune(choice)); n > longest { + longest = n + } + } + columns := max((width-len(choiceIndent))/(longest+choiceGap), 1) + tw := tabwriter.NewWriter(w, 0, 4, choiceGap, ' ', 0) + for i, choice := range choices { + if i%columns == 0 { + _, _ = fmt.Fprint(tw, choiceIndent) + } + if (i+1)%columns == 0 || i == len(choices)-1 { + _, _ = fmt.Fprintln(tw, choice) + continue + } + _, _ = fmt.Fprintf(tw, "%s\t", choice) + } + _ = tw.Flush() +} diff --git a/prompt/terminal.go b/prompt/terminal.go new file mode 100644 index 0000000..2541095 --- /dev/null +++ b/prompt/terminal.go @@ -0,0 +1,205 @@ +package prompt + +import ( + "bytes" + "fmt" + "io" + "os" + "strings" + + "golang.org/x/term" +) + +// TerminalSession is a [Session] over a real terminal. It holds the terminal in +// raw mode for the life of the form, which is what buys line editing, history on +// the arrow keys, and Tab completion. +type TerminalSession struct { + terminal *term.Terminal + fd int + oldState *term.State + input *seedReader +} + +// NewTerminalSession puts in into raw mode and draws the form on out. Close +// restores the terminal and has to run — a raw-mode terminal left behind is a +// shell with no echo. +// +// out is separate from in so the prompts land on stderr, leaving stdout for the +// command's data. +func NewTerminalSession(in *os.File, out io.Writer) (*TerminalSession, error) { + fd := int(in.Fd()) + if !term.IsTerminal(fd) { + return nil, fmt.Errorf("stdin is not a terminal") + } + oldState, err := term.MakeRaw(fd) + if err != nil { + return nil, fmt.Errorf("put the terminal into raw mode: %w", err) + } + input := &seedReader{src: in} + terminal := term.NewTerminal(struct { + io.Reader + io.Writer + }{input, out}, "") + // A pty with no window size reports 0, and a zero-width terminal wraps after + // every character. NewTerminal's own 80x24 is the better guess. + if width, height, err := term.GetSize(fd); err == nil && width > 0 && height > 0 { + _ = terminal.SetSize(width, height) + } + return &TerminalSession{terminal: terminal, fd: fd, oldState: oldState, input: input}, nil +} + +// Close restores the terminal to the state it was in before the form. +func (s *TerminalSession) Close() error { return term.Restore(s.fd, s.oldState) } + +// Write draws form output on the terminal, which rewrites the line endings raw +// mode needs and repaints whatever line is being edited. +func (s *TerminalSession) Write(p []byte) (int, error) { return s.terminal.Write(p) } + +// Completes reports that Tab works here. +func (s *TerminalSession) Completes() bool { return true } + +// Width is the terminal's current width, re-read each time because a pane can +// be resized part-way through a form. Eighty is the fallback x/term itself uses. +func (s *TerminalSession) Width() int { + if width, _, err := term.GetSize(s.fd); err == nil && width > 0 { + return width + } + return 80 +} + +// ReadLine asks one question, with the seed already on the line and Tab +// completing the choices. +func (s *TerminalSession) ReadLine(q Question) (string, error) { + s.terminal.SetPrompt(q.Label) + completer := &cycler{choices: q.Choices} + // A list short enough to print unasked is on the screen already, and + // reprinting it under the prompt is noise. A list too long for that was + // never shown at all, which leaves Tab as the only way to find out what is + // in it — and walking 54 projects one keypress at a time is not finding + // out. The form decides which of those this field is. + if q.ListChoices { + completer.session = s + } + s.terminal.AutoCompleteCallback = completer.complete + s.input.seed(q.Seed) + return s.terminal.ReadLine() +} + +// seedReader hands the terminal a string as though it had been typed, then goes +// back to reading the keyboard. +// +// This is what puts a rejected answer back on the line: x/term has no call for +// setting the edit buffer, and it cannot tell a seeded byte from a keystroke. +type seedReader struct { + pending []byte + src io.Reader +} + +func (r *seedReader) seed(s string) { r.pending = []byte(s) } + +func (r *seedReader) Read(p []byte) (int, error) { + if len(r.pending) > 0 { + n := copy(p, r.pending) + r.pending = r.pending[n:] + return n, nil + } + return r.src.Read(p) +} + +// cycler completes on Tab by walking the choices that match what has been typed, +// one per press, wrapping at the end. +// +// Cycling beats completing to the common prefix for a closed vocabulary of a +// dozen short words: Tab on an empty line browses the whole list, which is the +// case a prefix completer answers with nothing at all. +// +// Where session is set the first Tab on a given base prints the matches instead +// of walking them, the way a shell does. That is for a list the field never got +// to introduce — see [Question.ListChoices]. +type cycler struct { + choices []string + session Session + base string + last string + index int + active bool + listed bool +} + +// complete is an x/term AutoCompleteCallback. Any key other than Tab ends the +// current cycle, so typing after a Tab starts the next one from what is on the +// line rather than from where the walk had got to. +func (c *cycler) complete(line string, pos int, key rune) (string, int, bool) { + if key != '\t' || len(c.choices) == 0 { + c.active = false + return "", 0, false + } + typed := line[:pos] + if !c.active || typed != c.last { + c.base = typed + c.index = 0 + c.active = true + c.listed = false + } + matches := matching(c.choices, c.base) + if len(matches) == 0 { + c.active = false + return "", 0, false + } + // One match is not a list, so printing it and making the reader press Tab + // again to accept it is a step that buys nothing. + if c.session != nil && !c.listed && len(matches) > 1 { + c.listed = true + // The line is left exactly as it was, so record that rather than a + // completion — otherwise the next Tab reads it as newly typed and lists + // again forever instead of starting the walk. + c.last = typed + c.list(matches) + return "", 0, false + } + completed := matches[c.index%len(matches)] + c.index++ + c.last = completed + return completed + line[pos:], len(completed), true +} + +// list prints the matches above the prompt, in one Write. +// +// One Write, because [TerminalSession.Write] erases the prompt line, writes, +// and repaints the prompt — so every call is a repaint. Handing it a tabwriter +// directly costs one per cell and per pad, which measured 123 for 54 choices. +// A repaint against a row that already fills the terminal wraps, and the +// wrapped remnant survives the next chunk's erase, so the listing came back +// with fragments of the prompt sewn through it. Buffering makes it one repaint +// against text the terminal has entirely. +// +// The width is read here rather than when the read began. A pane resized while +// the prompt was up would otherwise lay the listing out to the width it had +// before the question was asked. +func (c *cycler) list(matches []string) { + var listing bytes.Buffer + writeColumns(&listing, c.session.Width(), matches) + _, _ = c.session.Write(listing.Bytes()) +} + +// matching returns the choices containing typed, case-insensitively, with the +// ones it is a prefix of ranked first. +// +// Substring rather than prefix alone, because a choice is often a phrase: a +// project called "Rewrite the backup scripts in Go" is found by typing +// "backup", which is how anyone would go looking for it. Prefixes still rank +// ahead, so a vocabulary of single words behaves the way a shell taught +// everyone to expect — "ho" reaches "Home" before it reaches "Chore". +func matching(choices []string, typed string) []string { + lowered := strings.ToLower(typed) + var prefixed, contained []string + for _, choice := range choices { + switch folded := strings.ToLower(choice); { + case strings.HasPrefix(folded, lowered): + prefixed = append(prefixed, choice) + case strings.Contains(folded, lowered): + contained = append(contained, choice) + } + } + return append(prefixed, contained...) +} diff --git a/prompt/validate.go b/prompt/validate.go new file mode 100644 index 0000000..1574186 --- /dev/null +++ b/prompt/validate.go @@ -0,0 +1,72 @@ +package prompt + +import ( + "fmt" + "strconv" + "strings" +) + +// OneOf accepts any of choices, case-insensitively, and returns the choice as +// the list spells it — so "chore" is stored as "Chore". +// +// Share one of these between a command's flag and its prompt. Both doors then +// refuse the same value with the same message, including the part that says +// what would have worked. +// +// What that part can be depends on how many choices there are: a dozen fit in a +// message and eighty do not. So it names the near-misses when the answer has +// any, falls back to the whole list while the list is short, and otherwise says +// how many there were rather than printing them. +func OneOf(choices []string) func(string) (string, error) { + return func(answer string) (string, error) { + for _, choice := range choices { + if strings.EqualFold(answer, choice) { + return choice, nil + } + } + if near := matching(choices, answer); len(near) > 0 && len(near) <= maxListedChoices { + return "", fmt.Errorf("unknown value %q — did you mean %s?", answer, listChoices(near)) + } + if len(choices) <= maxListedChoices { + return "", fmt.Errorf("unknown value %q — one of: %s", answer, listChoices(choices)) + } + return "", fmt.Errorf("unknown value %q — none of the %d known values match", answer, len(choices)) + } +} + +// listChoices renders candidates for an error that exists to say what to retry +// with, so each one has to be distinguishable from the punctuation between them. +// A project title carries commas and em-dashes of its own — "Garden — beds, +// paths, the shed" is one candidate — and a bare comma join leaves +// no way to tell where it ends. Quoting also shows the shell quoting a value +// with spaces needs. +func listChoices(choices []string) string { + quoted := make([]string, len(choices)) + for i, choice := range choices { + quoted[i] = strconv.Quote(choice) + } + return strings.Join(quoted, ", ") +} + +// Int accepts a base-10 whole number. +func Int(answer string) (string, error) { + n, err := strconv.Atoi(answer) + if err != nil { + return "", fmt.Errorf("%q is not a whole number", answer) + } + return strconv.Itoa(n), nil +} + +// IntAtLeast accepts a base-10 whole number no smaller than floor. +func IntAtLeast(floor int) func(string) (string, error) { + return func(answer string) (string, error) { + n, err := strconv.Atoi(answer) + if err != nil { + return "", fmt.Errorf("%q is not a whole number", answer) + } + if n < floor { + return "", fmt.Errorf("%d is below the minimum of %d", n, floor) + } + return strconv.Itoa(n), nil + } +} diff --git a/real_binary_flows_test.go b/real_binary_flows_test.go index 3d08ffa..19db595 100644 --- a/real_binary_flows_test.go +++ b/real_binary_flows_test.go @@ -2,12 +2,17 @@ package main import ( "fmt" + "io" + "log/slog" + "net/http/httptest" "os" "path/filepath" "testing" "github.com/datapointchris/tuidrive" + "github.com/datapointchris/projects/api/routes" + "github.com/datapointchris/projects/cli" "github.com/datapointchris/projects/model" "github.com/datapointchris/projects/service" "github.com/datapointchris/projects/store/sqlite" @@ -17,8 +22,9 @@ import ( // a freshly seeded database, and compares every frame it names with the goldens // under testdata/TestRealBinaryFlows/<flow>. PROJECTS_DB is relative to the program's // working directory, which is the test's temporary directory, so the status bar -// reads the same on every run and no flow can reach a real database. Write the -// goldens with TUIDRIVE_UPDATE=1. +// reads the same on every run and no flow can reach a real database. The api-mode +// flow reads its seeded database through the API's routes over HTTP, and must open +// none of its own. Write the goldens with TUIDRIVE_UPDATE=1. func TestRealBinaryFlows(t *testing.T) { binary := tuidrive.Build(t, ".") editor, err := filepath.Abs("testdata/flows/append-line-editor.sh") @@ -29,6 +35,7 @@ func TestRealBinaryFlows(t *testing.T) { for _, flow := range []struct { name, script string cols, rows int + api bool }{ {name: "browse", script: "browse.keys"}, {name: "reader", script: "reader.keys"}, @@ -40,22 +47,67 @@ func TestRealBinaryFlows(t *testing.T) { {name: "edit-note", script: "edit-note.keys"}, {name: "help-taller-than-the-window", script: "help.keys"}, {name: "narrow-window", script: "narrow-window.keys", cols: 50, rows: 14}, + {name: "api-mode", script: "api-mode.keys", api: true}, } { t.Run(flow.name, func(t *testing.T) { dir := t.TempDir() - seedFixtureDatabase(t, filepath.Join(dir, "projects.db")) + if flow.api { + served := filepath.Join(t.TempDir(), "served.db") + seedFixtureDatabase(t, served) + writeAPIConfig(t, dir, serveOverHTTP(t, served)) + } else { + seedFixtureDatabase(t, filepath.Join(dir, "projects.db")) + } term := tuidrive.Start(t, tuidrive.Program{ Path: binary, + Args: []string{"tui"}, Dir: dir, - Env: map[string]string{"PROJECTS_DB": "projects.db", "EDITOR": editor}, + Env: map[string]string{ + "PROJECTS_DB": "projects.db", + "EDITOR": editor, + "XDG_CONFIG_HOME": filepath.Join(dir, "config"), + }, Cols: flow.cols, Rows: flow.rows, }) term.RunScript(filepath.Join("testdata", "flows", flow.script)) + if _, err := os.Stat(filepath.Join(dir, "projects.db")); flow.api && err == nil { + t.Error("API mode opened a database") + } }) } } +// serveOverHTTP is the API's own routes over the database at path, reached the +// way a deployed API is. It checks no token, as the API behind a proxy that +// already did. +func serveOverHTTP(t *testing.T, path string) string { + t.Helper() + database, err := sqlite.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + quiet := slog.New(slog.NewTextHandler(io.Discard, nil)) + server := httptest.NewServer(routes.New(cli.LocalService(database, ""), quiet)) + t.Cleanup(server.Close) + return server.URL +} + +// writeAPIConfig puts the program in API mode, with a token_command standing in +// for a login. +func writeAPIConfig(t *testing.T, dir, api string) { + t.Helper() + path := filepath.Join(dir, "config", "projects", "config.toml") + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + config := fmt.Sprintf("api = %q\ntoken_command = [\"printf\", %q]\n", api, `{"access_token":"stand-in","token_type":"Bearer"}`) + if err := os.WriteFile(path, []byte(config), 0o600); err != nil { + t.Fatal(err) + } +} + // seedFixtureDatabase writes through the backend the TUI writes through with sync off, // so the fixture holds what the tool's own rules allow. The export note runs long, // because a one-line note hides layout bugs. @@ -70,7 +122,7 @@ func seedFixtureDatabase(t *testing.T, path string) { t.Fatal(err) } }() - seed := fixtureSeeder{t: t, backend: localOnlyBackend(database, filepath.Join(filepath.Dir(path), "repos.json"))} + seed := fixtureSeeder{t: t, backend: cli.LocalService(database, filepath.Join(filepath.Dir(path), "repos.json"))} blog := seed.project("Move the blog to plain files", fixtureText(t, "blog-project-description.md")) faucet := seed.project("Replace the kitchen faucet", "") diff --git a/repos/repos.go b/repos/repos.go index a096617..0ae7a8d 100644 --- a/repos/repos.go +++ b/repos/repos.go @@ -10,6 +10,7 @@ import ( "fmt" "os" "path/filepath" + "slices" "strings" "github.com/datapointchris/projects/config" @@ -24,6 +25,7 @@ type registryFile struct { // Registry is the set of known repo names, empty when the file is absent. type Registry struct { names map[string]bool + path string } // DefaultPath is where this machine keeps the registry: $PROJECTS_REPOS_REGISTRY, @@ -61,7 +63,7 @@ func DefaultPath() string { func Load(path string) (*Registry, error) { raw, err := os.ReadFile(path) if os.IsNotExist(err) { - return &Registry{}, nil + return &Registry{path: path}, nil } if err != nil { return nil, fmt.Errorf("reading repo registry %s: %w", path, err) @@ -74,7 +76,7 @@ func Load(path string) (*Registry, error) { for _, repo := range parsed.Repos { names[repo.Name] = true } - return &Registry{names: names}, nil + return &Registry{names: names, path: path}, nil } // Available reports whether the registry was found and had any repos in it. @@ -82,6 +84,42 @@ func (r *Registry) Available() bool { return r != nil && len(r.names) > 0 } +// Names is every registered repo, sorted, for a prompt to offer. +func (r *Registry) Names() []string { + if r == nil { + return nil + } + names := make([]string, 0, len(r.names)) + for name := range r.names { + names = append(names, name) + } + slices.Sort(names) + return names +} + +// Validate refuses a repo tag the registry does not hold, naming the near ones. +// An empty name is the untagged work, and a missing registry refuses nothing. +// +// On a write a mistyped tag drops the item out of every repo view, and on a read +// it selects nothing, which reads the same as a repo with no work. +func (r *Registry) Validate(name string) error { + if name == "" || !r.Available() || r.names[name] { + return nil + } + lowered := strings.ToLower(name) + var near []string + for _, known := range r.Names() { + lowerKnown := strings.ToLower(known) + if strings.Contains(lowerKnown, lowered) || strings.HasPrefix(lowered, lowerKnown) { + near = append(near, known) + } + } + if len(near) > 0 { + return fmt.Errorf("unknown repo %q — did you mean %s?", name, strings.Join(near[:min(len(near), 3)], ", ")) + } + return fmt.Errorf("unknown repo %q — %s lists the registered names", name, r.path) +} + // Knows reports whether name is a registered repo. Case- and // space-insensitive, because "Projects " names the same thing "projects" does and a // ban a capital letter walks around is decoration. diff --git a/service/service.go b/service/service.go index 9f76a6e..60eb9fb 100644 --- a/service/service.go +++ b/service/service.go @@ -943,6 +943,14 @@ func (s *Service) ListMemberships() ([]model.Membership, error) { // --- Tasks --- +func (s *Service) ListAllTasks() ([]model.ProjectItemTask, error) { + tasks, err := s.store.ListAllTasks(s.ctx()) + if err != nil { + return nil, fmt.Errorf("listing tasks: %w", err) + } + return tasks, nil +} + func (s *Service) ListTasks(itemRef string) ([]model.ProjectItemTask, error) { ctx := s.ctx() item, err := resolveItem(ctx, s.store, itemRef) diff --git a/sync/backend.go b/sync/backend.go index d7b0a54..3536f6f 100644 --- a/sync/backend.go +++ b/sync/backend.go @@ -65,6 +65,10 @@ func (s *SyncBackend) ListTasks(itemID string) ([]model.ProjectItemTask, error) return s.local.ListTasks(itemID) } +func (s *SyncBackend) ListAllTasks() ([]model.ProjectItemTask, error) { + return s.local.ListAllTasks() +} + func (s *SyncBackend) Search(query string) ([]model.ProjectItem, error) { return s.local.Search(query) } diff --git a/testdata/TestRealBinaryFlows/api-mode/item-added.style b/testdata/TestRealBinaryFlows/api-mode/item-added.style new file mode 100644 index 0000000..1803be9 --- /dev/null +++ b/testdata/TestRealBinaryFlows/api-mode/item-added.style @@ -0,0 +1,107 @@ +1: 1-40 fg=8 +1: 41-98 fg=4 +2: 1-1 fg=8 +2: 3-10 bold +2: 40-40 fg=8 +2: 41-41 fg=4 +2: 43-70 bold +2: 98-98 fg=4 +3: 1-1 fg=8 +3: 9-11 fg=3 +3: 13-14 fg=2 +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 42-74 fg=2 bold +3: 76-76 fg=6 bold +3: 78-85 fg=8 +3: 98-98 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 49-52 fg=8 strike +4: 53-53 strike +4: 54-56 fg=8 strike +4: 57-57 strike +4: 58-65 fg=8 strike +4: 98-98 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 98-98 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 98-98 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 45-45 fg=8 strike +7: 46-46 strike +7: 47-50 fg=8 strike +7: 51-51 strike +7: 52-52 fg=8 strike +7: 53-53 strike +7: 54-58 fg=8 strike +7: 59-60 strike +7: 61-61 fg=8 strike +7: 98-98 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 82-82 fg=8 +8: 98-98 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 47-94 fg=1 +9: 98-98 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 74-74 fg=8 +10: 98-98 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 47-97 fg=1 +11: 98-98 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 68-69 fg=8 +12: 98-98 fg=4 +13: 1-40 fg=8 +13: 41-98 fg=4 +14: 1-40 fg=8 +14: 41-98 fg=4 +15: 1-40 fg=8 +15: 41-98 fg=4 +16: 1-40 fg=8 +16: 41-98 fg=4 +17: 1-40 fg=8 +17: 41-98 fg=4 +18: 1-40 fg=8 +18: 41-98 fg=4 +19: 1-40 fg=8 +19: 41-98 fg=4 +20: 1-40 fg=8 +20: 41-98 fg=4 +21: 1-40 fg=8 +21: 41-98 fg=4 +22: 1-40 fg=8 +22: 41-98 fg=4 +23: 1-40 fg=8 +23: 41-98 fg=4 +24: 1-40 fg=8 +24: 41-98 fg=4 +25: 1-40 fg=8 +25: 41-98 fg=4 +26: 1-40 fg=8 +26: 41-98 fg=4 +27: 1-40 fg=8 +27: 41-98 fg=4 +28: 1-40 fg=8 +28: 41-98 fg=4 +29: 1-40 fg=8 +29: 41-98 fg=4 +30: 3-14 fg=2 +30: 80-82 fg=4 bold diff --git a/testdata/TestRealBinaryFlows/api-mode/item-added.txt b/testdata/TestRealBinaryFlows/api-mode/item-added.txt new file mode 100644 index 0000000..0c36653 --- /dev/null +++ b/testdata/TestRealBinaryFlows/api-mode/item-added.txt @@ -0,0 +1,30 @@ +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│ All ○38 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/3] 1 ┃ +│> Move the blog to plain files ○4 ✓1 │┃ ✓ Dump the database ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme 2 ┃ +│ │┃ ○ Set up redirects for the old URLs 3 ┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host 4 ┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ○ Rotate the API keys 39 ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + Item created [?]help API 127.0.0.1:<port> diff --git a/testdata/TestRealBinaryFlows/api-mode/opened.style b/testdata/TestRealBinaryFlows/api-mode/opened.style new file mode 100644 index 0000000..8c0181f --- /dev/null +++ b/testdata/TestRealBinaryFlows/api-mode/opened.style @@ -0,0 +1,104 @@ +1: 1-40 fg=4 +1: 41-98 fg=8 +2: 1-1 fg=4 +2: 3-10 bold +2: 40-40 fg=4 +2: 41-41 fg=8 +2: 43-70 bold +2: 98-98 fg=8 +3: 1-1 fg=4 +3: 9-11 fg=3 +3: 13-14 fg=2 +3: 40-40 fg=4 +3: 41-41 fg=8 +3: 42-74 fg=2 bold +3: 76-76 fg=6 bold +3: 78-98 fg=8 +4: 1-1 fg=4 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=4 +4: 41-41 fg=8 +4: 49-52 fg=8 strike +4: 53-53 strike +4: 54-56 fg=8 strike +4: 57-57 strike +4: 58-65 fg=8 strike +4: 98-98 fg=8 +5: 1-1 fg=4 +5: 32-33 fg=3 +5: 35-36 fg=8 +5: 40-40 fg=4 +5: 41-41 fg=8 +5: 98-98 fg=8 +6: 1-1 fg=4 +6: 26-28 fg=3 +6: 30-31 fg=8 +6: 40-40 fg=4 +6: 41-41 fg=8 +6: 98-98 fg=8 +7: 1-40 fg=4 +7: 41-41 fg=8 +7: 45-45 fg=8 strike +7: 46-46 strike +7: 47-50 fg=8 strike +7: 51-51 strike +7: 52-52 fg=8 strike +7: 53-53 strike +7: 54-58 fg=8 strike +7: 59-60 strike +7: 61-61 fg=8 strike +7: 98-98 fg=8 +8: 1-40 fg=4 +8: 41-41 fg=8 +8: 82-98 fg=8 +9: 1-40 fg=4 +9: 41-41 fg=8 +9: 47-94 fg=1 +9: 98-98 fg=8 +10: 1-40 fg=4 +10: 41-41 fg=8 +10: 74-98 fg=8 +11: 1-40 fg=4 +11: 41-41 fg=8 +11: 47-97 fg=1 +11: 98-98 fg=8 +12: 1-40 fg=4 +12: 41-98 fg=8 +13: 1-40 fg=4 +13: 41-98 fg=8 +14: 1-40 fg=4 +14: 41-98 fg=8 +15: 1-40 fg=4 +15: 41-98 fg=8 +16: 1-40 fg=4 +16: 41-98 fg=8 +17: 1-40 fg=4 +17: 41-98 fg=8 +18: 1-40 fg=4 +18: 41-98 fg=8 +19: 1-40 fg=4 +19: 41-98 fg=8 +20: 1-40 fg=4 +20: 41-98 fg=8 +21: 1-40 fg=4 +21: 41-98 fg=8 +22: 1-40 fg=4 +22: 41-98 fg=8 +23: 1-40 fg=4 +23: 41-98 fg=8 +24: 1-40 fg=4 +24: 41-98 fg=8 +25: 1-40 fg=4 +25: 41-98 fg=8 +26: 1-40 fg=4 +26: 41-98 fg=8 +27: 1-40 fg=4 +27: 41-98 fg=8 +28: 1-40 fg=4 +28: 41-98 fg=8 +29: 1-40 fg=4 +29: 41-98 fg=8 +30: 3-77 fg=8 +30: 80-82 fg=4 bold diff --git a/testdata/TestRealBinaryFlows/api-mode/opened.txt b/testdata/TestRealBinaryFlows/api-mode/opened.txt new file mode 100644 index 0000000..ea7feef --- /dev/null +++ b/testdata/TestRealBinaryFlows/api-mode/opened.txt @@ -0,0 +1,30 @@ +┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓╭────────────────────────────────────────────────────────╮ +┃ Projects ┃│ Move the blog to plain files │ +┃ All ○37 ✓1 ┃│> ○ Export posts from the old CMS ≡ [1/3] 1 │ +┃> Move the blog to plain files ○3 ✓1 ┃│ ✓ Dump the database │ +┃ Replace the kitchen faucet ○3 ✓0 ┃│ ○ Convert HTML to markdown │ +┃ Plan the Lisbon trip ○31 ✓0 ┃│ ○ Download the images │ +┃ ┃│ ✓ Pick a theme 2 │ +┃ ┃│ ○ Set up redirects for the old URLs 3 │ +┃ ┃│ └─ blocked by: Export posts from the old CMS (1) │ +┃ ┃│ ○ Point DNS at the new host 4 │ +┃ ┃│ └─ blocked by: Set up redirects for the old UR… (3)│ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┃ ┃│ │ +┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛╰────────────────────────────────────────────────────────╯ + [a]dd project [Enter]select [m]ove [Tab]items [T]ree [/]search [?]help API 127.0.0.1:<port> diff --git a/testdata/TestRealBinaryFlows/api-mode/read-again.style b/testdata/TestRealBinaryFlows/api-mode/read-again.style new file mode 100644 index 0000000..0ab1722 --- /dev/null +++ b/testdata/TestRealBinaryFlows/api-mode/read-again.style @@ -0,0 +1,107 @@ +1: 1-40 fg=8 +1: 41-98 fg=4 +2: 1-1 fg=8 +2: 3-10 bold +2: 40-40 fg=8 +2: 41-41 fg=4 +2: 43-70 bold +2: 98-98 fg=4 +3: 1-1 fg=8 +3: 9-11 fg=3 +3: 13-14 fg=2 +3: 40-40 fg=8 +3: 41-41 fg=4 +3: 42-74 fg=2 bold +3: 76-76 fg=6 bold +3: 78-85 fg=8 +3: 98-98 fg=4 +4: 1-1 fg=8 +4: 2-31 fg=2 bold +4: 33-34 fg=3 +4: 36-37 fg=2 +4: 40-40 fg=8 +4: 41-41 fg=4 +4: 49-52 fg=8 strike +4: 53-53 strike +4: 54-56 fg=8 strike +4: 57-57 strike +4: 58-65 fg=8 strike +4: 98-98 fg=4 +5: 1-1 fg=8 +5: 32-33 fg=3 +5: 35-40 fg=8 +5: 41-41 fg=4 +5: 98-98 fg=4 +6: 1-1 fg=8 +6: 26-28 fg=3 +6: 30-40 fg=8 +6: 41-41 fg=4 +6: 98-98 fg=4 +7: 1-40 fg=8 +7: 41-41 fg=4 +7: 45-45 fg=8 strike +7: 46-46 strike +7: 47-50 fg=8 strike +7: 51-51 strike +7: 52-52 fg=8 strike +7: 53-53 strike +7: 54-58 fg=8 strike +7: 59-60 strike +7: 61-61 fg=8 strike +7: 98-98 fg=4 +8: 1-40 fg=8 +8: 41-41 fg=4 +8: 82-82 fg=8 +8: 98-98 fg=4 +9: 1-40 fg=8 +9: 41-41 fg=4 +9: 47-94 fg=1 +9: 98-98 fg=4 +10: 1-40 fg=8 +10: 41-41 fg=4 +10: 74-74 fg=8 +10: 98-98 fg=4 +11: 1-40 fg=8 +11: 41-41 fg=4 +11: 47-97 fg=1 +11: 98-98 fg=4 +12: 1-40 fg=8 +12: 41-41 fg=4 +12: 68-69 fg=8 +12: 98-98 fg=4 +13: 1-40 fg=8 +13: 41-98 fg=4 +14: 1-40 fg=8 +14: 41-98 fg=4 +15: 1-40 fg=8 +15: 41-98 fg=4 +16: 1-40 fg=8 +16: 41-98 fg=4 +17: 1-40 fg=8 +17: 41-98 fg=4 +18: 1-40 fg=8 +18: 41-98 fg=4 +19: 1-40 fg=8 +19: 41-98 fg=4 +20: 1-40 fg=8 +20: 41-98 fg=4 +21: 1-40 fg=8 +21: 41-98 fg=4 +22: 1-40 fg=8 +22: 41-98 fg=4 +23: 1-40 fg=8 +23: 41-98 fg=4 +24: 1-40 fg=8 +24: 41-98 fg=4 +25: 1-40 fg=8 +25: 41-98 fg=4 +26: 1-40 fg=8 +26: 41-98 fg=4 +27: 1-40 fg=8 +27: 41-98 fg=4 +28: 1-40 fg=8 +28: 41-98 fg=4 +29: 1-40 fg=8 +29: 41-98 fg=4 +30: 3-17 fg=2 +30: 81-83 fg=4 bold diff --git a/testdata/TestRealBinaryFlows/api-mode/read-again.txt b/testdata/TestRealBinaryFlows/api-mode/read-again.txt new file mode 100644 index 0000000..ef213e1 --- /dev/null +++ b/testdata/TestRealBinaryFlows/api-mode/read-again.txt @@ -0,0 +1,30 @@ +╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ +│ Projects │┃ Move the blog to plain files ┃ +│ All ○38 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/3] 1 ┃ +│> Move the blog to plain files ○4 ✓1 │┃ ✓ Dump the database ┃ +│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ +│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ +│ │┃ ✓ Pick a theme 2 ┃ +│ │┃ ○ Set up redirects for the old URLs 3 ┃ +│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ +│ │┃ ○ Point DNS at the new host 4 ┃ +│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ +│ │┃ ○ Rotate the API keys 39 ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +│ │┃ ┃ +╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ + Read again from 127.0.0.1:<port> [?]help API 127.0.0.1:<port> diff --git a/testdata/flows/api-mode.keys b/testdata/flows/api-mode.keys new file mode 100644 index 0000000..436ecb2 --- /dev/null +++ b/testdata/flows/api-mode.keys @@ -0,0 +1,22 @@ +# The board in API mode: every read and write goes over HTTP to the API's routes. +# The status line names the API's host, whose port changes between runs. +mask /127\.0\.0\.1:\d+/ "127.0.0.1:<port>" +wait Export posts from the old CMS +wait /API 127\.0\.0\.1/ +frame opened + +press tab +wait /(?m)^╭.*┏/ +press a +wait Enter item title +type Rotate the API keys +press enter +wait ○ Rotate the API keys +frame item-added + +press R +wait Read again from +frame read-again + +press q +exits diff --git a/tui/app.go b/tui/app.go index 9dd2279..c644b0b 100644 --- a/tui/app.go +++ b/tui/app.go @@ -126,13 +126,20 @@ type App struct { syncStatus sync.SyncStatus syncInterval time.Duration - // Display info - dbPath string + where Where } -// NewApp creates a new TUI application backed by the given Backend. -// syncEngine may be nil when sync is disabled. -func NewApp(b backend.Backend, syncEngine *sync.Engine, dbPath string, syncInterval time.Duration) *App { +// Where names the data the board shows, for its status line: a database on this +// machine, or the API at APIHost. A database with sync on is named by its engine. +type Where struct { + DatabasePath string + APIHost string +} + +// NewApp creates a new TUI application backed by the given Backend. syncEngine +// is nil unless the database syncs. syncInterval is how often the board rereads +// what it shows: a pull with sync on, and a read of the API in API mode. +func NewApp(b backend.Backend, syncEngine *sync.Engine, where Where, syncInterval time.Duration) *App { ti := textinput.New() ti.CharLimit = 200 ti.SetStyles(inputStyles()) @@ -150,7 +157,7 @@ func NewApp(b backend.Backend, syncEngine *sync.Engine, dbPath string, syncInter depFilter: df, syncEngine: syncEngine, syncInterval: syncInterval, - dbPath: dbPath, + where: where, } } @@ -439,27 +446,9 @@ func fetchItemsCmd(b backend.Backend, projectID string, filter filterMode) tea.C } } - hasIncompleteTasks := make(map[string]bool) - taskCounts := make(map[string][2]int) - allTasks := make(map[string][]model.ProjectItemTask) - for _, item := range items { - tasks, err := b.ListTasks(item.ID) - if err != nil { - return errMsg{err} - } - if len(tasks) > 0 { - allTasks[item.ID] = tasks - done := 0 - for _, t := range tasks { - if t.Completed { - done++ - } - } - taskCounts[item.ID] = [2]int{done, len(tasks)} - if done < len(tasks) { - hasIncompleteTasks[item.ID] = true - } - } + allTasks, taskCounts, hasIncompleteTasks, err := taskSummary(b, items) + if err != nil { + return errMsg{err} } if filter == filterBlocked { @@ -476,6 +465,42 @@ func fetchItemsCmd(b backend.Backend, projectID string, filter filterMode) tea.C } } +// taskSummary is each listed item's tasks, its done and total counts, and +// whether any task is still open, from one read of every task. +func taskSummary(b backend.Backend, items []model.ProjectItemInProject) ( + tasks map[string][]model.ProjectItemTask, counts map[string][2]int, open map[string]bool, err error, +) { + every, err := b.ListAllTasks() + if err != nil { + return nil, nil, nil, err + } + listed := make(map[string]bool, len(items)) + for _, item := range items { + listed[item.ID] = true + } + tasks = make(map[string][]model.ProjectItemTask) + for _, task := range every { + if listed[task.ItemID] { + tasks[task.ItemID] = append(tasks[task.ItemID], task) + } + } + counts = make(map[string][2]int, len(tasks)) + open = make(map[string]bool) + for id, held := range tasks { + done := 0 + for _, task := range held { + if task.Completed { + done++ + } + } + counts[id] = [2]int{done, len(held)} + if done < len(held) { + open[id] = true + } + } + return tasks, counts, open, nil +} + func fetchAllItemsCmd(b backend.Backend, projects []model.ProjectWithItemCount, filter filterMode) tea.Cmd { return func() tea.Msg { var groups []allItemGroup @@ -537,32 +562,9 @@ func fetchAllItemsCmd(b backend.Backend, projects []model.ProjectWithItemCount, } } - hasIncompleteTasks := make(map[string]bool) - taskCounts := make(map[string][2]int) - allTasks := make(map[string][]model.ProjectItemTask) - seen := make(map[string]bool) - for _, item := range allItems { - if seen[item.ID] { - continue - } - seen[item.ID] = true - tasks, err := b.ListTasks(item.ID) - if err != nil { - return errMsg{err} - } - if len(tasks) > 0 { - allTasks[item.ID] = tasks - done := 0 - for _, t := range tasks { - if t.Completed { - done++ - } - } - taskCounts[item.ID] = [2]int{done, len(tasks)} - if done < len(tasks) { - hasIncompleteTasks[item.ID] = true - } - } + allTasks, taskCounts, hasIncompleteTasks, err := taskSummary(b, allItems) + if err != nil { + return errMsg{err} } if filter == filterBlocked { @@ -999,6 +1001,9 @@ func (m *App) Init() tea.Cmd { syncPullTickCmd(m.syncInterval), ) } + if m.where.APIHost != "" { + return tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), syncPullTickCmd(m.syncInterval)) + } return fetchProjectsCmd(m.backend, m.projectStatusFilter()) } @@ -1249,10 +1254,16 @@ func (m *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case syncPullTickMsg: next := syncPullTickCmd(m.syncInterval) - if m.syncEngine == nil || !m.safeToAutoPull() { + switch { + case !m.safeToAutoPull(): return m, next + case m.syncEngine != nil: + return m, tea.Batch(syncPullCmd(m.syncEngine, false), next) + case m.where.APIHost != "": + // Another client may have written since the last read. + return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), next) } - return m, tea.Batch(syncPullCmd(m.syncEngine, false), next) + return m, next case syncPullDoneMsg: if !msg.manual { @@ -1615,11 +1626,14 @@ func (m *App) handleNormalKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { // --- Phase 6: Advanced --- case "R": - if m.syncEngine == nil { - flashCmd := m.flash("Sync is disabled — running local-only") - return m, flashCmd + switch { + case m.syncEngine != nil: + return m, syncPullCmd(m.syncEngine, true) + case m.where.APIHost != "": + return m, tea.Batch(fetchProjectsCmd(m.backend, m.projectStatusFilter()), m.flash("Read again from "+m.where.APIHost)) } - return m, syncPullCmd(m.syncEngine, true) + disabled := m.flash("Sync is disabled — running local-only") + return m, disabled case "?": m.appMode = modeHelp @@ -3309,7 +3323,8 @@ func clipHints(hints string, maxWidth int) string { func (m *App) renderStatusBar() string { var modeStr string - if m.syncEngine != nil { + switch { + case m.syncEngine != nil: target := dimStyle.Render(" " + m.syncEngine.APIURL()) s := m.syncStatus switch { @@ -3322,8 +3337,10 @@ func (m *App) renderStatusBar() string { default: modeStr = syncOKStyle.Render("SYNCED") + target } - } else { - modeStr = modeLocalStyle.Render("LOCAL") + dimStyle.Render(" "+m.dbPath) + case m.where.APIHost != "": + modeStr = modeAPIStyle.Render("API") + dimStyle.Render(" "+m.where.APIHost) + default: + modeStr = modeLocalStyle.Render("LOCAL") + dimStyle.Render(" "+m.where.DatabasePath) } // [?]help sits on the right, with the things that are always true, rather diff --git a/tui/app_test.go b/tui/app_test.go index 291b865..bea13e5 100644 --- a/tui/app_test.go +++ b/tui/app_test.go @@ -58,7 +58,7 @@ func newTestAppWithItems(t *testing.T, width, height int, inputs []model.CreateP } } - app := NewApp(b, nil, ":memory:", 2*time.Minute) + app := NewApp(b, nil, Where{DatabasePath: ":memory:"}, 2*time.Minute) send(t, app, tea.WindowSizeMsg{Width: width, Height: height}) send(t, app, app.Init()()) send(t, app, app.fetchItems()()) @@ -389,7 +389,7 @@ func TestSyncTickReconcilesWithTheServer(t *testing.T) { engine := sync.New(database, ts.URL, "") t.Cleanup(engine.Stop) - app := NewApp(service.New(sqlite.New(database)), engine, ":memory:", 20*time.Millisecond) + app := NewApp(service.New(sqlite.New(database)), engine, Where{DatabasePath: ":memory:"}, 20*time.Millisecond) send(t, app, tea.WindowSizeMsg{Width: 80, Height: 24}) countHits := func() int { diff --git a/tui/realdata_test.go b/tui/realdata_test.go index c91f2b2..26c2e2f 100644 --- a/tui/realdata_test.go +++ b/tui/realdata_test.go @@ -41,7 +41,7 @@ func TestRealDatabaseSurvivesEverySizeAndProject(t *testing.T) { for _, size := range sizes { width, height := size[0], size[1] - app := NewApp(service.New(sqlite.New(database)), nil, path, 2*time.Minute) + app := NewApp(service.New(sqlite.New(database)), nil, Where{DatabasePath: path}, 2*time.Minute) send(t, app, tea.WindowSizeMsg{Width: width, Height: height}) send(t, app, app.Init()()) app.activePane = itemPane diff --git a/tui/styles.go b/tui/styles.go index 56d591f..911e327 100644 --- a/tui/styles.go +++ b/tui/styles.go @@ -119,6 +119,9 @@ var ( modeLocalStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("2")). Bold(true) + modeAPIStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("4")). + Bold(true) syncOKStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("2")). Bold(true) diff --git a/tui/without_terminal.go b/tui/without_terminal.go new file mode 100644 index 0000000..6a7d002 --- /dev/null +++ b/tui/without_terminal.go @@ -0,0 +1,49 @@ +package tui + +import ( + tea "charm.land/bubbletea/v2" + + "github.com/datapointchris/projects/model" +) + +// Once reads the board the way it opens and returns the frame it draws at width +// by height, for a caller with no terminal. A failed read is returned rather than +// drawn, so it reaches the exit code. +func (m *App) Once(width, height int) (string, error) { + m.Update(tea.WindowSizeMsg{Width: width, Height: height}) + if err := m.Read(); err != nil { + return "", err + } + return lastRows(m.screen(), height), nil +} + +// Read runs the board's opening reads in this goroutine: the projects, then the +// items of the one under the cursor. +func (m *App) Read() error { + if err := m.apply(fetchProjectsCmd(m.backend, m.projectStatusFilter())); err != nil { + return err + } + if cmd := m.fetchItems(); cmd != nil { + return m.apply(cmd) + } + return nil +} + +func (m *App) apply(cmd tea.Cmd) error { + msg := cmd() + if failed, ok := msg.(errMsg); ok { + return failed.error + } + m.Update(msg) + return nil +} + +// Shown is what the board holds after Read: its projects, the one under the +// cursor, and that project's items as the pane lists them. +func (m *App) Shown() ([]model.ProjectWithItemCount, *model.ProjectWithItemCount, []model.ProjectItemInProject) { + var selected *model.ProjectWithItemCount + if m.projectCursor < len(m.projects) { + selected = &m.projects[m.projectCursor] + } + return m.projects, selected, m.items +} From 213badfd4174d601f307b032918dbca6b8a3a3d5 Mon Sep 17 00:00:00 2001 From: Chris Birch <datapointchris@gmail.com> Date: Sat, 7 Nov 2026 17:03:25 -0500 Subject: [PATCH 2/7] fix: say pass it when items create misses one flag The --no-input refusal named one missing flag and still said to pass them. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b7cb-45d0-73ce-a2e4-69fe97cd3689 Worklog: 2402.1 --- cli/item_create.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/cli/item_create.go b/cli/item_create.go index f38a967..bc6b40a 100644 --- a/cli/item_create.go +++ b/cli/item_create.go @@ -42,8 +42,12 @@ func newItemsCreateCommand() *cobra.Command { // Ahead of opening anything, so a caller that cannot be asked is told // which flag it left out. if len(missing) > 0 && !interactive(cmd) { - return goclikit.UsageError(fmt.Errorf("%s required — pass them, or run from a terminal to be asked", - strings.Join(missing, " and "))) + pronoun := "it" + if len(missing) > 1 { + pronoun = "them" + } + return goclikit.UsageError(fmt.Errorf("%s required — pass %s, or run from a terminal to be asked", + strings.Join(missing, " and "), pronoun)) } source, err := openForWriting(cmd) if err != nil { From 4305425bb4363604c1a879d85ed38eafd47896e7 Mon Sep 17 00:00:00 2001 From: Chris Birch <datapointchris@gmail.com> Date: Sat, 7 Nov 2026 17:04:56 -0500 Subject: [PATCH 3/7] docs: state which failures the client retries before it exits 4 A 500 exits 4 at once, and only a read retries an answer it lost. The table read as if every 5xx were tried three times. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b7cb-45d0-73ce-a2e4-69fe97cd3689 Worklog: 2402.1 --- README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 082342d..abf69ca 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,11 @@ projects sync | version | update | completion | `0` | Done | | `1` | Refused or failed: not found, not logged in, a rule said no | | `2` | A usage error: an unknown flag, a missing argument, a value the API refused as input | -| `4` | The API never answered: refused connections, timeouts, `429` and `5xx` after three attempts | +| `4` | The API gave no answer: a refused connection, a timeout, a `429` or a `5xx` | + +A read tries three times before it exits 4, on a failed connection, an answer cut short, or a `429`, +`502`, `503` or `504`. A write tries again only where the connection never opened, so it is never sent +twice. ## Through an API, a login on this machine carries every command From 597d841f37305d7533a2fccc2a5dd8642b925d6a Mon Sep 17 00:00:00 2001 From: Chris Birch <datapointchris@gmail.com> Date: Sat, 7 Nov 2026 17:32:30 -0500 Subject: [PATCH 4/7] refactor: model owns which items the board hides and how it sorts archived ones The service and the API client each filtered archived items out of a project's list and a search, and each sorted the archive. The two copies had to agree for the board to read the same in both modes. model.WithoutArchived and model.SortArchivedForBoard are now the one statement of each rule, and both backends call them. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1 --- apiclient/backend.go | 7 +++---- model/project_item.go | 19 ++++++++++++++++++- service/service.go | 6 +++--- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apiclient/backend.go b/apiclient/backend.go index 42ca924..9a6eebc 100644 --- a/apiclient/backend.go +++ b/apiclient/backend.go @@ -2,7 +2,6 @@ package apiclient import ( "context" - "slices" "time" "github.com/datapointchris/projects/api/wire" @@ -102,7 +101,7 @@ func (b *Backend) ListItemsByProject(projectID string) ([]model.ProjectItemInPro if err != nil { return nil, err } - return slices.DeleteFunc(items, func(item model.ProjectItemInProject) bool { return item.Archived }), nil + return model.WithoutArchived(items), nil } func (b *Backend) projectItems(projectID, status string) ([]model.ProjectItemInProject, error) { @@ -261,7 +260,7 @@ func (b *Backend) Search(query string) ([]model.ProjectItem, error) { if err != nil { return nil, err } - return slices.DeleteFunc(items, func(item model.ProjectItem) bool { return item.Archived }), nil + return model.WithoutArchived(items), nil } func (b *Backend) ListBlocked() ([]model.ProjectItem, error) { @@ -273,7 +272,7 @@ func (b *Backend) ListArchived(projectID string) ([]model.ProjectItemInProject, if err != nil { return nil, err } - slices.SortStableFunc(archived, func(a, b model.ProjectItemInProject) int { return b.UpdatedAt.Compare(a.UpdatedAt) }) + model.SortArchivedForBoard(archived) return archived, nil } diff --git a/model/project_item.go b/model/project_item.go index b521225..76a6965 100644 --- a/model/project_item.go +++ b/model/project_item.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "slices" + "time" +) // An item's status is derived from two stored booleans. These three words and // StatusAll are the filter's whole vocabulary. @@ -66,6 +69,20 @@ type ProjectItemInProject struct { ProjectCount int `json:"project_count,omitempty"` } +func (i ProjectItem) archived() bool { return i.Archived } + +// WithoutArchived drops the archived items, which the board's item lists and its +// search leave out. +func WithoutArchived[T interface{ archived() bool }](items []T) []T { + return slices.DeleteFunc(items, func(item T) bool { return item.archived() }) +} + +// SortArchivedForBoard orders archived items most recently updated first, as the +// board lists them. +func SortArchivedForBoard(items []ProjectItemInProject) { + slices.SortStableFunc(items, func(a, b ProjectItemInProject) int { return b.UpdatedAt.Compare(a.UpdatedAt) }) +} + // ItemFilter narrows a list of items. Status is an item status or StatusAll. An // empty Repo keeps the untagged items, and a nil one keeps every item. Since and // Until bound CompletedAt, half-open, and an item with none is outside any diff --git a/service/service.go b/service/service.go index 60eb9fb..4521393 100644 --- a/service/service.go +++ b/service/service.go @@ -462,7 +462,7 @@ func (s *Service) ListItemsByProject(projectRef string) ([]model.ProjectItemInPr if err != nil { return nil, err } - return slices.DeleteFunc(items, func(item model.ProjectItemInProject) bool { return item.Archived }), nil + return model.WithoutArchived(items), nil } // GetItem answers an item with everything filed against it. @@ -1081,7 +1081,7 @@ func (s *Service) Search(query string) ([]model.ProjectItem, error) { if err != nil { return nil, err } - return slices.DeleteFunc(items, func(item model.ProjectItem) bool { return item.Archived }), nil + return model.WithoutArchived(items), nil } // --- Filters --- @@ -1100,6 +1100,6 @@ func (s *Service) ListArchived(projectRef string) ([]model.ProjectItemInProject, if err != nil { return nil, err } - slices.SortStableFunc(archived, func(a, b model.ProjectItemInProject) int { return b.UpdatedAt.Compare(a.UpdatedAt) }) + model.SortArchivedForBoard(archived) return archived, nil } From 95c42520654be8ae544b045f07aa37460ec68c1b Mon Sep 17 00:00:00 2001 From: Chris Birch <datapointchris@gmail.com> Date: Sat, 7 Nov 2026 17:32:53 -0500 Subject: [PATCH 5/7] test: pin R re-reading the API with a model test, not a flow frame The api-mode flow pressed R against an API nothing else wrote to, so its frame matched the one before it and could not fail. The model test writes an item behind the board's back, checks it is absent, presses R, and checks it arrived. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1 --- .../api-mode/read-again.style | 107 ------------------ .../api-mode/read-again.txt | 30 ----- testdata/flows/api-mode.keys | 4 - tui/app_test.go | 28 +++++ 4 files changed, 28 insertions(+), 141 deletions(-) delete mode 100644 testdata/TestRealBinaryFlows/api-mode/read-again.style delete mode 100644 testdata/TestRealBinaryFlows/api-mode/read-again.txt diff --git a/testdata/TestRealBinaryFlows/api-mode/read-again.style b/testdata/TestRealBinaryFlows/api-mode/read-again.style deleted file mode 100644 index 0ab1722..0000000 --- a/testdata/TestRealBinaryFlows/api-mode/read-again.style +++ /dev/null @@ -1,107 +0,0 @@ -1: 1-40 fg=8 -1: 41-98 fg=4 -2: 1-1 fg=8 -2: 3-10 bold -2: 40-40 fg=8 -2: 41-41 fg=4 -2: 43-70 bold -2: 98-98 fg=4 -3: 1-1 fg=8 -3: 9-11 fg=3 -3: 13-14 fg=2 -3: 40-40 fg=8 -3: 41-41 fg=4 -3: 42-74 fg=2 bold -3: 76-76 fg=6 bold -3: 78-85 fg=8 -3: 98-98 fg=4 -4: 1-1 fg=8 -4: 2-31 fg=2 bold -4: 33-34 fg=3 -4: 36-37 fg=2 -4: 40-40 fg=8 -4: 41-41 fg=4 -4: 49-52 fg=8 strike -4: 53-53 strike -4: 54-56 fg=8 strike -4: 57-57 strike -4: 58-65 fg=8 strike -4: 98-98 fg=4 -5: 1-1 fg=8 -5: 32-33 fg=3 -5: 35-40 fg=8 -5: 41-41 fg=4 -5: 98-98 fg=4 -6: 1-1 fg=8 -6: 26-28 fg=3 -6: 30-40 fg=8 -6: 41-41 fg=4 -6: 98-98 fg=4 -7: 1-40 fg=8 -7: 41-41 fg=4 -7: 45-45 fg=8 strike -7: 46-46 strike -7: 47-50 fg=8 strike -7: 51-51 strike -7: 52-52 fg=8 strike -7: 53-53 strike -7: 54-58 fg=8 strike -7: 59-60 strike -7: 61-61 fg=8 strike -7: 98-98 fg=4 -8: 1-40 fg=8 -8: 41-41 fg=4 -8: 82-82 fg=8 -8: 98-98 fg=4 -9: 1-40 fg=8 -9: 41-41 fg=4 -9: 47-94 fg=1 -9: 98-98 fg=4 -10: 1-40 fg=8 -10: 41-41 fg=4 -10: 74-74 fg=8 -10: 98-98 fg=4 -11: 1-40 fg=8 -11: 41-41 fg=4 -11: 47-97 fg=1 -11: 98-98 fg=4 -12: 1-40 fg=8 -12: 41-41 fg=4 -12: 68-69 fg=8 -12: 98-98 fg=4 -13: 1-40 fg=8 -13: 41-98 fg=4 -14: 1-40 fg=8 -14: 41-98 fg=4 -15: 1-40 fg=8 -15: 41-98 fg=4 -16: 1-40 fg=8 -16: 41-98 fg=4 -17: 1-40 fg=8 -17: 41-98 fg=4 -18: 1-40 fg=8 -18: 41-98 fg=4 -19: 1-40 fg=8 -19: 41-98 fg=4 -20: 1-40 fg=8 -20: 41-98 fg=4 -21: 1-40 fg=8 -21: 41-98 fg=4 -22: 1-40 fg=8 -22: 41-98 fg=4 -23: 1-40 fg=8 -23: 41-98 fg=4 -24: 1-40 fg=8 -24: 41-98 fg=4 -25: 1-40 fg=8 -25: 41-98 fg=4 -26: 1-40 fg=8 -26: 41-98 fg=4 -27: 1-40 fg=8 -27: 41-98 fg=4 -28: 1-40 fg=8 -28: 41-98 fg=4 -29: 1-40 fg=8 -29: 41-98 fg=4 -30: 3-17 fg=2 -30: 81-83 fg=4 bold diff --git a/testdata/TestRealBinaryFlows/api-mode/read-again.txt b/testdata/TestRealBinaryFlows/api-mode/read-again.txt deleted file mode 100644 index ef213e1..0000000 --- a/testdata/TestRealBinaryFlows/api-mode/read-again.txt +++ /dev/null @@ -1,30 +0,0 @@ -╭──────────────────────────────────────╮┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ -│ Projects │┃ Move the blog to plain files ┃ -│ All ○38 ✓1 │┃> ○ Export posts from the old CMS ≡ [1/3] 1 ┃ -│> Move the blog to plain files ○4 ✓1 │┃ ✓ Dump the database ┃ -│ Replace the kitchen faucet ○3 ✓0 │┃ ○ Convert HTML to markdown ┃ -│ Plan the Lisbon trip ○31 ✓0 │┃ ○ Download the images ┃ -│ │┃ ✓ Pick a theme 2 ┃ -│ │┃ ○ Set up redirects for the old URLs 3 ┃ -│ │┃ └─ blocked by: Export posts from the old CMS (1) ┃ -│ │┃ ○ Point DNS at the new host 4 ┃ -│ │┃ └─ blocked by: Set up redirects for the old UR… (3)┃ -│ │┃ ○ Rotate the API keys 39 ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -│ │┃ ┃ -╰──────────────────────────────────────╯┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ - Read again from 127.0.0.1:<port> [?]help API 127.0.0.1:<port> diff --git a/testdata/flows/api-mode.keys b/testdata/flows/api-mode.keys index 436ecb2..9885651 100644 --- a/testdata/flows/api-mode.keys +++ b/testdata/flows/api-mode.keys @@ -14,9 +14,5 @@ press enter wait ○ Rotate the API keys frame item-added -press R -wait Read again from -frame read-again - press q exits diff --git a/tui/app_test.go b/tui/app_test.go index bea13e5..c7ac2dc 100644 --- a/tui/app_test.go +++ b/tui/app_test.go @@ -120,6 +120,34 @@ func pressKey(t *testing.T, app *App, key string) { send(t, app, runeKey([]rune(key)[0])) } +// Through an API, an item another machine wrote reaches the board only when it +// reads the API again, which R does at once. +func TestRInAPIModeShowsAnItemAnotherMachineWrote(t *testing.T) { + app := newTestApp(t, 80, 24, "here at launch") + app.where = Where{APIHost: "projects.example.com"} + projects, err := app.backend.ListProjectsByStatus(model.StatusActive) + if err != nil || len(projects) == 0 { + t.Fatalf("listing projects: %v", err) + } + if _, err := app.backend.CreateItem(model.CreateProjectItem{Title: "written elsewhere", ProjectIDs: []string{projects[0].ID}}); err != nil { + t.Fatal(err) + } + titles := func() []string { + var held []string + for _, item := range app.items { + held = append(held, item.Title) + } + return held + } + if slices.Contains(titles(), "written elsewhere") { + t.Fatal("the board showed the item before reading the API again") + } + pressKey(t, app, "R") + if !slices.Contains(titles(), "written elsewhere") { + t.Errorf("after R the board holds %q, want the item written elsewhere", titles()) + } +} + // A position counts every member of a project and this list hides the archived // ones, so a drop has to count them or it lands that many rows too high. func TestAMovedItemLandsWhereItIsDroppedBelowAnArchivedOne(t *testing.T) { From 6c200c1db42fb5f4c4d6d9ffafd3c4efc98b483f Mon Sep 17 00:00:00 2001 From: Chris Birch <datapointchris@gmail.com> Date: Sat, 7 Nov 2026 17:33:17 -0500 Subject: [PATCH 6/7] docs: say what the form's listing and fallbacks do, without their history The comments cited a test that does not exist, measurements taken while building it, and earlier drafts. They now state what the code does and why. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1 --- prompt/prompt.go | 12 +++++------- prompt/terminal.go | 11 +++++------ 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/prompt/prompt.go b/prompt/prompt.go index 3a0903e..f78e4ec 100644 --- a/prompt/prompt.go +++ b/prompt/prompt.go @@ -227,8 +227,7 @@ func (f Form) Run(s Session) (Answers, error) { answers := Answers{} // By value, and ask takes it by value too. That is what keeps an Escape's // mutation of Choices and made inside the run that made it, so a Form value - // can be run twice. TestForm_RunningOneFormTwiceDoesNotCarryTheFirstRunsEscapeOver - // is what holds it. + // can be run twice. for _, field := range f.Fields { values, err := ask(s, field) if err != nil { @@ -390,8 +389,8 @@ func askProse(s Session, field Field) ([]string, error) { } // maxListedChoices is where a list stops being a glance and becomes a scroll. -// Past it the field says how many there are instead, because printing eighty -// repo names unasked is the wall of text this form exists to replace. +// Past it the field says how many there are instead, because a list that long +// scrolls past the prompt it introduces. // // It is also what decides whether Tab lists: what the field could not show up // front is what Tab is there to show. See tooManyToList. @@ -424,9 +423,8 @@ func writeChoices(s Session, field Field) { // reaches those choices, so a session without it gets the count and nothing else. func writeChoiceCount(s Session, field Field) { if s.Completes() { - // Kept inside 80 columns: it prints directly above the prompt, and a - // field whose introduction wraps to two lines is a poor advertisement - // for a change about fitting output to the width. + // Kept inside 80 columns: it prints directly above the prompt, and + // wrapping would split the introduction from the field it introduces. _, _ = fmt.Fprintf(s, "%s — %d to choose from. Tab lists them; type part of one to narrow.\n", field.Label, len(field.Choices)) return diff --git a/prompt/terminal.go b/prompt/terminal.go index 2541095..10b5af4 100644 --- a/prompt/terminal.go +++ b/prompt/terminal.go @@ -75,7 +75,7 @@ func (s *TerminalSession) ReadLine(q Question) (string, error) { // A list short enough to print unasked is on the screen already, and // reprinting it under the prompt is noise. A list too long for that was // never shown at all, which leaves Tab as the only way to find out what is - // in it — and walking 54 projects one keypress at a time is not finding + // in it — and walking a long list one keypress at a time is not finding // out. The form decides which of those this field is. if q.ListChoices { completer.session = s @@ -167,11 +167,10 @@ func (c *cycler) complete(line string, pos int, key rune) (string, int, bool) { // // One Write, because [TerminalSession.Write] erases the prompt line, writes, // and repaints the prompt — so every call is a repaint. Handing it a tabwriter -// directly costs one per cell and per pad, which measured 123 for 54 choices. -// A repaint against a row that already fills the terminal wraps, and the -// wrapped remnant survives the next chunk's erase, so the listing came back -// with fragments of the prompt sewn through it. Buffering makes it one repaint -// against text the terminal has entirely. +// directly costs one repaint per cell and per pad. A repaint against a row that +// already fills the terminal wraps, and the wrapped remnant survives the next +// chunk's erase, which sews fragments of the prompt through the listing. +// Buffering makes it one repaint against text the terminal has entirely. // // The width is read here rather than when the read began. A pane resized while // the prompt was up would otherwise lay the listing out to the width it had From 443675fac370d2a3eafc1a3d78ec7b295cab40b9 Mon Sep 17 00:00:00 2001 From: Chris Birch <datapointchris@gmail.com> Date: Sat, 7 Nov 2026 17:33:33 -0500 Subject: [PATCH 7/7] fix: a write that may have landed exits 1, and one mode decision serves every command Exit 4 tells a caller that running the command again is safe. A write that was sent and got no answer, or got a 5xx, may have been carried out, so it now exits 1 and says to check before running it again. Retrying returns apiclient.Unanswered with the method, the attempts made and whether any try was sent. A read, a write that never left and a 429 still exit 4, and the attempt count printed is the real one. The token is fetched outside the retries. oauth2.Transport wraps Retrying, and a token the provider or token_command did not hand is worded with whichever it was. decideMode reads goclilogin's parse of api, so the mode and the login read one setting. chooseMode is the one sync check that every command, projects sync and auth login take, and its refusal names PROJECTS_SYNC when the environment turned sync on. --repo is checked against this machine's registry in database mode only, because an API holds tags written from every machine. config refuses a token_command written in the other key's shape and names the shape it takes. The README points at --help for the verb tree and states what exit 4 promises. Agent-Name: claude-code-unattended Agent-Session: 17043598-5f7e-48e2-b1f3-970a6fc9750e Agent-Run: 01a1b810-2ad8-7bd1-b611-099b108439c7 Worklog: 2402.1 --- CLAUDE.md | 27 +++++--- README.md | 48 +++++++------- apiclient/client.go | 14 +++-- apiclient/transports.go | 29 ++++++++- cli/api_errors.go | 98 ++++++++++++++++++++++++----- cli/auth.go | 17 +++-- cli/data_source.go | 132 ++++++++++++++++++++++++++++++++------- cli/flags.go | 9 ++- cli/focus_queue.go | 8 +-- cli/item_create.go | 2 +- cli/item_dependencies.go | 6 +- cli/items.go | 57 ++++++++++------- cli/projects.go | 6 +- cli/sync.go | 13 ++-- config/config.go | 35 ++++++++--- 15 files changed, 360 insertions(+), 141 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e889aad..05c1748 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,16 +176,27 @@ Bare `projects` prints help. The project verbs sit at the root, and the item ver `projects items`, with `tasks`, `dependencies` and `memberships` as nouns under it. `cli/root.go` builds the tree. Help renders through `gotermstyle/cobrahelp`. -- **The `api` key, or `--api`, chooses the mode, and neither means the database.** `openDataSource` - decides it in each command's `RunE`, so `help`, `completion` and `version` open nothing. -- **`api` beside `[sync] enabled = true` refuses every command**, naming both keys, because they are - two authorities for the same rows. +- **The `api` key, or `--api`, chooses the mode, and neither means the database.** `decideMode` in + `cli/data_source.go` reads goclilogin's parse of the key, so the mode and the login read one + setting. It runs in each command's `RunE`, so `help`, `completion` and `version` open nothing. +- **`api` beside sync refuses**, through `chooseMode`, the one check every command, `projects sync` + and `auth login` take. The two are two authorities for the same rows. The refusal names + `PROJECTS_SYNC` when the environment turned sync on, and the config file's `[sync]` otherwise. - **With sync on, a command that writes refuses before it asks anything.** `openForWriting` holds the sentence, and `refusingWrites` answers 409 with it for any write that reaches the in-process routes without it. A write there would land in the copy with nothing queued, and the next pull deletes it. - **Exit codes are 0, 1 for refused or failed, 2 for usage, and 4 for no answer.** `exitCodeFor` in - `cli/root.go` maps them. A 422 is usage. A 429, a 5xx or a transport error after - `apiclient.Attempts` is no answer. A refused credential is 1. + `cli/root.go` maps them, and `explain` in `cli/api_errors.go` words them. A 422 is usage. + A refused credential is 1. +- **4 means running it again is safe.** That covers a read with no answer or a 5xx, a 429, a write + that never left, and a token request the login provider did not answer. `apiclient.Retrying` + returns `apiclient.Unanswered`, which records the method, the attempts made and whether any try + was sent. +- **A write that was sent and got no answer, or got a 5xx, exits 1.** It may have been carried out, + and the message says to check before running it again. +- **The token is fetched outside the retries.** `oauth2.Transport` wraps `Retrying`, so a failed token + request is not retried as though the API had not answered. `failedTokensMarked` tags the failure + as a `tokenFailure`, and `noToken` words it with the issuer or the `token_command`. - **A `--json` document this tool composes carries `schema_version`**, and its schema is under `cli/schemas`. A new shape takes a file there and a row in `both_modes_test.go`. Every other `--json` answer is the API's own, under `api/schemas`. @@ -193,7 +204,9 @@ builds the tree. Help renders through `gotermstyle/cobrahelp`. client id `projects-cli-<host>`, and the client-credentials grant with `projects.items.read` when `PROJECTS_CLIENT_SECRET` is set. - **The repo-name ban runs in database mode only.** `LocalService` sets `RefusingRepoNames`, and an API - sets none. + sets none. `--repo` is checked against this machine's registry in database mode only too, because + an API serves tags written from every machine. `repoRegistry` on the data source answers an empty + registry in API mode. An item shows its **number**, the handle the API assigns and answers with. The UUID is the sync key and stops there. diff --git a/README.md b/README.md index abf69ca..c1dda44 100644 --- a/README.md +++ b/README.md @@ -71,30 +71,27 @@ one. ## The project verbs sit at the root, and the item verbs under `projects items` -```text -projects list | next | show <project> -projects create | edit | shelve | complete | close | reopen | delete -projects items list | blocked | search | show | blockers -projects items create | edit | complete | reopen | archive | unarchive | delete | reorder -projects items tasks list | add | complete | edit | remove -projects items dependencies add | remove | tree -projects items memberships add | remove -projects tui -projects auth login | logout | status | token -projects sync | version | update | completion +`projects --help` lists every command, grouped. `projects items --help` lists the item verbs, with +`tasks`, `dependencies` and `memberships` as nouns under them. + +```bash +projects show "Kitchen remodel" # a project and its open items +projects items create --project "Kitchen remodel" --title "Get three quotes" +projects items dependencies add 43 --depends-on 42 # 43 waits on 42 ``` - `<project>` is a project's name or UUID. `<item>` is an item's number or UUID. - An answer goes to stdout. A hint, a prompt and an error go to stderr. - Every read takes `--json`, which prints one JSON document on stdout. A list is a bare array, and an empty one is `[]`. -- `show`, `items show`, `next`, `items dependencies tree` and `tui --json` print documents that carry - `schema_version`. [cli/schemas](cli/schemas) holds their JSON Schemas. Every other `--json` answer - is the API's own shape, under [api/schemas](api/schemas). -- `next` prints ten items unless `--limit` says otherwise. A list under `projects items` prints every +- A `--json` document this tool composes carries `schema_version`, and [cli/schemas](cli/schemas) + holds its JSON Schema. Every other `--json` answer is the API's own shape, under + [api/schemas](api/schemas). +- `next` prints a capped list, and `--limit` sets the cap. A list under `projects items` prints every row unless `--limit` caps it. -- `--repo` takes a name from the repo registry. On a filter, `--repo ""` means untagged work. On - `items edit`, it clears the tag. +- In database mode, `--repo` takes a name from the repo registry. Through an API it takes any tag, + because tags come from every machine sharing the API. On a filter, `--repo ""` means untagged work. + On `items edit`, it clears the tag. - `items create` in a terminal asks for the title and projects the flags left out. Typing `+` at the project question creates a project on the spot. `--no-input`, or no terminal, fails instead and names the flag that would have answered. @@ -106,13 +103,14 @@ projects sync | version | update | completion | Exit | Meaning | | ---- | ------- | | `0` | Done | -| `1` | Refused or failed: not found, not logged in, a rule said no | +| `1` | Refused or failed: not found, not logged in, a rule said no, or a write the API may have carried out | | `2` | A usage error: an unknown flag, a missing argument, a value the API refused as input | -| `4` | The API gave no answer: a refused connection, a timeout, a `429` or a `5xx` | +| `4` | Running it again is safe and may succeed: the API or the login provider gave no answer, a `429`, or a `5xx` to a read | -A read tries three times before it exits 4, on a failed connection, an answer cut short, or a `429`, -`502`, `503` or `504`. A write tries again only where the connection never opened, so it is never sent -twice. +A read is retried before it exits 4, on a failed connection, an answer cut short, or a `429`, `502`, +`503` or `504`. A write is retried only where the connection never opened, so it is never sent twice. +A write that was sent and got no answer, or a `5xx`, exits 1 and says it may have been carried out, +because running it again could do it twice. ## Through an API, a login on this machine carries every command @@ -258,7 +256,7 @@ shows the last 8 characters of its id until its push lands and earns it a number ## Sync keeps a database a copy of an API, and only the board writes to the copy -Sync is the older way to reach an API. `[sync] enabled = true` keeps a database on this machine as a +`[sync] enabled = true` keeps a database on this machine as a copy of `sync.api_url`, and `projects tui` edits the copy and pushes each edit. With `api` set there is no copy, so a config file holding both `api` and `[sync] enabled = true` stops every command, naming both keys. @@ -337,7 +335,7 @@ enabled = false The bare `token_command` belongs to `api`, and is an argument list printing a token response. The `[sync]` one belongs to `api_url`, and is a string printing a bare token. The two never stand in for -each other. +each other, and either written in the other's shape is refused, naming the shape it takes. `[sync]`'s `token_command` is how `projects` authenticates to the API without holding a secret itself. It is split on spaces and run without a shell or stdin. Whatever it prints becomes the bearer token. A command @@ -381,7 +379,7 @@ apiclient.Client backend.Backend | -> service.Service | | -> store/sqlite +-- api: apiclient.Backend | -> apiclient.Client - +-- api: HTTP, three attempts + +-- api: HTTP, retried -> projects-api ``` diff --git a/apiclient/client.go b/apiclient/client.go index 3e1260b..7a1b6f5 100644 --- a/apiclient/client.go +++ b/apiclient/client.go @@ -33,14 +33,18 @@ func New(base string, httpClient *http.Client) *Client { return &Client{Base: strings.TrimRight(base, "/"), http: httpClient} } -// APIError is an answer outside 2xx. Message is the API's own sentence, empty when -// the body carried none, as a proxy's error page does. +// APIError is an answer outside 2xx to a request sent with Method. Message is the +// API's own sentence, empty when the body carried none, as a proxy's error page does. type APIError struct { + Method string StatusCode int Message string Problems []wire.ValidationError } +// Read is whether the refused request only read, so sending it again changes nothing. +func (e *APIError) Read() bool { return e.Method == http.MethodGet } + func (e *APIError) Error() string { if e.Message != "" { return e.Message @@ -90,7 +94,7 @@ func (c *Client) call(ctx context.Context, method, path string, query url.Values return fmt.Errorf("reading the answer to %s %s: %w", method, path, err) } if response.StatusCode >= http.StatusMultipleChoices { - return refusalOf(response.StatusCode, received) + return refusalOf(method, response.StatusCode, received) } if answer == nil || len(received) == 0 { return nil @@ -101,8 +105,8 @@ func (c *Client) call(ctx context.Context, method, path string, query url.Values return nil } -func refusalOf(status int, body []byte) *APIError { - refused := &APIError{StatusCode: status} +func refusalOf(method string, status int, body []byte) *APIError { + refused := &APIError{Method: method, StatusCode: status} var answer wire.Error if json.Unmarshal(body, &answer) == nil { refused.Message = answer.Message diff --git a/apiclient/transports.go b/apiclient/transports.go index 6ed03d5..e0c6be0 100644 --- a/apiclient/transports.go +++ b/apiclient/transports.go @@ -50,6 +50,7 @@ type retrying struct { } func (t retrying) RoundTrip(request *http.Request) (*http.Response, error) { + sent := false for attempt := 1; ; attempt++ { response, err := t.next.RoundTrip(request) if err == nil { @@ -57,7 +58,11 @@ func (t retrying) RoundTrip(request *http.Request) (*http.Response, error) { response = nil } } + sent = sent || err == nil || !neverSent(err) if attempt == Attempts || !worthRetrying(request.Method, response, err) { + if err != nil { + err = &Unanswered{Method: request.Method, Attempts: attempt, Sent: sent, Err: err} + } return response, err } pause := t.pause(attempt, response) @@ -67,7 +72,7 @@ func (t retrying) RoundTrip(request *http.Request) (*http.Response, error) { select { case <-time.After(pause): case <-request.Context().Done(): - return nil, request.Context().Err() + return nil, &Unanswered{Method: request.Method, Attempts: attempt, Sent: sent, Err: request.Context().Err()} } request = request.Clone(request.Context()) if request.GetBody != nil { @@ -80,6 +85,28 @@ func (t retrying) RoundTrip(request *http.Request) (*http.Response, error) { } } +// Unanswered is a request that got no answer. Attempts counts the tries, and is 0 +// where nothing counted them. Sent is whether any try may have reached the API, so +// a write that was sent may have been carried out. +type Unanswered struct { + Method string + Attempts int + Sent bool + Err error +} + +func (u *Unanswered) Error() string { + if u.Attempts > 1 { + return fmt.Sprintf("%v, after %d attempts", u.Err, u.Attempts) + } + return u.Err.Error() +} + +func (u *Unanswered) Unwrap() error { return u.Err } + +// Read is whether the request only read, so sending it again changes nothing. +func (u *Unanswered) Read() bool { return u.Method == http.MethodGet } + // answerCutShort is a body that broke after its status arrived. type answerCutShort struct{ error } diff --git a/cli/api_errors.go b/cli/api_errors.go index 306fa6a..6bce685 100644 --- a/cli/api_errors.go +++ b/cli/api_errors.go @@ -4,24 +4,29 @@ import ( "context" "errors" "fmt" + "net" "net/http" "net/url" + "strings" "github.com/datapointchris/goclikit" "github.com/datapointchris/goclilogin" + "golang.org/x/oauth2" "github.com/datapointchris/projects/apiclient" ) -// noAnswerError is a request the API never judged: it was not reached, timed out, -// or answered 429 or 5xx. It exits 4, because the same command later can succeed. +// noAnswerError is a failure a later run of the same command can pass without +// doing anything twice: a read that got no answer, a 429 or a 5xx, and a write +// that never reached the API or got a 429. It exits 4. type noAnswerError struct{ error } func (n noAnswerError) Unwrap() error { return n.error } // explain words an error from a request for the person reading it. A credential // failure names its own remedy, a service is never sent to a login it cannot -// complete, and a refusal is the API's sentence alone. +// complete, and a refusal is the API's sentence alone. A write the API may have +// carried out says so and exits 1, so nothing told to retry on 4 repeats it. func (s *dataSource) explain(err error) error { if err == nil { return nil @@ -36,21 +41,71 @@ func (s *dataSource) explain(err error) error { return worded } } - switch { - case isRefusal && refused.StatusCode == http.StatusUnprocessableEntity && refused.Message != "": + var noToken tokenFailure + if s.deployment != nil && errors.As(err, &noToken) { + return s.noToken(noToken.error) + } + if isRefusal && refused.StatusCode == http.StatusUnprocessableEntity && refused.Message != "" { return goclikit.UsageError(refused) - case isRefusal && (refused.StatusCode == http.StatusTooManyRequests || refused.StatusCode >= http.StatusInternalServerError): - return noAnswerError{s.unanswered(refused)} - case errors.Is(err, context.DeadlineExceeded): - return noAnswerError{s.unanswered(err)} } + if s.deployment == nil { + return err + } + if isRefusal && (refused.StatusCode == http.StatusTooManyRequests || refused.StatusCode >= http.StatusInternalServerError) { + answered := s.answeredWith(refused) + if refused.Read() || refused.StatusCode == http.StatusTooManyRequests { + return noAnswerError{answered} + } + return mayHaveBeenCarriedOut(refused.Method, answered) + } + var lost *apiclient.Unanswered + if errors.As(err, &lost) { + if lost.Read() || !lost.Sent { + return noAnswerError{s.unanswered(lost)} + } + return mayHaveBeenCarriedOut(lost.Method, s.unanswered(lost)) + } + // A transport error nothing counted, such as the client's own deadline running + // out, is taken to have been sent. var transport *url.Error if errors.As(err, &transport) { - return noAnswerError{s.unanswered(transport.Err)} + if strings.EqualFold(transport.Op, http.MethodGet) { + return noAnswerError{s.unanswered(transport.Err)} + } + return mayHaveBeenCarriedOut(strings.ToUpper(transport.Op), s.unanswered(transport.Err)) + } + if errors.Is(err, context.DeadlineExceeded) { + return noAnswerError{s.unanswered(err)} } return err } +// mayHaveBeenCarriedOut is a write that reached the API with no answer saying +// whether it landed. Running it again could do it twice, so it exits 1. +func mayHaveBeenCarriedOut(method string, cause error) error { + return fmt.Errorf("%w; the %s was sent, so it may have been carried out — check before running it again", cause, method) +} + +// noToken is a token this run could not get, so no request was sent. A provider +// that did not answer is a failure a later run can pass. +func (s *dataSource) noToken(cause error) error { + from := "the login provider at " + s.deployment.Issuer.Value + if s.deployment.HandsTokens() { + from = "token_command in " + homeAsTilde(s.deployment.Path) + } + failed := fmt.Errorf("no token from %s: %w", from, cause) + var network net.Error + var provider *oauth2.RetrieveError + switch { + case errors.As(cause, &network), errors.Is(cause, context.DeadlineExceeded): + return noAnswerError{failed} + case errors.As(cause, &provider) && provider.Response != nil && + (provider.Response.StatusCode == http.StatusTooManyRequests || provider.Response.StatusCode >= http.StatusInternalServerError): + return noAnswerError{failed} + } + return failed +} + // credentialFailure words a failure to authenticate, which the request never got // past, or nil when err is not one. func (s *dataSource) credentialFailure(err error, refused *apiclient.APIError) error { @@ -95,15 +150,26 @@ func (s *dataSource) credentialFailure(err error, refused *apiclient.APIError) e // unanswered names the API and what chose it, as the first thing a person checks. func (s *dataSource) unanswered(cause error) error { - if s.deployment == nil { - return cause - } api := s.deployment.API - chosenBy := "--api" + return fmt.Errorf("no answer from the API at %s (%s): %w", api.Value, chosenBy(api), cause) +} + +// answeredWith is a 429 or 5xx, which names the API the way unanswered does. +func (s *dataSource) answeredWith(refused *apiclient.APIError) error { + api := s.deployment.API + answered := fmt.Sprintf("the API at %s (%s) answered %d %s", api.Value, chosenBy(api), + refused.StatusCode, http.StatusText(refused.StatusCode)) + if refused.Message != "" { + answered += ": " + refused.Message + } + return errors.New(answered) +} + +func chosenBy(api goclilogin.Setting) string { if api.Source == goclilogin.SourceFile { - chosenBy = "api in " + homeAsTilde(api.From) + return "api in " + homeAsTilde(api.From) } - return fmt.Errorf("no answer from the API at %s (%s): %w, %d attempts", api.Value, chosenBy, cause, apiclient.Attempts) + return "--api" } // notFound is the API's sentence for a 404 it explained, which goclikit follows diff --git a/cli/auth.go b/cli/auth.go index 2455ebf..340c85c 100644 --- a/cli/auth.go +++ b/cli/auth.go @@ -1,6 +1,7 @@ package cli import ( + "cmp" "context" "errors" "fmt" @@ -75,7 +76,7 @@ func newAuthLoginCommand() *cobra.Command { } url = args[0] } - if err := refuseLoginBesideSync(); err != nil { + if err := refuseLoginBesideSync(url); err != nil { return err } ctx, cancel := context.WithTimeout(cmd.Context(), loginTimeout) @@ -129,18 +130,16 @@ func newAuthLoginCommand() *cobra.Command { return cmd } -// refuseLoginBesideSync refuses a login that would write api into a file that -// keeps a synced database, because the next command would then refuse the pair. -func refuseLoginBesideSync() error { +// refuseLoginBesideSync refuses a login that would write api beside a synced +// database, because every command after it would refuse the pair. +func refuseLoginBesideSync(url string) error { cfg, err := config.Load() if err != nil { return err } - if cfg.Sync.Enabled { - return fmt.Errorf("%s sets [sync] enabled = true, and logging in writes api beside it, which is two authorities for "+ - "the same rows; remove [sync] first to work through the API", homeAsTilde(configFilePath())) - } - return nil + _, err = chooseMode(cfg, cmp.Or(url, "the API it logs in to"), + "logging in writes api to "+homeAsTilde(configFilePath())) + return err } func newAuthLogoutCommand() *cobra.Command { diff --git a/cli/data_source.go b/cli/data_source.go index 9c33478..26fd23a 100644 --- a/cli/data_source.go +++ b/cli/data_source.go @@ -71,20 +71,82 @@ func LocalService(database *sql.DB, reposRegistryPath string) *service.Service { return service.New(sqlite.New(database), service.AssigningItemNumbers(), service.RefusingRepoNames(reposRegistryPath)) } -// openDataSource decides the mode and opens it. --api or the config file's api key -// is API mode, and opens no database. Neither is database mode. +// runMode is where a run's data lives. +type runMode int + +const ( + databaseMode runMode = iota + apiMode +) + +// openDataSource opens where this run's data lives, as decideMode answers it. func openDataSource(cmd *cobra.Command) (*dataSource, error) { - if err := config.RefuseConfigUnderFormerName(); err != nil { + cfg, deployment, mode, err := decideMode(cmd.Context()) + if err != nil { return nil, err } + if mode == apiMode { + return openAPI(cmd.Context(), cfg, deployment) + } + return openDatabase(cfg) +} + +// decideMode reads the config file and the login settings in it, and answers +// which store this run uses. goclilogin's reading of api is the one the mode +// follows, because it is the one every request is then sent with. +func decideMode(ctx context.Context) (*config.Config, goclilogin.Deployment, runMode, error) { + if err := config.RefuseConfigUnderFormerName(); err != nil { + return nil, goclilogin.Deployment{}, databaseMode, err + } cfg, err := config.Load() if err != nil { - return nil, err + return nil, goclilogin.Deployment{}, databaseMode, err + } + deployment, loadErr := loadDeployment(ctx, apiOverride) + mode, err := chooseMode(cfg, deployment.API.Value, apiPhrase(deployment.API)) + if err != nil { + return nil, deployment, mode, err } - if apiOverride != "" || cfg.API != "" { - return openAPI(cmd.Context(), cfg) + // With no api, the deployment's only error worth stopping for is a file it + // could not read: a broken api line must not fall back to the database. + if loadErr != nil && (mode == apiMode || !errors.Is(loadErr, goclilogin.ErrNotConfigured)) { + return nil, deployment, mode, loadErr } - return openDatabase(cfg) + return cfg, deployment, mode, nil +} + +// chooseMode is the one answer to where a run's data lives: the API api names, or +// this machine's database when it names none. A synced database beside an API is +// refused, because the two are authorities for the same rows. apiPhrase says +// what named the API. +func chooseMode(cfg *config.Config, api, apiPhrase string) (runMode, error) { + if api == "" { + return databaseMode, nil + } + if cfg.Sync.Enabled { + chosenBy, remedy := syncChosenBy() + return apiMode, fmt.Errorf("%s, and %s keeps a synced database, which are two authorities for the same rows; "+ + "%s to work through the API", apiPhrase, chosenBy, remedy) + } + return apiMode, nil +} + +// apiPhrase names what chose the API, as the first thing a person checks. +func apiPhrase(api goclilogin.Setting) string { + if api.Source == goclilogin.SourceCommandLine { + return "--api sends this command to " + api.Value + } + return fmt.Sprintf("api in %s sends every command to %s", homeAsTilde(api.From), api.Value) +} + +// syncChosenBy names what turned sync on and how to turn it off. The variable +// outranks the file, so it is named whenever it is set. +func syncChosenBy() (chosenBy, remedy string) { + if value := os.Getenv(config.SyncVariable); value != "" { + return fmt.Sprintf("%s=%s in the environment", config.SyncVariable, value), "unset " + config.SyncVariable + } + path := homeAsTilde(configFilePath()) + return "[sync] enabled = true in " + path, "remove [sync] from " + path } // openForWriting is openDataSource for a command that writes, refused before it @@ -101,15 +163,7 @@ func openForWriting(cmd *cobra.Command) (*dataSource, error) { return source, nil } -func openAPI(ctx context.Context, cfg *config.Config) (*dataSource, error) { - if cfg.Sync.Enabled { - return nil, fmt.Errorf("%s sets api and [sync] enabled = true, which are two authorities for the same rows; "+ - "remove [sync] to work through the API, or remove api to keep the synced database", homeAsTilde(configFilePath())) - } - deployment, err := loadDeployment(ctx, apiOverride) - if err != nil { - return nil, err - } +func openAPI(ctx context.Context, cfg *config.Config, deployment goclilogin.Deployment) (*dataSource, error) { source := &dataSource{cfg: cfg, deployment: &deployment} // oauth2 fetches a token through the context its source was built with, so the // fetch is bounded by that context's client rather than by the request's. @@ -118,13 +172,36 @@ func openAPI(ctx context.Context, cfg *config.Config) (*dataSource, error) { if err != nil { return nil, source.explain(err) } - httpClient := oauth2.NewClient(ctx, tokens) - httpClient.Transport = apiclient.Retrying(httpClient.Transport) - httpClient.Timeout = requestTimeout + // The token is fetched outside the retries, so a provider that does not answer + // is asked once per request and is never reported as the API. + httpClient := &http.Client{ + Transport: &oauth2.Transport{ + Source: oauth2.ReuseTokenSource(nil, failedTokensMarked{tokens}), + Base: apiclient.Retrying(http.DefaultTransport), + }, + Timeout: requestTimeout, + } source.client = apiclient.New(deployment.API.Value, httpClient) return source, nil } +// tokenFailure is a token this run could not get, so no request was sent. +type tokenFailure struct{ error } + +func (f tokenFailure) Unwrap() error { return f.error } + +// failedTokensMarked marks every failure to get a token, so explain words it as +// the login provider's rather than the API's. +type failedTokensMarked struct{ next oauth2.TokenSource } + +func (m failedTokensMarked) Token() (*oauth2.Token, error) { + token, err := m.next.Token() + if err != nil { + return nil, tokenFailure{err} + } + return token, nil +} + // tokenSource is the config file's token_command where it names one, the // client-credentials grant when PROJECTS_CLIENT_SECRET is set, and otherwise the // token this machine logged in for. @@ -158,9 +235,10 @@ func openDatabase(cfg *config.Config) (*dataSource, error) { if cfg.Sync.Enabled { source.local = service.New(sqlite.New(database), service.RefusingRepoNames(repos.DefaultPath())) source.engine = sync.New(database, cfg.Sync.APIURL, cfg.Sync.TokenCommand) - source.writeRefusal = fmt.Sprintf("[sync] enabled = true keeps this database a copy of %s, and a write from a "+ - "command would never reach it; make the change in `projects tui`, or set api in %s and remove [sync] "+ - "to write through the projects API", cfg.Sync.APIURL, homeAsTilde(configFilePath())) + chosenBy, remedy := syncChosenBy() + source.writeRefusal = fmt.Sprintf("%s keeps this database a copy of %s, and a write from a command would "+ + "never reach it; make the change in `projects tui`, or %s and set api in %s to write through the projects API", + chosenBy, cfg.Sync.APIURL, remedy, homeAsTilde(configFilePath())) } else { source.local = LocalService(database, repos.DefaultPath()) } @@ -212,6 +290,16 @@ func (s *dataSource) boardBackend() backend.Backend { } } +// repoRegistry is what a repo tag is checked against: this machine's registry in +// database mode, and an empty one through an API, which holds tags any machine +// may have written. +func (s *dataSource) repoRegistry() (*repos.Registry, error) { + if s.deployment != nil { + return &repos.Registry{}, nil + } + return repos.Load(repos.DefaultPath()) +} + // apiHost is the API's host, or "" in database mode. func (s *dataSource) apiHost() string { if s.deployment == nil { diff --git a/cli/flags.go b/cli/flags.go index e7a4074..e0d7dd3 100644 --- a/cli/flags.go +++ b/cli/flags.go @@ -13,7 +13,6 @@ import ( "github.com/spf13/cobra" "github.com/datapointchris/projects/model" - "github.com/datapointchris/projects/repos" ) // itemStatuses is what an item --status takes. They partition every item, because @@ -99,11 +98,11 @@ func validateStatus(cmd *cobra.Command, status string, known []string) error { // repoFlagValue validates --repo and renders it as a filter. Absent is nil, no // filter. Present and empty asks for the untagged items, the work that is not // code, which collapsing the two would leave unreachable. -func repoFlagValue(cmd *cobra.Command, repo string) (*string, error) { +func repoFlagValue(cmd *cobra.Command, source *dataSource, repo string) (*string, error) { if !cmd.Flags().Changed("repo") { return nil, nil } - if err := validateRepoFlag(cmd, repo); err != nil { + if err := validateRepoFlag(cmd, source, repo); err != nil { return nil, err } return &repo, nil @@ -112,11 +111,11 @@ func repoFlagValue(cmd *cobra.Command, repo string) (*string, error) { // validateRepoFlag refuses a --repo the registry does not know. On a write a // mistyped tag drops the item out of every repo view, and on a read it selects // nothing, which reads the same as a repo with no work. -func validateRepoFlag(cmd *cobra.Command, repo string) error { +func validateRepoFlag(cmd *cobra.Command, source *dataSource, repo string) error { if !cmd.Flags().Changed("repo") { return nil } - registry, err := repos.Load(repos.DefaultPath()) + registry, err := source.repoRegistry() if err != nil { return err } diff --git a/cli/focus_queue.go b/cli/focus_queue.go index f6305e2..b9116ae 100644 --- a/cli/focus_queue.go +++ b/cli/focus_queue.go @@ -13,7 +13,7 @@ import ( "github.com/datapointchris/projects/model" ) -// defaultNextItemLimit caps the items `projects next` prints; --limit raises it. +// defaultNextItemLimit caps the items `projects next` prints when --limit is not given. const defaultNextItemLimit = 10 // focusShown is `projects next --json`. cli/schemas/focus-queue.schema.json is its @@ -53,15 +53,15 @@ func newProjectsNextCommand() *cobra.Command { " projects next --limit 1 --json", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - filter, err := repoFlagValue(cmd, repo) + source, err := openDataSource(cmd) if err != nil { return err } - source, err := openDataSource(cmd) + defer func() { _ = source.Close() }() + filter, err := repoFlagValue(cmd, source, repo) if err != nil { return err } - defer func() { _ = source.Close() }() // Every open item, uncapped, because --limit caps what is printed here. all, err := source.client.ListItems(cmd.Context(), apiclient.ItemQuery{Status: model.ItemStatusOpen, Repo: filter}) if err != nil { diff --git a/cli/item_create.go b/cli/item_create.go index bc6b40a..d567421 100644 --- a/cli/item_create.go +++ b/cli/item_create.go @@ -122,7 +122,7 @@ func itemCreateFields(ctx context.Context, source *dataSource, made *createdProj names = append(names, project.Name) } sort.Strings(names) - registry, err := repos.Load(repos.DefaultPath()) + registry, err := source.repoRegistry() if err != nil { return nil, err } diff --git a/cli/item_dependencies.go b/cli/item_dependencies.go index 3b40dd0..b4fe0e1 100644 --- a/cli/item_dependencies.go +++ b/cli/item_dependencies.go @@ -152,15 +152,15 @@ func newDependenciesTreeCommand() *cobra.Command { if cmd.Flags().Changed("depth") { maxDepth = depth } - repoFilter, err := repoFlagValue(cmd, repo) + source, err := openDataSource(cmd) if err != nil { return err } - source, err := openDataSource(cmd) + defer func() { _ = source.Close() }() + repoFilter, err := repoFlagValue(cmd, source, repo) if err != nil { return err } - defer func() { _ = source.Close() }() // Every item, whatever the flags narrow to: a finished item mid-chain // joins the halves either side of it, so a subset splits one tree. items, err := source.client.ListItems(cmd.Context(), apiclient.ItemQuery{Status: model.StatusAll}) diff --git a/cli/items.go b/cli/items.go index 10718f2..a5ee85a 100644 --- a/cli/items.go +++ b/cli/items.go @@ -109,16 +109,16 @@ func newItemsListCommand() *cobra.Command { if project != "" && cmd.Flags().Changed("repo") { return goclikit.UsageError(errors.New("--repo and --project are different questions — pass one")) } - filter, err := repoFlagValue(cmd, repo) + source, err := openDataSource(cmd) if err != nil { return err } - query := apiclient.ItemQuery{Status: itemStatus, Repo: filter, Since: since, Until: until, Limit: limitFlag(cmd)} - source, err := openDataSource(cmd) + defer func() { _ = source.Close() }() + filter, err := repoFlagValue(cmd, source, repo) if err != nil { return err } - defer func() { _ = source.Close() }() + query := apiclient.ItemQuery{Status: itemStatus, Repo: filter, Since: since, Until: until, Limit: limitFlag(cmd)} if project == "" { items, err := source.client.ListItems(cmd.Context(), query) if err != nil { @@ -162,12 +162,12 @@ func newItemsBlockedCommand() *cobra.Command { Example: " projects items blocked\n projects items blocked --repo photo-sorter", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - filter, err := repoFlagValue(cmd, repo) - if err != nil { - return err - } - return listItems(cmd, asJSON, func(ctx context.Context, c *apiclient.Client) ([]wire.ProjectItem, error) { - return c.ListBlockedItems(ctx, filter) + return listItems(cmd, asJSON, func(ctx context.Context, source *dataSource) ([]wire.ProjectItem, error) { + filter, err := repoFlagValue(cmd, source, repo) + if err != nil { + return nil, err + } + return source.client.ListBlockedItems(ctx, filter) }) }, } @@ -188,12 +188,12 @@ func newItemsSearchCommand() *cobra.Command { Example: " projects items search kitchen\n projects items search \"off-site backup\" --repo photo-sorter", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - filter, err := repoFlagValue(cmd, repo) - if err != nil { - return err - } - return listItems(cmd, asJSON, func(ctx context.Context, c *apiclient.Client) ([]wire.ProjectItem, error) { - return c.SearchItems(ctx, args[0], filter) + return listItems(cmd, asJSON, func(ctx context.Context, source *dataSource) ([]wire.ProjectItem, error) { + filter, err := repoFlagValue(cmd, source, repo) + if err != nil { + return nil, err + } + return source.client.SearchItems(ctx, args[0], filter) }) }, } @@ -210,8 +210,8 @@ func newItemsBlockersCommand() *cobra.Command { Example: " projects items blockers 118", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return listItems(cmd, asJSON, func(ctx context.Context, c *apiclient.Client) ([]wire.ProjectItem, error) { - return c.ListBlockers(ctx, args[0]) + return listItems(cmd, asJSON, func(ctx context.Context, source *dataSource) ([]wire.ProjectItem, error) { + return source.client.ListBlockers(ctx, args[0]) }) }, } @@ -221,13 +221,13 @@ func newItemsBlockersCommand() *cobra.Command { // listItems is the body of blocked, search and blockers: one read, then JSON or // a table. -func listItems(cmd *cobra.Command, asJSON bool, read func(context.Context, *apiclient.Client) ([]wire.ProjectItem, error)) error { +func listItems(cmd *cobra.Command, asJSON bool, read func(context.Context, *dataSource) ([]wire.ProjectItem, error)) error { source, err := openDataSource(cmd) if err != nil { return err } defer func() { _ = source.Close() }() - items, err := read(cmd.Context(), source.client) + items, err := read(cmd.Context(), source) if err != nil { return source.explain(err) } @@ -323,15 +323,20 @@ func newItemsEditCommand() *cobra.Command { edit.Notes, edit.ClearNotes = ¬es, notes == "" } if f.Changed("repo") { - if err := validateRepoFlag(cmd, repo); err != nil { - return err - } edit.Repo, edit.ClearRepo = &repo, repo == "" } if edit == (apiclient.ItemChanges{}) { return goclikit.UsageError(errors.New("nothing to change — pass --title, --notes or --repo")) } - return runItemUpdate(cmd, args[0], edit, asJSON, "Updated") + source, err := openForWriting(cmd) + if err != nil { + return err + } + defer func() { _ = source.Close() }() + if err := validateRepoFlag(cmd, source, repo); err != nil { + return err + } + return updateItem(cmd, source, args[0], edit, asJSON, "Updated") }, } cmd.Flags().StringVar(&title, "title", "", "New item title") @@ -372,6 +377,10 @@ func runItemUpdate(cmd *cobra.Command, ref string, edit apiclient.ItemChanges, a return err } defer func() { _ = source.Close() }() + return updateItem(cmd, source, ref, edit, asJSON, done) +} + +func updateItem(cmd *cobra.Command, source *dataSource, ref string, edit apiclient.ItemChanges, asJSON bool, done string) error { item, err := source.client.UpdateItem(cmd.Context(), ref, edit) if err != nil { return source.explain(err) diff --git a/cli/projects.go b/cli/projects.go index 79fdbb6..c602bc1 100644 --- a/cli/projects.go +++ b/cli/projects.go @@ -39,15 +39,15 @@ func newProjectsListCommand() *cobra.Command { if err := validateStatus(cmd, projectStatus, projectStatuses); err != nil { return err } - filter, err := repoFlagValue(cmd, repo) + source, err := openDataSource(cmd) if err != nil { return err } - source, err := openDataSource(cmd) + defer func() { _ = source.Close() }() + filter, err := repoFlagValue(cmd, source, repo) if err != nil { return err } - defer func() { _ = source.Close() }() projects, err := source.client.ListProjects(cmd.Context(), apiclient.ProjectQuery{Status: projectStatus, Repo: filter, Limit: limitFlag(cmd)}) if err != nil { diff --git a/cli/sync.go b/cli/sync.go index 2a058f7..6f5ac02 100644 --- a/cli/sync.go +++ b/cli/sync.go @@ -6,7 +6,6 @@ import ( "github.com/spf13/cobra" - "github.com/datapointchris/projects/config" "github.com/datapointchris/projects/sync" ) @@ -30,18 +29,14 @@ func newSyncCommand() *cobra.Command { " projects sync --force sync even when the pull would delete most local rows", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - if err := config.RefuseConfigUnderFormerName(); err != nil { - return err - } - cfg, err := config.Load() + cfg, deployment, mode, err := decideMode(cmd.Context()) if err != nil { return err } - if cfg.API != "" { - return fmt.Errorf("api in %s sends every command to %s, so there is no database copy to sync", - homeAsTilde(configFilePath()), cfg.API) + if mode == apiMode { + return fmt.Errorf("%s, so there is no database copy to sync", apiPhrase(deployment.API)) } - source, err := openDataSource(cmd) + source, err := openDatabase(cfg) if err != nil { return err } diff --git a/config/config.go b/config/config.go index dec4a48..43b12ce 100644 --- a/config/config.go +++ b/config/config.go @@ -25,12 +25,9 @@ type Config struct { // resolution order reads in one place rather than half here and half there. ReposRegistry string `mapstructure:"repos_registry"` - // API is the projects API every command reads and writes through. Set, no - // database is opened. Unset, the database under local is the store, so a - // fresh install works with no server. goclilogin reads it beside issuer, - // client_id and token_command, and `projects auth login <url>` writes the - // first three. They are declared here because ErrorUnused refuses a key the - // struct has no home for. + // API, Issuer, ClientID and TokenCommand are goclilogin's keys, and the mode + // follows its reading of them, so nothing reads these fields. They are declared + // because ErrorUnused refuses a key the struct has no home for. API string `mapstructure:"api"` Issuer string `mapstructure:"issuer"` ClientID string `mapstructure:"client_id"` @@ -77,7 +74,7 @@ func Load() (*Config, error) { // Env var overrides v.SetEnvPrefix("PROJECTS") _ = v.BindEnv("local.db_path", "PROJECTS_DB") - _ = v.BindEnv("sync.enabled", "PROJECTS_SYNC") + _ = v.BindEnv("sync.enabled", SyncVariable) _ = v.BindEnv("sync.api_url", "PROJECTS_SYNC_URL") _ = v.BindEnv("sync.token_command", "PROJECTS_SYNC_TOKEN_COMMAND") _ = v.BindEnv("sync.interval", "PROJECTS_SYNC_INTERVAL") @@ -91,6 +88,9 @@ func Load() (*Config, error) { // naming its registry under a key the tool does not read would fall back to the // data directory, find no registry there, and lift the ban with nothing // reporting why. + if err := refuseTokenCommandInTheOtherShape(v); err != nil { + return nil, err + } var cfg Config if err := v.Unmarshal(&cfg, viper.DecodeHook(mapstructure.StringToTimeDurationHookFunc()), func(dc *mapstructure.DecoderConfig) { dc.ErrorUnused = true @@ -123,6 +123,27 @@ func Load() (*Config, error) { return &cfg, nil } +// SyncVariable turns sync on or off from the environment, over the file's +// sync.enabled. +const SyncVariable = "PROJECTS_SYNC" + +// refuseTokenCommandInTheOtherShape refuses a token_command written the way the +// other key takes it. Beside api it is an argument list printing a JSON token +// response; under [sync] it is one string printing a bare token. A command moved +// from one to the other would otherwise fail on a type error naming neither. +func refuseTokenCommandInTheOtherShape(v *viper.Viper) error { + if command, isString := v.Get("token_command").(string); isString { + return fmt.Errorf("token_command beside api is a list of arguments, run without a shell, that prints a JSON "+ + "token response: token_command = [\"%s\"]; one string is [sync]'s form", + strings.Join(strings.Fields(command), `", "`)) + } + if _, isList := v.Get("sync.token_command").([]any); isList { + return fmt.Errorf("[sync] token_command is one string, split on spaces, that prints a bare token, " + + "such as token_command = \"<cli> auth token\"; a list is the form beside api") + } + return nil +} + // minSyncInterval floors the reconcile period. A full pull is 2+2N requests, so // anything shorter is a self-inflicted denial of service on the API. const minSyncInterval = 15 * time.Second