Files
Steve Cliff b07572fed5 Released v1
2026-02-12 10:37:19 +00:00

216 lines
10 KiB
Markdown

## Context
This is a greenfield Go project. There is no existing codebase — the repository currently contains only the Planka OpenAPI 3.0 spec (`planka-api.json`) and OpenSpec configuration. The Planka API uses JWT bearer auth and returns JSON responses. The API follows a nested resource model: Projects → Boards → Lists → Cards, with sub-resources (comments, tasks, labels, memberships) hanging off cards and boards.
Primary consumers are AI agents and CI/CD pipelines, not humans at a terminal. This shapes every design decision — JSON-first output, env-var auth, no interactive prompts.
## Goals / Non-Goals
**Goals:**
- Single statically-linked Go binary with zero runtime dependencies
- Predictable, machine-parseable output for every command
- Clean mapping from CLI commands to Planka API operations
- Minimal abstraction — thin client, not an SDK
- Easy to extend with new resources/commands later
**Non-Goals:**
- Interactive TUI or prompt-based workflows
- Caching, offline mode, or local state
- WebSocket/real-time integration (Planka supports it, but out of scope)
- Admin operations (user management, project/board CRUD beyond read)
- Attachment upload/download
- Shell completions (can be added later, not v1)
## Decisions
### 1. Project layout — flat `internal/` package vs separate packages
**Decision**: Separate packages under project root: `cmd/`, `client/`, `model/`, `output/`
**Alternatives considered**:
- `internal/` with sub-packages — adds nesting without benefit for a single-binary tool
- Single `main` package — doesn't scale past ~10 files
**Rationale**: Clear separation of concerns. `cmd/` owns CLI wiring, `client/` owns HTTP, `model/` owns types, `output/` owns formatting. Each package has a single responsibility and can be tested independently.
```
github.com/dcgsteve/pcli
├── main.go → entry point, calls cmd.Execute()
├── cmd/
│ ├── root.go → root command, global flags, env loading
│ ├── project.go → project list, get
│ ├── board.go → board get, actions
│ ├── card.go → card CRUD, move, duplicate, assign, labels
│ ├── comment.go → comment CRUD
│ ├── task_list.go → task-list CRUD
│ ├── task.go → task CRUD
│ └── label.go → label CRUD
├── client/
│ ├── client.go → base HTTP client (URL, token, Do, error handling)
│ ├── projects.go
│ ├── boards.go
│ ├── cards.go
│ ├── comments.go
│ ├── task_lists.go
│ ├── tasks.go
│ └── labels.go
├── model/
│ └── types.go → all API structs + response envelope
├── output/
│ └── output.go → Print(data, format), envelope wrapping, table rendering
├── logging/
│ └── logging.go → structured logger setup, level control
├── go.mod
└── go.sum
```
### 2. HTTP client — code-generated vs hand-rolled
**Decision**: Hand-rolled thin client using `net/http` from the standard library.
**Alternatives considered**:
- `oapi-codegen` — generates typed client from OpenAPI spec. Produces verbose code, hard to customize, and we only cover ~30 of 96 endpoints.
- `go-resty` or similar HTTP wrapper — adds a dependency for minimal benefit over `net/http`.
**Rationale**: The API is straightforward REST/JSON. A base client with `Do(method, path, body) (*http.Response, error)` plus per-resource methods is simple, readable, and fully under our control. ~30 endpoints don't justify code generation overhead.
### 3. Base client design
```go
type Client struct {
BaseURL string
Token string
HTTPClient *http.Client
Logger *slog.Logger
}
func (c *Client) Do(ctx context.Context, method, path string, body any) (json.RawMessage, error)
```
- `Do` handles: URL construction, bearer token header, JSON marshal/unmarshal, HTTP error → Go error mapping
- `Do` logs every request at DEBUG level (`method`, `path`, `status`, `duration`) and errors at WARN
- Per-resource files add typed methods: `func (c *Client) GetCard(ctx context.Context, id string) (*model.Card, error)`
- API errors (4xx/5xx) are mapped to a structured `APIError` type with status code and message
### 4. Response envelope
**Decision**: All CLI output wrapped in `{"data": ..., "error": null}`.
```go
type Envelope struct {
Data any `json:"data"`
Error *string `json:"error"`
}
```
- On success: `{"data": <result>, "error": null}` + exit code 0
- On error: `{"data": null, "error": "<message>"}` + exit code 1
- Table format (`--format=table`) bypasses the envelope — prints human-readable rows directly, errors go to stderr
### 5. Auth — env vars with flag override
**Decision**: `PLANKA_URL` and `PLANKA_TOKEN` environment variables, overridable with `--url` and `--token` global flags.
**Rationale**: Env vars are the standard for CI/CD and agent environments. Flag overrides allow ad-hoc use without modifying the environment. No config file, no login command, no token storage.
Precedence: flag > env var. Missing values produce a clear error message and exit code 1.
### 6. CLI framework — Cobra
**Decision**: `github.com/spf13/cobra` for command structure.
**Alternatives considered**:
- `urfave/cli` — simpler but less ecosystem support, no built-in nested subcommands
- `kong` — struct-based, clean but less widely adopted
**Rationale**: Cobra is the de facto standard for Go CLIs. Nested subcommand support maps directly to our `pcli <resource> <action>` pattern. Well-documented, widely understood.
### 7. `card list --board <id>` enrichment
This command requires multiple API calls:
1. `GET /boards/{id}` → get board details (includes list of lists with IDs and names)
2. `GET /lists/{listId}/cards` for each list → get cards
The client method composes these calls and injects `listName` into each card response. This is the only command that does multi-call enrichment — all others are 1:1 with API endpoints.
### 8. Model types — partial structs
**Decision**: Model structs include all fields from the API schemas that are relevant to our scoped operations. Fields use `json` tags and pointer types for nullable/optional fields.
**Rationale**: We don't need every field from every schema. Types are defined once in `model/types.go` and shared by both `client/` and `cmd/`. Using `json.RawMessage` for truly dynamic fields (like `Action.data`).
### 9. Pagination — cursor-based with auto-fetch
The Planka API uses cursor-based pagination on 4 list endpoints:
- `GET /boards/{boardId}/actions``beforeId` param
- `GET /cards/{cardId}/actions``beforeId` param
- `GET /lists/{listId}/cards``before` param
- `GET /cards/{cardId}/comments``beforeId` param
**Decision**: Build pagination into the client layer from the start. List methods accept an optional limit and automatically page through results.
- `--limit N` flag on all list commands (actions, cards, comments). When set, fetch stops after N total items. When omitted, fetch all pages.
- Client list methods accept a `limit` parameter (0 = no limit).
```go
// Client list methods follow this pattern:
func (c *Client) ListCardComments(ctx context.Context, cardId string, limit int) ([]model.Comment, error) {
var all []model.Comment
var beforeId string
for {
page, err := c.listCardCommentsPage(ctx, cardId, beforeId)
if err != nil { return nil, err }
all = append(all, page...)
if len(page) == 0 { break }
if limit > 0 && len(all) >= limit {
all = all[:limit]
break
}
beforeId = page[len(page)-1].ID // cursor = last item's ID
}
return all, nil
}
```
**Rationale**: Cursor-based pagination is easy to get wrong if bolted on later — callers assume they get complete results. Building it in from day one means every list command returns complete data by default, while `--limit` gives agents control over response size and latency. The loop terminates when a page returns empty results or the limit is reached.
Logging at DEBUG level tracks each page fetch so pagination behavior is observable.
### 10. Structured logging
**Decision**: Use Go's standard library `log/slog` for structured logging.
**Alternatives considered**:
- `zerolog` — fast, but an external dependency for something `slog` handles well
- `logrus` — widely used but effectively in maintenance mode; `slog` is the successor
- No logging — makes debugging agent/CI failures opaque
**Rationale**: `slog` is in the standard library (Go 1.21+), produces structured JSON or text output, and has zero dependencies. Perfect fit for a tool consumed by agents.
**Log levels**:
- `DEBUG` — HTTP request/response details, pagination page fetches, config resolution
- `INFO` — command execution summary (what resource, what action)
- `WARN` — non-fatal issues (e.g., unexpected API response fields)
- `ERROR` — failures that cause non-zero exit
**Control**: `--log-level` global flag (default: `WARN`). Agents can set `--log-level=debug` for full visibility. Logs go to **stderr** so they never pollute the JSON output on stdout.
```go
// In root.go
var logLevel string // global flag: --log-level
func initLogger() *slog.Logger {
var level slog.Level
level.UnmarshalText([]byte(logLevel))
return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
}
```
## Risks / Trade-offs
- **API version coupling** → The client is built against Planka v2.0 API. Breaking API changes require pcli updates. Mitigation: pin to known API version, document compatibility.
- **Pagination completeness** → Auto-fetching all pages could be slow for very large result sets (e.g., thousands of actions). Mitigation: `--limit N` flag available on all list commands to cap results.
- **Multi-call `card list --board`** → Slower than a single API call, N+1 pattern (1 board call + N list calls). Mitigation: acceptable for typical board sizes (< 20 lists). Could parallelize list fetches if needed.
- **No retry/backoff** → Network errors fail immediately. Mitigation: acceptable for v1. Agents can implement their own retry logic. Can add later with minimal changes to `client.Do`.
- **Bearer token in env var** → Token visible in process environment. Mitigation: standard practice for CI/CD tools; better than on-disk storage or passing credentials.