commit f84642623917596c48a6ed4156b4c145012d4230 Author: theo Date: Thu Jun 4 17:00:36 2026 +0200 Initial commit: plane-cli zsh wrapper for Plane A lightweight zsh + curl CLI for Plane with fzf pickers: list/create/edit/ delete issues, state/priority/title filters, JSON output, comments, labels, multi-project switching, and cross-project sync. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b666ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +*.local +.env +.env.* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9d8f157 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Theo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..981fac8 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# plane-cli + +A lightweight **zsh + curl** CLI for [Plane](https://plane.so) (works with self-hosted +instances). No build step, no dependencies to install beyond what you already have — it's +a single sourced shell file that wraps the Plane REST API with `fzf` pickers. + +> Personal tool, built for a self-hosted Plane instance. Inspired by +> [`cpatrickalves/plane-cli`](https://github.com/cpatrickalves/plane-cli), but deliberately +> tiny: just functions in your shell. + +## Requirements + +- `zsh` +- `curl` +- `python3` (used for JSON shaping — stdlib only) +- `fzf` (interactive pickers) + +## Install + +```sh +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 +source ~/Developer/plane-cli/plane.zsh +``` + +Reload: `source ~/.zshrc`. + +### 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` | + +`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). + +## Commands + +``` +pl list open issues (shows labels) +pl -a list ALL issues (incl. done/cancelled) +pl -s backlog|todo|... filter by state +pl -p urgent|high|... filter by priority +pl /text search titles containing 'text' +pl -j raw JSON output (pipe to jq); honours filters +pl -h full help + +pladd create an issue (interactive wizard) +pladd "title" [opts] create inline + -p priority urgent|high|medium|low (default: none) + -s state backlog|todo|progress (default: backlog) + -d YYYY-MM-DD due date + -l a,b,c attach labels (must already exist) + +plstate [#] change state — fzf picker if no id +pldone [#] mark done — fzf picker if no id +plshow fzf-pick and inspect an issue (shows labels) +pledit [#] edit title/priority/due/labels +plrm [# ...] delete — fzf multi-select if no ids + +plcomment [#] [text] list comments on an issue, or add one +pllabels [add "n" [#hex]] list project labels, or create one + +plprojects list all projects +pluse [name|id] switch active project (fzf if no arg) +plsync [--dry-run] mirror open issues from all projects into an "Everything" project +``` + +### Examples + +```sh +pl -s todo # only todo issues +pl -p high # only high priority +pl /login # titles containing "login" +pl -j | jq -r '.[].name' # scriptable JSON + +pladd "Fix export button" -p high -s todo -l bug,urgent +plcomment 7 "fixed in latest deploy" +pllabels add "bug" "#FF0000" +``` + +## Notes + +- `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. + +## License + +MIT diff --git a/plane.zsh b/plane.zsh new file mode 100644 index 0000000..1376952 --- /dev/null +++ b/plane.zsh @@ -0,0 +1,567 @@ +#!/usr/bin/env zsh +# plane-cli — a lightweight zsh + curl CLI for Plane (https://plane.so). +# 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 + +# Plane API token — REQUIRED, taken from the environment (never hard-code it). +_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}" +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_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`) +_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 +_pl_labels_json() { + _pl_get "$_PL_BASE/projects/$_PL_PROJECT/labels/" | python3 -c " +import sys, json +d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', []) +print(json.dumps({l['id']: l['name'] for l in r})) +" +} + +# Resolve "a,b,c" label names → JSON id array (warns on unknown names → stderr) +_pl_resolve_labels() { + _pl_get "$_PL_BASE/projects/$_PL_PROJECT/labels/" | python3 -c " +import sys, json +d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', []) +by = {l['name'].lower(): l['id'] for l in r} +ids = [] +for nm in [x.strip() for x in sys.argv[1].split(',') if x.strip()]: + if nm.lower() in by: ids.append(by[nm.lower()]) + else: sys.stderr.write(f' ! no label named \"{nm}\" (create with: pllabels add \"{nm}\")\n') +print(json.dumps(ids)) +" "$1" +} + +# Resolve sequence_id → UUID +_pl_id_from_seq() { + _pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \ + | python3 -c " +import sys, json +for i in json.load(sys.stdin).get('results', []): + if str(i['sequence_id']) == '${1}': + print(i['id']); break +" +} + +# Filter + format issues. +# args: +_pl_fmt_issues() { + python3 -c " +import sys, json +all_flag, json_out, fstate, fprio, query = sys.argv[1:6] +smap = json.loads(sys.argv[6]) # state key -> id +labels = json.loads(sys.argv[7]) # label id -> name +id2key = {v: k for k, v in smap.items()} +done_ids = {smap.get('done'), smap.get('cancelled')} +out = [] +for i in json.load(sys.stdin).get('results', []): + if all_flag != '-a' and i.get('state') in done_ids: continue + if fstate and i.get('state') != smap.get(fstate): continue + if fprio and i.get('priority') != fprio: continue + if query and query.lower() not in i['name'].lower(): continue + out.append(i) +if json_out == '1': + print(json.dumps(out, indent=2)); sys.exit() +for i in out: + due = (i.get('target_date') or '')[:10] or '—' + lbl = ','.join(labels.get(l, l[:6]) for l in i.get('labels', [])) + lbl = ' {' + lbl + '}' if lbl else '' + print(f'#{i[\"sequence_id\"]:>3} {i[\"name\"]} [{i[\"priority\"]}] [{id2key.get(i[\"state\"],\"?\")}] due:{due}{lbl}') +" "$@" +} + +# fzf picker → sequence_id; pass --multi for multi-select +_pl_pick() { + local prompt="${1:-Issue}" multi="${2:---no-multi}" + _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'} +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}' +} + +# List open issues | pl -a for all | pl -h for help +pl() { + if [[ "$1" == "-h" || "$1" == "--help" ]]; then + cat < [opts] create an issue (inline) + -p, --priority urgent|high|medium|low default: none + -s, --state backlog|todo|progress default: backlog + -d, --due YYYY-MM-DD set due date + -l, --labels a,b,c attach labels (must exist) + plstate [#] change state — fzf picker if no id + pldone [#] mark done — fzf picker if no id + plshow fuzzy-pick and inspect an issue (shows labels) + pledit [#] edit title/priority/due/labels — fzf if no id + plrm [# ...] delete — fzf multi-select if no ids + plcomment [#] [text] list comments on an issue, or add one + pllabels [add "n" [#hex]] list project labels, or create one + plprojects list all projects + pluse [name|id] switch active project (fzf if no arg) + plsync [--dry-run] mirror open issues from all projects → Everything +EOF + return + fi + local all="" json="" state="" prio="" query="" + while [[ $# -gt 0 ]]; do + case "$1" in + -a|--all) all="-a"; shift ;; + -j|--json) json="1"; shift ;; + -s|--state) state="$2"; shift 2 ;; + -p|--priority) prio="$2"; shift 2 ;; + /*) query="${1#/}"; shift ;; + *) query="$1"; shift ;; + esac + done + _pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \ + | _pl_fmt_issues "$all" "$json" "$state" "$prio" "$query" "$(_pl_state_json)" "$(_pl_labels_json)" +} + +# pladd "fix login bug" -p high -s todo -d 2026-05-20 +# pladd (interactive wizard) +pladd() { + local title="" priority="none" state_key="backlog" due="" labels_csv="" + local interactive=false + + if [[ $# -eq 0 ]]; then + interactive=true + + # 1. Pick project + local proj_choice proj_id proj_name + proj_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', []) +active = '${_PL_PROJECT}' +for p in projects: + marker = ' ←' if p['id'] == active else '' + print(f'{p[\"id\"]} {p[\"name\"]}{marker}') +" | fzf --prompt="Project > ") + [[ -z "$proj_choice" ]] && return + proj_id=$(echo "$proj_choice" | awk '{print $1}') + proj_name=$(echo "$proj_choice" | sed 's/^[^ ]* *//') + + # 2. Title + echo -n "Title: "; read title + [[ -z "$title" ]] && { echo "Cancelled."; return 1; } + + # 3. Priority + priority=$(printf '%s\n' none urgent high medium low | fzf --prompt="Priority > ") + [[ -z "$priority" ]] && priority="none" + + # 4. State + state_key=$(printf '%s\n' backlog todo progress | fzf --prompt="State > ") + [[ -z "$state_key" ]] && state_key="backlog" + + # 5. Due date (optional) + echo -n "Due date (YYYY-MM-DD, enter to skip): "; read due + + # 6. Labels (optional, multi-select from the chosen project's labels) + labels_csv=$(_pl_get "$_PL_BASE/projects/$proj_id/labels/" | python3 -c " +import sys, json +d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', []) +for l in r: print(l['name']) +" | fzf --multi --prompt="Labels (tab=multi, esc=none) > " | paste -sd, -) + + # Temporarily switch project if different + local orig_project="$_PL_PROJECT" orig_name="$_PL_PROJECT_NAME" + if [[ "$proj_id" != "$_PL_PROJECT" ]]; then + pluse "$proj_id" > /dev/null + fi + else + while [[ $# -gt 0 ]]; do + case "$1" in + -p|--priority) priority="$2"; shift 2 ;; + -s|--state) state_key="$2"; shift 2 ;; + -d|--due) due="$2"; shift 2 ;; + -l|--labels) labels_csv="$2"; shift 2 ;; + *) title+="${title:+ }$1"; shift ;; + esac + 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 labels_json="[]" + [[ -n "$labels_csv" ]] && labels_json="$(_pl_resolve_labels "$labels_csv")" + local body + body=$(python3 -c " +import json, sys +d = {'name': sys.argv[1], 'priority': sys.argv[2], 'state': sys.argv[3]} +if sys.argv[4]: d['target_date'] = sys.argv[4] +lbls = json.loads(sys.argv[5]) +if lbls: d['labels'] = lbls +print(json.dumps(d)) +" "$title" "$priority" "$state_id" "$due" "$labels_json") + _pl_post "$_PL_BASE/projects/$_PL_PROJECT/issues/" -d "$body" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created #{d[\"sequence_id\"]} {d[\"name\"]} [{d[\"priority\"]}]')" + + # Restore original project if we switched for interactive mode + if [[ "$interactive" == true && "$orig_project" != "$proj_id" ]]; then + pluse "$orig_project" > /dev/null + fi +} + +# Change state interactively or by id: plstate [#] +plstate() { + local seq="${1:-$(_pl_pick "Issue")}" id new_state state_id + [[ -z "$seq" ]] && return + id=$(_pl_id_from_seq "$seq") + [[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; } + new_state=$(printf '%s\n' backlog todo progress done cancelled | fzf --prompt="New state > ") + [[ -z "$new_state" ]] && return + state_id="${_PL_STATE[$new_state]}" + [[ -z "$state_id" ]] && { echo "Unknown state: $new_state"; return 1; } + _pl_patch "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" -d "{\"state\":\"$state_id\"}" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Updated #{d[\"sequence_id\"]} {d[\"name\"]}')" +} + +# Mark done: pldone [#] +pldone() { + local seq="${1:-$(_pl_pick "Done")}" id + [[ -z "$seq" ]] && return + id=$(_pl_id_from_seq "$seq") + [[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; } + _pl_patch "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" -d "{\"state\":\"${_PL_STATE[done]}\"}" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Done #{d[\"sequence_id\"]} {d[\"name\"]}')" +} + +# Fuzzy-pick and inspect: plshow +plshow() { + local seq id + seq=$(_pl_pick "Show") + [[ -z "$seq" ]] && return + id=$(_pl_id_from_seq "$seq") + _pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" | python3 -c " +import sys, json +d = json.load(sys.stdin) +smap = json.loads(sys.argv[1]); id2key = {v: k for k, v in smap.items()} +labels = json.loads(sys.argv[2]) +due = (d.get('target_date') or '')[:10] or '—' +lbl = ', '.join(labels.get(l, l) for l in d.get('labels', [])) or '—' +print(f'#{d[\"sequence_id\"]} {d[\"name\"]}') +print(f' priority : {d[\"priority\"]}') +print(f' state : {id2key.get(d[\"state\"],\"?\")}') +print(f' labels : {lbl}') +print(f' due : {due}') +print(f' created : {d[\"created_at\"][:10]}') +" "$(_pl_state_json)" "$(_pl_labels_json)" +} + +# Edit title/priority/due: pledit [#] +pledit() { + local seq="${1:-$(_pl_pick "Edit")}" id + [[ -z "$seq" ]] && return + id=$(_pl_id_from_seq "$seq") + [[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; } + local title priority due labels_in + echo -n "New title (enter to skip): "; read title + echo -n "New priority (enter to skip): "; read priority + echo -n "New due YYYY-MM-DD (enter to skip): "; read due + echo -n "Labels a,b,c (enter=skip, - =clear): "; read labels_in + local labels_json="" + if [[ "$labels_in" == "-" ]]; then labels_json="[]" + elif [[ -n "$labels_in" ]]; then labels_json="$(_pl_resolve_labels "$labels_in")"; fi + local body + body=$(python3 -c " +import json, sys +d = {} +if sys.argv[1]: d['name'] = sys.argv[1] +if sys.argv[2]: d['priority'] = sys.argv[2] +if sys.argv[3]: d['target_date'] = sys.argv[3] +if sys.argv[4] != '': d['labels'] = json.loads(sys.argv[4]) +print(json.dumps(d)) +" "$title" "$priority" "$due" "$labels_json") + [[ "$body" == "{}" ]] && { echo "Nothing changed."; return; } + _pl_patch "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" -d "$body" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Updated #{d[\"sequence_id\"]} {d[\"name\"]} [{d[\"priority\"]}]')" +} + +# Delete issues: plrm [# ...] +plrm() { + local -a seqs=("$@") + [[ ${#seqs} -eq 0 ]] && seqs=($(_pl_pick "Delete" --multi)) + [[ ${#seqs} -eq 0 ]] && return + for seq in "${seqs[@]}"; do + local id=$(_pl_id_from_seq "$seq") + if [[ -z "$id" ]]; then echo "Issue #$seq not found"; continue; fi + _pl_delete "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/" > /dev/null + echo "Deleted #$seq" + done +} + +# Comments: plcomment [#] [text...] +# plcomment fzf-pick an issue, then list its comments +# plcomment 7 list comments on #7 +# plcomment 7 fixed it add a comment to #7 +plcomment() { + local seq="" + if [[ -n "$1" && "$1" != -* ]]; then seq="$1"; shift; fi + [[ -z "$seq" ]] && seq=$(_pl_pick "Comment") + [[ -z "$seq" ]] && return + local id; id=$(_pl_id_from_seq "$seq") + [[ -z "$id" ]] && { echo "Issue #$seq not found"; return 1; } + local text="$*" + if [[ -n "$text" ]]; then + _pl_post "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/comments/" \ + -d "$(python3 -c 'import json,sys,html; print(json.dumps({"comment_html":"<p>"+html.escape(sys.argv[1])+"</p>"}))' "$text")" \ + > /dev/null + echo "Commented on #$seq" + return + fi + _pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/$id/comments/" | python3 -c " +import sys, json, re +d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', []) +if not r: print(' (no comments)'); sys.exit() +for c in r: + txt = c.get('comment_stripped') or re.sub('<[^>]+>', '', c.get('comment_html', '') or '') + txt = ' '.join(txt.split()) + when = (c.get('created_at') or '')[:16].replace('T', ' ') + print(f' [{when}] {txt}') +" +} + +# Labels: pllabels [add "name" [#hexcolor]] +# pllabels list project labels +# pllabels add "bug" "#FF0000" create a label +pllabels() { + if [[ "$1" == "add" ]]; then + local name="$2" color="${3:-#6b7280}" + [[ -z "$name" ]] && { echo "Usage: pllabels add \"name\" [#hexcolor]"; return 1; } + _pl_post "$_PL_BASE/projects/$_PL_PROJECT/labels/" \ + -d "$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"color":sys.argv[2]}))' "$name" "$color")" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Created label {d.get(\"name\",\"?\")} {d.get(\"color\",\"\")}')" + return + fi + _pl_get "$_PL_BASE/projects/$_PL_PROJECT/labels/" | python3 -c " +import sys, json +d = json.load(sys.stdin); r = d if isinstance(d, list) else d.get('results', []) +if not r: print(' (no labels yet — pllabels add \"name\" [#hexcolor])'); sys.exit() +for l in r: print(f' {l[\"name\"]:20} {l.get(\"color\",\"\")}') +" +} + +# List all workspace projects: plprojects +plprojects() { + _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', []) +active = '${_PL_PROJECT}' +for p in projects: + marker = ' ←' if p['id'] == active else '' + print(f' {p[\"identifier\"]:12} {p[\"name\"]}{marker} ({p[\"id\"]})') +" +} + +# Switch active project: pluse [name|identifier|id] +pluse() { + local raw="$1" + local 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 > ") + [[ -z "$choice" ]] && return + id=$(echo "$choice" | awk '{print $1}') + name=$(echo "$choice" | awk '{print $3}') + else + # match by name, identifier, or id prefix + local match + match=$(_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', []) +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 +" "$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}') + 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")" + + 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. +plsync() { + local dry="${1:-}" + local EVERYTHING_ID="7effc903-e24a-474e-a300-91e8e9462538" + local KEY="$_PL_KEY" + local BASE="$_PL_BASE" + + python3 - "$dry" "$KEY" "$BASE" "$EVERYTHING_ID" <<'PYEOF' +import json, sys, subprocess + +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}"] + if method in ("POST", "PATCH"): + cmd += ["-X", method, "-H", "Content-Type: application/json", "-d", json.dumps(body)] + elif method == "DELETE": + cmd += ["-X", "DELETE"] + cmd.append(f"{base}{path}") + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) + return json.loads(out) if out.strip() else {} + +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} + +for p in projects: + pid = p["id"] + ident = p["identifier"] + name = 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: + 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"]), + } + +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/"))} +for i in results(api("GET", f"/projects/{everything}/issues/")): + if i["name"].startswith("["): + existing[i["name"]] = { + "id": i["id"], + "priority": i.get("priority", "none"), + "due": (i.get("target_date") or "")[:10] or None, + "state_id": i.get("state"), + } + +# ── Create or update ────────────────────────────────────────────────────────── +created = updated = skipped = deleted = 0 +for sync_key, meta in source.items(): + if sync_key not in existing: + body = {"name": sync_key, "priority": meta["priority"], "state": meta["state_id"]} + if meta["due"]: + body["target_date"] = meta["due"] + if dry: + print(f" [dry] create: {sync_key} [{meta['priority']}] due:{meta['due'] or '—'}") + else: + r = api("POST", f"/projects/{everything}/issues/", body) + print(f" ✓ created #{r.get('sequence_id','?')} {sync_key}") + created += 1 + else: + cur = existing[sync_key] + patch = {} + if meta["priority"] != cur["priority"]: + patch["priority"] = meta["priority"] + if meta["due"] != cur["due"]: + patch["target_date"] = meta["due"] or "" + if meta["state_id"] != cur["state_id"]: + patch["state"] = meta["state_id"] + if patch: + if dry: + print(f" [dry] update: {sync_key} {patch}") + else: + api("PATCH", f"/projects/{everything}/issues/{cur['id']}/", patch) + print(f" ↻ updated: {sync_key} {list(patch.keys())}") + updated += 1 + else: + skipped += 1 + +# ── Remove stale (closed at source) ────────────────────────────────────────── +for sync_key, cur in existing.items(): + if sync_key not in source: + if dry: + print(f" [dry] remove: {sync_key}") + else: + api("DELETE", f"/projects/{everything}/issues/{cur['id']}/") + print(f" 🗑 removed: {sync_key}") + deleted += 1 + +print(f"\n✅ Sync complete — created:{created} updated:{updated} skipped:{skipped} removed:{deleted}") +if dry: + print(" (dry run — nothing was changed)") +PYEOF +} +