#!/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 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 # ── Config ──────────────────────────────────────────────────────────────────── _PL_KEY="${PLANE_API_KEY:?plane-cli: set PLANE_API_KEY to your Plane API token}" _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=() _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" "$@"; } # ── 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 _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 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\"]}] [{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) 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 pladd create an issue (interactive wizard) pladd [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 _pl_init || return 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() { _pl_init || return 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 <title> [-p priority] [-s state] [-d YYYY-MM-DD] [-l labels]"; return 1; } fi 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 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() { _pl_init || return 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() { _pl_init || return 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() { _pl_init || return 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/labels: pledit [#] pledit() { _pl_init || return 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() { _pl_init || return 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() { _pl_init || return 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() { _pl_init || return 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] (persisted to $_PL_ACTIVE) pluse() { local raw="$1" choice id name if [[ -z "$raw" ]]; then 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\"]}\t{p[\"identifier\"]}\t{p[\"name\"]}') " | fzf --delimiter='\t' --with-nth=2,3 --prompt="Switch project > ") [[ -z "$choice" ]] && return id="${choice%%$'\t'*}" name="${choice##*$'\t'}" else 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'] + '\t' + p['name']); break " "$raw") [[ -z "$match" ]] && { echo "No project matching '$raw'"; return 1; } id="${match%%$'\t'*}" name="${match##*$'\t'}" fi _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 → 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:-}" 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" "$_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] 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", []) DONE_GROUPS = {"completed", "cancelled"} # 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) # ── 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.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}" source[sync_key] = { "priority": i.get("priority", "none"), "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 = {} 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 / update / remove ────────────────────────────────────────────────── 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 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 }