Make the CLI instance-agnostic: no hard-coded UUIDs
- Config fully from env (PLANE_API_KEY + PLANE_BASE required); remove the baked-in ludique.dev / colorcatchers / state-UUID defaults. - Fetch each project's workflow states live; cache per project per shell. Fixes the buggy group->key mapping (unstarted now maps to 'todo', not 'backlog'). Verified the dynamic map matches the old hard-coded one. - Persist the active project to $PLANE_CLI_HOME/active so `pluse` is remembered across shells; auto-resolve project name from UUID. - plsync resolves its "Everything" target dynamically (PLANE_EVERYTHING_* or by name) and builds its state map from the target's live states. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f846426239
commit
7c3c2053a5
2 changed files with 165 additions and 102 deletions
37
README.md
37
README.md
|
|
@ -25,22 +25,35 @@ Add to your `~/.zshrc`:
|
||||||
|
|
||||||
```sh
|
```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
|
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
|
### Configuration
|
||||||
|
|
||||||
| Env var | Required | Default |
|
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_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 `<your-plane>/api/v1/workspaces/<workspace-slug>`. The active project can
|
| Env var | Required | Purpose |
|
||||||
also be switched at runtime with `pluse` (it re-fetches the project's workflow states).
|
|-----------------------------|----------|---------------------------------------------------------------------|
|
||||||
|
| `PLANE_API_KEY` | **yes** | Your API token (Plane → *Settings → API tokens*). |
|
||||||
|
| `PLANE_BASE` | **yes** | `https://<host>/api/v1/workspaces/<workspace-slug>`. |
|
||||||
|
| `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 <name|identifier|id>`. State keys (`backlog/todo/progress/done/cancelled`)
|
||||||
|
are derived from Plane's state *groups*, so they map correctly on any project.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
|
|
@ -91,8 +104,10 @@ pllabels add "bug" "#FF0000"
|
||||||
|
|
||||||
- `pl -j` emits the filtered issue list as JSON for piping to `jq`.
|
- `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).
|
- 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
|
- `plsync` mirrors every project's open issues into one "Everything" project. It resolves the
|
||||||
that have been closed at the source.
|
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
|
## License
|
||||||
|
|
||||||
|
|
|
||||||
218
plane.zsh
218
plane.zsh
|
|
@ -1,25 +1,82 @@
|
||||||
#!/usr/bin/env zsh
|
#!/usr/bin/env zsh
|
||||||
# plane-cli — a lightweight zsh + curl CLI for Plane (https://plane.so).
|
# 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
|
# Source this file from your ~/.zshrc: source /path/to/plane.zsh
|
||||||
#
|
#
|
||||||
# Required: export PLANE_API_KEY="plane_api_xxx" (Plane: Settings -> API tokens)
|
# Required env:
|
||||||
# Optional: PLANE_BASE, PLANE_PROJECT, PLANE_PROJECT_NAME (see README)
|
# 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
|
# 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_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_BASE="${PLANE_BASE:?plane-cli: set PLANE_BASE to https://<host>/api/v1/workspaces/<workspace>}"
|
||||||
_PL_PROJECT="${PLANE_PROJECT:-9eab6f76-e0a6-47a3-ab4e-2cd53b71883c}"
|
_PL_HOME="${PLANE_CLI_HOME:-${XDG_CONFIG_HOME:-$HOME/.config}/plane-cli}"
|
||||||
_PL_PROJECT_NAME="${PLANE_PROJECT_NAME:-colorcatchers}"
|
_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
|
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_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_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_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" "$@"; }
|
_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}"; }
|
_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
|
# label id→name map as JSON for the active project
|
||||||
|
|
@ -89,17 +146,17 @@ _pl_pick() {
|
||||||
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \
|
_pl_get "$_PL_BASE/projects/$_PL_PROJECT/issues/" \
|
||||||
| python3 -c "
|
| python3 -c "
|
||||||
import sys, json
|
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', []):
|
for i in json.load(sys.stdin).get('results', []):
|
||||||
print(f'{i[\"sequence_id\"]} {i[\"name\"]} [{i[\"priority\"]}] [{sn.get(i[\"state\"],\"?\")}]')
|
print(f'{i[\"sequence_id\"]} {i[\"name\"]} [{i[\"priority\"]}] [{id2key.get(i[\"state\"],\"?\")}]')
|
||||||
" | fzf --prompt="$prompt > " $multi | awk '{print $1}'
|
" "$(_pl_state_json)" | fzf --prompt="$prompt > " $multi | awk '{print $1}'
|
||||||
}
|
}
|
||||||
|
|
||||||
# List open issues | pl -a for all | pl -h for help
|
# List open issues | pl -a for all | pl -h for help
|
||||||
pl() {
|
pl() {
|
||||||
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
if [[ "$1" == "-h" || "$1" == "--help" ]]; then
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
Plane CLI (project: $_PL_PROJECT_NAME)
|
Plane CLI (project: ${_PL_PROJECT_NAME:-<none — run pluse>})
|
||||||
|
|
||||||
pl list open issues
|
pl list open issues
|
||||||
pl -a list ALL issues (incl. done/cancelled)
|
pl -a list ALL issues (incl. done/cancelled)
|
||||||
|
|
@ -126,6 +183,7 @@ Plane CLI (project: $_PL_PROJECT_NAME)
|
||||||
EOF
|
EOF
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
_pl_init || return
|
||||||
local all="" json="" state="" prio="" query=""
|
local all="" json="" state="" prio="" query=""
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -144,6 +202,7 @@ EOF
|
||||||
# pladd "fix login bug" -p high -s todo -d 2026-05-20
|
# pladd "fix login bug" -p high -s todo -d 2026-05-20
|
||||||
# pladd (interactive wizard)
|
# pladd (interactive wizard)
|
||||||
pladd() {
|
pladd() {
|
||||||
|
_pl_init || return
|
||||||
local title="" priority="none" state_key="backlog" due="" labels_csv=""
|
local title="" priority="none" state_key="backlog" due="" labels_csv=""
|
||||||
local interactive=false
|
local interactive=false
|
||||||
|
|
||||||
|
|
@ -204,7 +263,7 @@ for l in r: print(l['name'])
|
||||||
done
|
done
|
||||||
[[ -z "$title" ]] && { echo "Usage: pladd <title> [-p priority] [-s state] [-d YYYY-MM-DD] [-l labels]"; return 1; }
|
[[ -z "$title" ]] && { echo "Usage: pladd <title> [-p priority] [-s state] [-d YYYY-MM-DD] [-l labels]"; return 1; }
|
||||||
fi
|
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="[]"
|
local labels_json="[]"
|
||||||
[[ -n "$labels_csv" ]] && labels_json="$(_pl_resolve_labels "$labels_csv")"
|
[[ -n "$labels_csv" ]] && labels_json="$(_pl_resolve_labels "$labels_csv")"
|
||||||
local body
|
local body
|
||||||
|
|
@ -227,6 +286,7 @@ print(json.dumps(d))
|
||||||
|
|
||||||
# Change state interactively or by id: plstate [#]
|
# Change state interactively or by id: plstate [#]
|
||||||
plstate() {
|
plstate() {
|
||||||
|
_pl_init || return
|
||||||
local seq="${1:-$(_pl_pick "Issue")}" id new_state state_id
|
local seq="${1:-$(_pl_pick "Issue")}" id new_state state_id
|
||||||
[[ -z "$seq" ]] && return
|
[[ -z "$seq" ]] && return
|
||||||
id=$(_pl_id_from_seq "$seq")
|
id=$(_pl_id_from_seq "$seq")
|
||||||
|
|
@ -241,6 +301,7 @@ plstate() {
|
||||||
|
|
||||||
# Mark done: pldone [#]
|
# Mark done: pldone [#]
|
||||||
pldone() {
|
pldone() {
|
||||||
|
_pl_init || return
|
||||||
local seq="${1:-$(_pl_pick "Done")}" id
|
local seq="${1:-$(_pl_pick "Done")}" id
|
||||||
[[ -z "$seq" ]] && return
|
[[ -z "$seq" ]] && return
|
||||||
id=$(_pl_id_from_seq "$seq")
|
id=$(_pl_id_from_seq "$seq")
|
||||||
|
|
@ -251,6 +312,7 @@ pldone() {
|
||||||
|
|
||||||
# Fuzzy-pick and inspect: plshow
|
# Fuzzy-pick and inspect: plshow
|
||||||
plshow() {
|
plshow() {
|
||||||
|
_pl_init || return
|
||||||
local seq id
|
local seq id
|
||||||
seq=$(_pl_pick "Show")
|
seq=$(_pl_pick "Show")
|
||||||
[[ -z "$seq" ]] && return
|
[[ -z "$seq" ]] && return
|
||||||
|
|
@ -271,8 +333,9 @@ print(f' created : {d[\"created_at\"][:10]}')
|
||||||
" "$(_pl_state_json)" "$(_pl_labels_json)"
|
" "$(_pl_state_json)" "$(_pl_labels_json)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Edit title/priority/due: pledit [#]
|
# Edit title/priority/due/labels: pledit [#]
|
||||||
pledit() {
|
pledit() {
|
||||||
|
_pl_init || return
|
||||||
local seq="${1:-$(_pl_pick "Edit")}" id
|
local seq="${1:-$(_pl_pick "Edit")}" id
|
||||||
[[ -z "$seq" ]] && return
|
[[ -z "$seq" ]] && return
|
||||||
id=$(_pl_id_from_seq "$seq")
|
id=$(_pl_id_from_seq "$seq")
|
||||||
|
|
@ -302,6 +365,7 @@ print(json.dumps(d))
|
||||||
|
|
||||||
# Delete issues: plrm [# ...]
|
# Delete issues: plrm [# ...]
|
||||||
plrm() {
|
plrm() {
|
||||||
|
_pl_init || return
|
||||||
local -a seqs=("$@")
|
local -a seqs=("$@")
|
||||||
[[ ${#seqs} -eq 0 ]] && seqs=($(_pl_pick "Delete" --multi))
|
[[ ${#seqs} -eq 0 ]] && seqs=($(_pl_pick "Delete" --multi))
|
||||||
[[ ${#seqs} -eq 0 ]] && return
|
[[ ${#seqs} -eq 0 ]] && return
|
||||||
|
|
@ -318,6 +382,7 @@ plrm() {
|
||||||
# plcomment 7 list comments on #7
|
# plcomment 7 list comments on #7
|
||||||
# plcomment 7 fixed it add a comment to #7
|
# plcomment 7 fixed it add a comment to #7
|
||||||
plcomment() {
|
plcomment() {
|
||||||
|
_pl_init || return
|
||||||
local seq=""
|
local seq=""
|
||||||
if [[ -n "$1" && "$1" != -* ]]; then seq="$1"; shift; fi
|
if [[ -n "$1" && "$1" != -* ]]; then seq="$1"; shift; fi
|
||||||
[[ -z "$seq" ]] && seq=$(_pl_pick "Comment")
|
[[ -z "$seq" ]] && seq=$(_pl_pick "Comment")
|
||||||
|
|
@ -348,6 +413,7 @@ for c in r:
|
||||||
# pllabels list project labels
|
# pllabels list project labels
|
||||||
# pllabels add "bug" "#FF0000" create a label
|
# pllabels add "bug" "#FF0000" create a label
|
||||||
pllabels() {
|
pllabels() {
|
||||||
|
_pl_init || return
|
||||||
if [[ "$1" == "add" ]]; then
|
if [[ "$1" == "add" ]]; then
|
||||||
local name="$2" color="${3:-#6b7280}"
|
local name="$2" color="${3:-#6b7280}"
|
||||||
[[ -z "$name" ]] && { echo "Usage: pllabels add \"name\" [#hexcolor]"; return 1; }
|
[[ -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() {
|
pluse() {
|
||||||
local raw="$1"
|
local raw="$1" choice id name
|
||||||
local choice id name
|
|
||||||
|
|
||||||
if [[ -z "$raw" ]]; then
|
if [[ -z "$raw" ]]; then
|
||||||
# fzf picker
|
|
||||||
choice=$(_pl_get "$_PL_BASE/projects/" | python3 -c "
|
choice=$(_pl_get "$_PL_BASE/projects/" | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
projects = json.load(sys.stdin)
|
projects = json.load(sys.stdin)
|
||||||
projects = projects if isinstance(projects, list) else projects.get('results', [])
|
projects = projects if isinstance(projects, list) else projects.get('results', [])
|
||||||
for p in projects:
|
for p in projects:
|
||||||
print(f'{p[\"id\"]} {p[\"identifier\"]:12} {p[\"name\"]}')
|
print(f'{p[\"id\"]}\t{p[\"identifier\"]}\t{p[\"name\"]}')
|
||||||
" | fzf --prompt="Switch project > ")
|
" | fzf --delimiter='\t' --with-nth=2,3 --prompt="Switch project > ")
|
||||||
[[ -z "$choice" ]] && return
|
[[ -z "$choice" ]] && return
|
||||||
id=$(echo "$choice" | awk '{print $1}')
|
id="${choice%%$'\t'*}"
|
||||||
name=$(echo "$choice" | awk '{print $3}')
|
name="${choice##*$'\t'}"
|
||||||
else
|
else
|
||||||
# match by name, identifier, or id prefix
|
|
||||||
local match
|
local match
|
||||||
match=$(_pl_get "$_PL_BASE/projects/" | python3 -c "
|
match=$(_pl_get "$_PL_BASE/projects/" | python3 -c "
|
||||||
import sys, json
|
import sys, json
|
||||||
|
|
@ -404,49 +466,44 @@ projects = projects if isinstance(projects, list) else projects.get('results', [
|
||||||
q = sys.argv[1].lower()
|
q = sys.argv[1].lower()
|
||||||
for p in projects:
|
for p in projects:
|
||||||
if q in p['name'].lower() or q == p['identifier'].lower() or p['id'].startswith(q):
|
if q in p['name'].lower() or q == p['identifier'].lower() or p['id'].startswith(q):
|
||||||
print(p['id'] + ' ' + p['name'])
|
print(p['id'] + '\t' + p['name']); break
|
||||||
break
|
|
||||||
" "$raw")
|
" "$raw")
|
||||||
if [[ -z "$match" ]]; then
|
[[ -z "$match" ]] && { echo "No project matching '$raw'"; return 1; }
|
||||||
echo "No project matching '$raw'"; return 1
|
id="${match%%$'\t'*}"
|
||||||
fi
|
name="${match##*$'\t'}"
|
||||||
id=$(echo "$match" | awk '{print $1}')
|
|
||||||
name=$(echo "$match" | awk '{print $2}')
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Fetch states for the new project and update globals
|
_PL_PROJECT="$id"
|
||||||
local states_json
|
_PL_PROJECT_NAME="$name"
|
||||||
states_json=$(_pl_get "$_PL_BASE/projects/$id/states/")
|
_PL_STATE_PROJECT="" # force a state refetch for the new project
|
||||||
eval "$(python3 -c "
|
_pl_ensure_states
|
||||||
import sys, json
|
mkdir -p "$_PL_HOME"
|
||||||
states = json.load(sys.stdin)
|
printf '%s\t%s\n' "$id" "$name" > "$_PL_ACTIVE"
|
||||||
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"
|
echo "Switched to project: $name"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Sync open issues from all projects → an "Everything" project (idempotent).
|
||||||
# Sync open issues from all projects → Everything project
|
# Target resolves from PLANE_EVERYTHING_PROJECT, else a project named "Everything"
|
||||||
# Uses "[IDENTIFIER] title" as the sync key so re-runs are idempotent.
|
# (override the name with PLANE_EVERYTHING_NAME). Keyed by "[IDENTIFIER] title".
|
||||||
# Closed/cancelled issues are removed from Everything on next sync.
|
|
||||||
plsync() {
|
plsync() {
|
||||||
local dry="${1:-}"
|
local dry="${1:-}" everything
|
||||||
local EVERYTHING_ID="7effc903-e24a-474e-a300-91e8e9462538"
|
if [[ -n "$PLANE_EVERYTHING_PROJECT" ]]; then
|
||||||
local KEY="$_PL_KEY"
|
everything="$PLANE_EVERYTHING_PROJECT"
|
||||||
local BASE="$_PL_BASE"
|
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
|
import json, sys, subprocess
|
||||||
|
|
||||||
dry = sys.argv[1] == "--dry-run"
|
dry = sys.argv[1] == "--dry-run"
|
||||||
|
|
@ -467,47 +524,40 @@ def api(method, path, body=None):
|
||||||
def results(data):
|
def results(data):
|
||||||
return data if isinstance(data, list) else data.get("results", [])
|
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"}
|
DONE_GROUPS = {"completed", "cancelled"}
|
||||||
|
|
||||||
# ── Collect open issues from all source projects ──────────────────────────────
|
# Target state map (group → id), fetched live from the Everything project.
|
||||||
projects = results(api("GET", "/projects/"))
|
E_STATES = {}
|
||||||
source = {} # sync_key → {priority, due, state_id}
|
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:
|
# ── Collect open issues from all source projects ──────────────────────────────
|
||||||
pid = p["id"]
|
source = {} # sync_key → {priority, due, state_id}
|
||||||
ident = p["identifier"]
|
for p in results(api("GET", "/projects/")):
|
||||||
name = p["name"]
|
pid, ident, name = p["id"], p["identifier"], p["name"]
|
||||||
if pid == everything:
|
if pid == everything:
|
||||||
continue
|
continue
|
||||||
print(f" → {name}")
|
print(f" → {name}")
|
||||||
states = {s["id"]: s["group"] for s in results(api("GET", f"/projects/{pid}/states/"))}
|
states = {s["id"]: (s.get("group") or "").lower() for s in results(api("GET", f"/projects/{pid}/states/"))}
|
||||||
issues = results(api("GET", f"/projects/{pid}/issues/"))
|
for i in results(api("GET", f"/projects/{pid}/issues/")):
|
||||||
for i in issues:
|
|
||||||
group = states.get(i.get("state", ""), "unstarted")
|
group = states.get(i.get("state", ""), "unstarted")
|
||||||
if group in DONE_GROUPS:
|
if group in DONE_GROUPS:
|
||||||
continue
|
continue
|
||||||
title = i["name"].replace("[", "(").replace("]", ")")
|
title = i["name"].replace("[", "(").replace("]", ")")
|
||||||
sync_key = f"[{ident}] {title}"
|
sync_key = f"[{ident}] {title}"
|
||||||
due = (i.get("target_date") or "")[:10] or None
|
|
||||||
source[sync_key] = {
|
source[sync_key] = {
|
||||||
"priority": i.get("priority", "none"),
|
"priority": i.get("priority", "none"),
|
||||||
"due": due,
|
"due": (i.get("target_date") or "")[:10] or None,
|
||||||
"state_id": E_STATES.get(group, E_STATES["unstarted"]),
|
"state_id": estate(group),
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"\n {len(source)} open issues across source projects")
|
print(f"\n {len(source)} open issues across source projects")
|
||||||
|
|
||||||
# ── Fetch existing Everything issues (with current field values) ──────────────
|
# ── Fetch existing Everything issues (with current field values) ──────────────
|
||||||
existing = {} # sync_key → {id, priority, due, state_id}
|
existing = {}
|
||||||
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/")):
|
for i in results(api("GET", f"/projects/{everything}/issues/")):
|
||||||
if i["name"].startswith("["):
|
if i["name"].startswith("["):
|
||||||
existing[i["name"]] = {
|
existing[i["name"]] = {
|
||||||
|
|
@ -517,7 +567,7 @@ for i in results(api("GET", f"/projects/{everything}/issues/")):
|
||||||
"state_id": i.get("state"),
|
"state_id": i.get("state"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Create or update ──────────────────────────────────────────────────────────
|
# ── Create / update / remove ──────────────────────────────────────────────────
|
||||||
created = updated = skipped = deleted = 0
|
created = updated = skipped = deleted = 0
|
||||||
for sync_key, meta in source.items():
|
for sync_key, meta in source.items():
|
||||||
if sync_key not in existing:
|
if sync_key not in existing:
|
||||||
|
|
@ -549,7 +599,6 @@ for sync_key, meta in source.items():
|
||||||
else:
|
else:
|
||||||
skipped += 1
|
skipped += 1
|
||||||
|
|
||||||
# ── Remove stale (closed at source) ──────────────────────────────────────────
|
|
||||||
for sync_key, cur in existing.items():
|
for sync_key, cur in existing.items():
|
||||||
if sync_key not in source:
|
if sync_key not in source:
|
||||||
if dry:
|
if dry:
|
||||||
|
|
@ -564,4 +613,3 @@ if dry:
|
||||||
print(" (dry run — nothing was changed)")
|
print(" (dry run — nothing was changed)")
|
||||||
PYEOF
|
PYEOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue