plane-cli/plane.zsh

616 lines
25 KiB
Bash
Raw Permalink Normal View History

#!/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://<host>/api/v1/workspaces/<workspace-slug>
# 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://<host>/api/v1/workspaces/<workspace>}"
_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: <all> <json> <state> <priority> <query> <state_map_json> <label_map_json>
_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 <<EOF
Plane CLI (project: ${_PL_PROJECT_NAME:-<none — run pluse>})
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 <title> [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
}