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 <noreply@anthropic.com>
This commit is contained in:
commit
f846426239
4 changed files with 691 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
.DS_Store
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||||
99
README.md
Normal file
99
README.md
Normal file
|
|
@ -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 `<your-plane>/api/v1/workspaces/<workspace-slug>`. 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
|
||||||
567
plane.zsh
Normal file
567
plane.zsh
Normal file
|
|
@ -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: <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
|
||||||
|
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 <<EOF
|
||||||
|
Plane CLI (project: $_PL_PROJECT_NAME)
|
||||||
|
|
||||||
|
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
|
||||||
|
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 <title> [-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
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in a new issue