diff --git a/README.md b/README.md index 981fac8..68644eb 100644 --- a/README.md +++ b/README.md @@ -24,23 +24,36 @@ git clone https://forgejo.ludique.dev/theo/plane-cli.git ~/Developer/plane-cli Add to your `~/.zshrc`: ```sh -export PLANE_API_KEY="plane_api_xxxxxxxxxxxxxxxx" # keep this out of version control +export PLANE_API_KEY="plane_api_xxxxxxxxxxxxxxxx" # keep this out of version control +export PLANE_BASE="https://your-plane.example.com/api/v1/workspaces/your-workspace" source ~/Developer/plane-cli/plane.zsh ``` -Reload: `source ~/.zshrc`. +Reload (`source ~/.zshrc`), then pick your project once: + +```sh +pluse # fzf-pick a project — remembered across shells +``` ### Configuration -| Env var | Required | Default | -|----------------------|----------|-----------------------------------------------------------| -| `PLANE_API_KEY` | **yes** | — (Plane → *Settings → API tokens*) | -| `PLANE_BASE` | no | `https://plane.ludique.dev/api/v1/workspaces/oloap` | -| `PLANE_PROJECT` | no | a project UUID | -| `PLANE_PROJECT_NAME` | no | `colorcatchers` | +Nothing instance-specific is hard-coded. The CLI reads everything from the environment and +fetches each project's workflow states live, so it works against any Plane instance. -`PLANE_BASE` is `/api/v1/workspaces/`. The active project can -also be switched at runtime with `pluse` (it re-fetches the project's workflow states). +| Env var | Required | Purpose | +|-----------------------------|----------|---------------------------------------------------------------------| +| `PLANE_API_KEY` | **yes** | Your API token (Plane → *Settings → API tokens*). | +| `PLANE_BASE` | **yes** | `https:///api/v1/workspaces/`. | +| `PLANE_PROJECT` | no | Pin the active project (UUID). Otherwise use `pluse`. | +| `PLANE_PROJECT_NAME` | no | Display name for a pinned project (auto-resolved if omitted). | +| `PLANE_EVERYTHING_PROJECT` | no | Target project UUID for `plsync` (see below). | +| `PLANE_EVERYTHING_NAME` | no | Name `plsync` looks up if the UUID isn't set (default `Everything`).| +| `PLANE_CLI_HOME` | no | Config dir (default `${XDG_CONFIG_HOME:-~/.config}/plane-cli`). | + +**Active project** — `pluse` (no args) opens an `fzf` picker, fetches that project's states, +and persists the choice to `$PLANE_CLI_HOME/active`, so every new shell remembers it. You can +also pass `pluse `. State keys (`backlog/todo/progress/done/cancelled`) +are derived from Plane's state *groups*, so they map correctly on any project. ## Commands @@ -91,8 +104,10 @@ pllabels add "bug" "#FF0000" - `pl -j` emits the filtered issue list as JSON for piping to `jq`. - Comments are created via Plane's `comment_html` field (the API stores HTML). -- `plsync` is idempotent — it keys mirrored items by `[IDENTIFIER] title` and removes ones - that have been closed at the source. +- `plsync` mirrors every project's open issues into one "Everything" project. It resolves the + target from `PLANE_EVERYTHING_PROJECT`, or a project named `Everything` (override the name + with `PLANE_EVERYTHING_NAME`). It's idempotent — items are keyed by `[IDENTIFIER] title` and + ones closed at the source are removed. Run `plsync --dry-run` first to preview. ## License diff --git a/plane.zsh b/plane.zsh index 1376952..1cfa754 100644 --- a/plane.zsh +++ b/plane.zsh @@ -1,25 +1,82 @@ #!/usr/bin/env zsh # plane-cli — a lightweight zsh + curl CLI for Plane (https://plane.so). +# Works with any Plane instance (cloud or self-hosted). Nothing instance-specific +# is hard-coded: state maps are fetched live and the active project is remembered. +# # Source this file from your ~/.zshrc: source /path/to/plane.zsh # -# Required: export PLANE_API_KEY="plane_api_xxx" (Plane: Settings -> API tokens) -# Optional: PLANE_BASE, PLANE_PROJECT, PLANE_PROJECT_NAME (see README) -# Deps: zsh, curl, python3, fzf +# Required env: +# PLANE_API_KEY your Plane API token (Plane: Settings -> API tokens) +# PLANE_BASE https:///api/v1/workspaces/ +# Optional env: +# PLANE_PROJECT pin the active project (UUID); otherwise use `pluse` +# PLANE_PROJECT_NAME display name for the pinned project +# PLANE_EVERYTHING_PROJECT / PLANE_EVERYTHING_NAME target for `plsync` +# PLANE_CLI_HOME config dir (default: ${XDG_CONFIG_HOME:-~/.config}/plane-cli) +# Deps: zsh, curl, python3, fzf -# Plane API token — REQUIRED, taken from the environment (never hard-code it). +# ── Config ──────────────────────────────────────────────────────────────────── _PL_KEY="${PLANE_API_KEY:?plane-cli: set PLANE_API_KEY to your Plane API token}" -_PL_BASE="${PLANE_BASE:-https://plane.ludique.dev/api/v1/workspaces/oloap}" -_PL_PROJECT="${PLANE_PROJECT:-9eab6f76-e0a6-47a3-ab4e-2cd53b71883c}" -_PL_PROJECT_NAME="${PLANE_PROJECT_NAME:-colorcatchers}" +_PL_BASE="${PLANE_BASE:?plane-cli: set PLANE_BASE to https:///api/v1/workspaces/}" +_PL_HOME="${PLANE_CLI_HOME:-${XDG_CONFIG_HOME:-$HOME/.config}/plane-cli}" +_PL_ACTIVE="$_PL_HOME/active" + +# Active project + its fetched state map (resolved lazily; see _pl_init). +_PL_PROJECT="${PLANE_PROJECT:-}" +_PL_PROJECT_NAME="${PLANE_PROJECT_NAME:-}" typeset -gA _PL_STATE -_PL_STATE=([backlog]="02bd8972-e615-4b1f-893f-66e69d20a8e2" [todo]="00507acd-e959-446b-9a8f-c9580bafd1d5" [progress]="09becae7-4702-44c8-9bd5-27d20f82ee02" [done]="65f467f7-d9e1-4d63-a02d-c35b124abb53" [cancelled]="70dd95eb-dabc-4d52-8b7c-6cd27ad9be69") +_PL_STATE=() +_PL_STATE_PROJECT="" # which project _PL_STATE was built for + +# Seed the active project from the persisted file (no network at source time). +if [[ -z "$_PL_PROJECT" && -r "$_PL_ACTIVE" ]]; then + IFS=$'\t' read -r _PL_PROJECT _PL_PROJECT_NAME < "$_PL_ACTIVE" +fi + +# Canonical Plane state group → short key used throughout this CLI. +_PL_GROUP_MAP='{"backlog":"backlog","unstarted":"todo","started":"progress","completed":"done","cancelled":"cancelled"}' _pl_get() { curl -s -H "X-Api-Key: $_PL_KEY" "$@"; } _pl_post() { curl -s -X POST -H "X-Api-Key: $_PL_KEY" -H "Content-Type: application/json" "$@"; } _pl_patch() { curl -s -X PATCH -H "X-Api-Key: $_PL_KEY" -H "Content-Type: application/json" "$@"; } _pl_delete() { curl -s -X DELETE -H "X-Api-Key: $_PL_KEY" "$@"; } -# state key→id map as JSON (reflects the active project after `pluse`) +# ── Lazy resolution of active project + its state map ───────────────────────── +# Ensure an active project is selected (env > persisted file > interactive pick). +_pl_ensure_project() { + [[ -n "$_PL_PROJECT" ]] && return 0 + echo "plane-cli: no active project — pick one (or set PLANE_PROJECT)." >&2 + pluse || return 1 +} + +# Fetch the active project's workflow states into _PL_STATE (once per project/shell). +_pl_ensure_states() { + [[ "$_PL_STATE_PROJECT" == "$_PL_PROJECT" && ${#_PL_STATE} -gt 0 ]] && return 0 + eval "$(_pl_get "$_PL_BASE/projects/$_PL_PROJECT/states/" | python3 -c " +import sys, json +g2k = json.loads(sys.argv[1]) +d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', []) +out = {} +for s in r: + k = g2k.get((s.get('group') or '').lower()) + if k and k not in out: out[k] = s['id'] +print('_PL_STATE=(' + ' '.join(f'[{k}]=\"{v}\"' for k, v in out.items()) + ')') +" "$_PL_GROUP_MAP")" + _PL_STATE_PROJECT="$_PL_PROJECT" +} + +# Resolve active project (+ name) and its states. Call at the top of commands. +_pl_init() { + _pl_ensure_project || return 1 + if [[ -z "$_PL_PROJECT_NAME" || "$_PL_PROJECT_NAME" == "$_PL_PROJECT" ]]; then + _PL_PROJECT_NAME=$(_pl_get "$_PL_BASE/projects/$_PL_PROJECT/" \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('name',''))" 2>/dev/null) + fi + _pl_ensure_states +} + +# ── Helpers ─────────────────────────────────────────────────────────────────── +# state key→id map as JSON (reflects the active project) _pl_state_json() { python3 -c "import json,sys; print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2]))))" "${(@kv)_PL_STATE}"; } # label id→name map as JSON for the active project @@ -89,17 +146,17 @@ _pl_pick() { _pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \ | python3 -c " import sys, json -sn = {'02bd8972-e615-4b1f-893f-66e69d20a8e2':'backlog','00507acd-e959-446b-9a8f-c9580bafd1d5':'todo','09becae7-4702-44c8-9bd5-27d20f82ee02':'progress','65f467f7-d9e1-4d63-a02d-c35b124abb53':'done','70dd95eb-dabc-4d52-8b7c-6cd27ad9be69':'cancelled'} +smap = json.loads(sys.argv[1]); id2key = {v: k for k, v in smap.items()} for i in json.load(sys.stdin).get('results', []): - print(f'{i[\"sequence_id\"]} {i[\"name\"]} [{i[\"priority\"]}] [{sn.get(i[\"state\"],\"?\")}]') -" | fzf --prompt="$prompt > " $multi | awk '{print $1}' + print(f'{i[\"sequence_id\"]} {i[\"name\"]} [{i[\"priority\"]}] [{id2key.get(i[\"state\"],\"?\")}]') +" "$(_pl_state_json)" | fzf --prompt="$prompt > " $multi | awk '{print $1}' } # List open issues | pl -a for all | pl -h for help pl() { if [[ "$1" == "-h" || "$1" == "--help" ]]; then cat <}) pl list open issues pl -a list ALL issues (incl. done/cancelled) @@ -126,6 +183,7 @@ Plane CLI (project: $_PL_PROJECT_NAME) EOF return fi + _pl_init || return local all="" json="" state="" prio="" query="" while [[ $# -gt 0 ]]; do case "$1" in @@ -144,6 +202,7 @@ EOF # pladd "fix login bug" -p high -s todo -d 2026-05-20 # pladd (interactive wizard) pladd() { + _pl_init || return local title="" priority="none" state_key="backlog" due="" labels_csv="" local interactive=false @@ -204,7 +263,7 @@ for l in r: print(l['name']) done [[ -z "$title" ]] && { echo "Usage: pladd [-p priority] [-s state] [-d YYYY-MM-DD] [-l labels]"; return 1; } fi - local state_id="${_PL_STATE[$state_key]:-${_PL_STATE[backlog]}}" + local state_id="${_PL_STATE[$state_key]:-${_PL_STATE[backlog]:-${_PL_STATE[todo]}}}" local labels_json="[]" [[ -n "$labels_csv" ]] && labels_json="$(_pl_resolve_labels "$labels_csv")" local body @@ -227,6 +286,7 @@ print(json.dumps(d)) # Change state interactively or by id: plstate [#] plstate() { + _pl_init || return local seq="${1:-$(_pl_pick "Issue")}" id new_state state_id [[ -z "$seq" ]] && return id=$(_pl_id_from_seq "$seq") @@ -241,6 +301,7 @@ plstate() { # Mark done: pldone [#] pldone() { + _pl_init || return local seq="${1:-$(_pl_pick "Done")}" id [[ -z "$seq" ]] && return id=$(_pl_id_from_seq "$seq") @@ -251,6 +312,7 @@ pldone() { # Fuzzy-pick and inspect: plshow plshow() { + _pl_init || return local seq id seq=$(_pl_pick "Show") [[ -z "$seq" ]] && return @@ -271,8 +333,9 @@ print(f' created : {d[\"created_at\"][:10]}') " "$(_pl_state_json)" "$(_pl_labels_json)" } -# Edit title/priority/due: pledit [#] +# Edit title/priority/due/labels: pledit [#] pledit() { + _pl_init || return local seq="${1:-$(_pl_pick "Edit")}" id [[ -z "$seq" ]] && return id=$(_pl_id_from_seq "$seq") @@ -302,6 +365,7 @@ print(json.dumps(d)) # Delete issues: plrm [# ...] plrm() { + _pl_init || return local -a seqs=("$@") [[ ${#seqs} -eq 0 ]] && seqs=($(_pl_pick "Delete" --multi)) [[ ${#seqs} -eq 0 ]] && return @@ -318,6 +382,7 @@ plrm() { # plcomment 7 list comments on #7 # plcomment 7 fixed it add a comment to #7 plcomment() { + _pl_init || return local seq="" if [[ -n "$1" && "$1" != -* ]]; then seq="$1"; shift; fi [[ -z "$seq" ]] && seq=$(_pl_pick "Comment") @@ -348,6 +413,7 @@ for c in r: # pllabels list project labels # pllabels add "bug" "#FF0000" create a label pllabels() { + _pl_init || return if [[ "$1" == "add" ]]; then local name="$2" color="${3:-#6b7280}" [[ -z "$name" ]] && { echo "Usage: pllabels add \"name\" [#hexcolor]"; return 1; } @@ -377,25 +443,21 @@ for p in projects: " } -# Switch active project: pluse [name|identifier|id] +# Switch active project: pluse [name|identifier|id] (persisted to $_PL_ACTIVE) pluse() { - local raw="$1" - local choice id name - + local raw="$1" choice id name if [[ -z "$raw" ]]; then - # fzf picker choice=$(_pl_get "$_PL_BASE/projects/" | python3 -c " import sys, json projects = json.load(sys.stdin) projects = projects if isinstance(projects, list) else projects.get('results', []) for p in projects: - print(f'{p[\"id\"]} {p[\"identifier\"]:12} {p[\"name\"]}') -" | fzf --prompt="Switch project > ") + print(f'{p[\"id\"]}\t{p[\"identifier\"]}\t{p[\"name\"]}') +" | fzf --delimiter='\t' --with-nth=2,3 --prompt="Switch project > ") [[ -z "$choice" ]] && return - id=$(echo "$choice" | awk '{print $1}') - name=$(echo "$choice" | awk '{print $3}') + id="${choice%%$'\t'*}" + name="${choice##*$'\t'}" else - # match by name, identifier, or id prefix local match match=$(_pl_get "$_PL_BASE/projects/" | python3 -c " import sys, json @@ -404,55 +466,50 @@ projects = projects if isinstance(projects, list) else projects.get('results', [ q = sys.argv[1].lower() for p in projects: if q in p['name'].lower() or q == p['identifier'].lower() or p['id'].startswith(q): - print(p['id'] + ' ' + p['name']) - break + print(p['id'] + '\t' + p['name']); break " "$raw") - if [[ -z "$match" ]]; then - echo "No project matching '$raw'"; return 1 - fi - id=$(echo "$match" | awk '{print $1}') - name=$(echo "$match" | awk '{print $2}') + [[ -z "$match" ]] && { echo "No project matching '$raw'"; return 1; } + id="${match%%$'\t'*}" + name="${match##*$'\t'}" fi - # Fetch states for the new project and update globals - local states_json - states_json=$(_pl_get "$_PL_BASE/projects/$id/states/") - eval "$(python3 -c " -import sys, json -states = json.load(sys.stdin) -states = states if isinstance(states, list) else states.get('results', []) -mapping = {'backlog':'backlog','unstarted':'backlog','started':'progress','in progress':'progress', - 'todo':'todo','done':'done','completed':'done','cancelled':'cancelled'} -out = {} -for s in states: - key = mapping.get(s['group'].lower(), s['group'].lower()) - out.setdefault(key, s['id']) -parts = ' '.join(f'[{k}]=\"{v}\"' for k,v in out.items()) -print(f'typeset -gA _PL_STATE; _PL_STATE=({parts})') -print(f'_PL_PROJECT=\"{sys.argv[1]}\"') -print(f'_PL_PROJECT_NAME=\"{sys.argv[2]}\"') -" "$id" "$name" <<< "$states_json")" - + _PL_PROJECT="$id" + _PL_PROJECT_NAME="$name" + _PL_STATE_PROJECT="" # force a state refetch for the new project + _pl_ensure_states + mkdir -p "$_PL_HOME" + printf '%s\t%s\n' "$id" "$name" > "$_PL_ACTIVE" echo "Switched to project: $name" } - -# Sync open issues from all projects → Everything project -# Uses "[IDENTIFIER] title" as the sync key so re-runs are idempotent. -# Closed/cancelled issues are removed from Everything on next sync. +# Sync open issues from all projects → an "Everything" project (idempotent). +# Target resolves from PLANE_EVERYTHING_PROJECT, else a project named "Everything" +# (override the name with PLANE_EVERYTHING_NAME). Keyed by "[IDENTIFIER] title". plsync() { - local dry="${1:-}" - local EVERYTHING_ID="7effc903-e24a-474e-a300-91e8e9462538" - local KEY="$_PL_KEY" - local BASE="$_PL_BASE" + local dry="${1:-}" everything + if [[ -n "$PLANE_EVERYTHING_PROJECT" ]]; then + everything="$PLANE_EVERYTHING_PROJECT" + else + everything=$(_pl_get "$_PL_BASE/projects/" | python3 -c " +import sys, json +d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', []) +q = sys.argv[1].lower() +for p in r: + if (p.get('name') or '').lower() == q: print(p['id']); break +" "${PLANE_EVERYTHING_NAME:-Everything}") + fi + if [[ -z "$everything" ]]; then + echo "plsync: no target project found. Create a project named 'Everything', or set PLANE_EVERYTHING_PROJECT=<uuid>." >&2 + return 1 + fi - python3 - "$dry" "$KEY" "$BASE" "$EVERYTHING_ID" <<'PYEOF' + python3 - "$dry" "$_PL_KEY" "$_PL_BASE" "$everything" "$_PL_GROUP_MAP" <<'PYEOF' import json, sys, subprocess -dry = sys.argv[1] == "--dry-run" -key = sys.argv[2] -base = sys.argv[3] -everything = sys.argv[4] +dry = sys.argv[1] == "--dry-run" +key = sys.argv[2] +base = sys.argv[3] +everything = sys.argv[4] def api(method, path, body=None): cmd = ["curl", "-s", "-H", f"X-Api-Key: {key}"] @@ -467,47 +524,40 @@ def api(method, path, body=None): def results(data): return data if isinstance(data, list) else data.get("results", []) -# Everything state map: group → id -E_STATES = { - "backlog": "f534d9ae-f9a3-4d30-a49e-ebef2a4655f9", - "unstarted": "70497908-c296-4061-962a-127a36e4deea", - "started": "25028084-45cb-4070-8237-3e111b4cde7f", - "completed": "01edc937-7665-4d15-8e28-7f72185dfba5", - "cancelled": "7707cb0a-fd81-4622-96ec-3150842c02f7", -} DONE_GROUPS = {"completed", "cancelled"} -# ── Collect open issues from all source projects ────────────────────────────── -projects = results(api("GET", "/projects/")) -source = {} # sync_key → {priority, due, state_id} +# Target state map (group → id), fetched live from the Everything project. +E_STATES = {} +for s in results(api("GET", f"/projects/{everything}/states/")): + g = (s.get("group") or "").lower() + E_STATES.setdefault(g, s["id"]) +def estate(group): + return E_STATES.get(group) or E_STATES.get("unstarted") or (next(iter(E_STATES.values())) if E_STATES else None) -for p in projects: - pid = p["id"] - ident = p["identifier"] - name = p["name"] +# ── Collect open issues from all source projects ────────────────────────────── +source = {} # sync_key → {priority, due, state_id} +for p in results(api("GET", "/projects/")): + pid, ident, name = p["id"], p["identifier"], p["name"] if pid == everything: continue print(f" → {name}") - states = {s["id"]: s["group"] for s in results(api("GET", f"/projects/{pid}/states/"))} - issues = results(api("GET", f"/projects/{pid}/issues/")) - for i in issues: + states = {s["id"]: (s.get("group") or "").lower() for s in results(api("GET", f"/projects/{pid}/states/"))} + for i in results(api("GET", f"/projects/{pid}/issues/")): group = states.get(i.get("state", ""), "unstarted") if group in DONE_GROUPS: continue title = i["name"].replace("[", "(").replace("]", ")") sync_key = f"[{ident}] {title}" - due = (i.get("target_date") or "")[:10] or None source[sync_key] = { "priority": i.get("priority", "none"), - "due": due, - "state_id": E_STATES.get(group, E_STATES["unstarted"]), + "due": (i.get("target_date") or "")[:10] or None, + "state_id": estate(group), } print(f"\n {len(source)} open issues across source projects") # ── Fetch existing Everything issues (with current field values) ────────────── -existing = {} # sync_key → {id, priority, due, state_id} -e_states = {s["id"]: s["group"] for s in results(api("GET", f"/projects/{everything}/states/"))} +existing = {} for i in results(api("GET", f"/projects/{everything}/issues/")): if i["name"].startswith("["): existing[i["name"]] = { @@ -517,7 +567,7 @@ for i in results(api("GET", f"/projects/{everything}/issues/")): "state_id": i.get("state"), } -# ── Create or update ────────────────────────────────────────────────────────── +# ── Create / update / remove ────────────────────────────────────────────────── created = updated = skipped = deleted = 0 for sync_key, meta in source.items(): if sync_key not in existing: @@ -549,7 +599,6 @@ for sync_key, meta in source.items(): else: skipped += 1 -# ── Remove stale (closed at source) ────────────────────────────────────────── for sync_key, cur in existing.items(): if sync_key not in source: if dry: @@ -564,4 +613,3 @@ if dry: print(" (dry run — nothing was changed)") PYEOF } -