@delorenj/pjangler 1.4.5 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -520
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +5747 -13483
- package/dist/mcp-server.js +6853 -14059
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -513
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -1,671 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env sh
|
|
2
|
-
# Plane ticket-provider adapter.
|
|
3
|
-
#
|
|
4
|
-
# Credentials: PLANE_API_KEY or PLANE_<WORKSPACE>_API_KEY (raw at runtime or
|
|
5
|
-
# an op:// reference resolved immediately before use)
|
|
6
|
-
# Endpoint: PLANE_BASE (default https://plane.delo.sh)
|
|
7
|
-
# Board binding (repo-root .project.json `ticket_provider:`):
|
|
8
|
-
# workspace: <workspace-slug> (or env PLANE_WORKSPACE)
|
|
9
|
-
# board_id: <project-uuid> (set by create_board / 42-ticket-provider)
|
|
10
|
-
# timezone: <IANA timezone> optional project calendar override
|
|
11
|
-
# state_map: { started: "In Progress", in_review: "In Review",
|
|
12
|
-
# completed: "Done", cancelled: "Cancelled" } optional
|
|
13
|
-
#
|
|
14
|
-
# Rate limiting: reads retry HTTP 429 up to PLANE_READ_MAX_ATTEMPTS (default 4)
|
|
15
|
-
# times, sleeping the server's Retry-After capped at PLANE_429_MAX_DELAY
|
|
16
|
-
# (default 30s). Mutations are never retried in the transport layer, and
|
|
17
|
-
# transition sends at most ONE PATCH: without a version/precondition guard a
|
|
18
|
-
# repeated PATCH can stomp a concurrent actor, so the live read-back after the
|
|
19
|
-
# single attempt is the only success proof. PLANE_MAX_PAGES bounds every
|
|
20
|
-
# paginated collection (default 1000). All numeric overrides are validated
|
|
21
|
-
# before any request.
|
|
22
|
-
#
|
|
23
|
-
# Plane model: project = board, cycle = milestone, state.group in
|
|
24
|
-
# backlog|unstarted|started|completed|cancelled.
|
|
25
|
-
#
|
|
26
|
-
# NOTE: REST paths follow Plane's v1 public API. Verify against a live board on
|
|
27
|
-
# first use; state/cycle naming varies per workspace.
|
|
28
|
-
set -eu
|
|
29
|
-
|
|
30
|
-
OP="${1:-}"; shift 2>/dev/null || true
|
|
31
|
-
ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
32
|
-
ROLE_YAML="$ROLE_DIR/role.yaml"
|
|
33
|
-
BASE="${PLANE_BASE:-https://plane.delo.sh}"
|
|
34
|
-
|
|
35
|
-
FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
|
|
36
|
-
|
|
37
|
-
die() { echo "plane: $*" >&2; exit 1; }
|
|
38
|
-
need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
|
|
39
|
-
|
|
40
|
-
validated_uint() {
|
|
41
|
-
setting="$1"; value="$2"; minimum="$3"; maximum="$4"
|
|
42
|
-
case "$value" in ''|*[!0-9]*) die "$setting must be an integer from $minimum through $maximum" ;; esac
|
|
43
|
-
[ "$value" -ge "$minimum" ] && [ "$value" -le "$maximum" ] \
|
|
44
|
-
|| die "$setting must be an integer from $minimum through $maximum"
|
|
45
|
-
printf '%s' "$value"
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
workspace_key() {
|
|
49
|
-
key="$(printf '%s' "${1:-default}" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
|
|
50
|
-
[ -n "$key" ] || key="DEFAULT"
|
|
51
|
-
printf 'PLANE_%s_API_KEY' "$key"
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
# dotenv_value FILE KEY — read one exact dotenv assignment as inert data.
|
|
55
|
-
# Never source the shared fleet file: it may contain unrelated command
|
|
56
|
-
# substitutions or credential helpers that this provider must not execute.
|
|
57
|
-
dotenv_value() {
|
|
58
|
-
python3 - "$1" "$2" <<'PY'
|
|
59
|
-
import pathlib, sys
|
|
60
|
-
|
|
61
|
-
path = pathlib.Path(sys.argv[1])
|
|
62
|
-
key = sys.argv[2]
|
|
63
|
-
value = ""
|
|
64
|
-
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
65
|
-
line = raw.strip()
|
|
66
|
-
if not line or line.startswith("#"):
|
|
67
|
-
continue
|
|
68
|
-
if line.startswith("export "):
|
|
69
|
-
line = line[7:].lstrip()
|
|
70
|
-
name, sep, candidate = line.partition("=")
|
|
71
|
-
if sep and name.strip() == key:
|
|
72
|
-
candidate = candidate.strip()
|
|
73
|
-
if len(candidate) >= 2 and candidate[0] == candidate[-1] and candidate[0] in "'\"":
|
|
74
|
-
candidate = candidate[1:-1]
|
|
75
|
-
value = candidate
|
|
76
|
-
break
|
|
77
|
-
print(value, end="")
|
|
78
|
-
PY
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
# Resolve an approved secret reference only after selecting the exact provider
|
|
82
|
-
# key. The shared dotenv stays inert and the resolved value exists only in this
|
|
83
|
-
# provider process.
|
|
84
|
-
resolve_secret_value() {
|
|
85
|
-
value="${1:-}"
|
|
86
|
-
case "$value" in
|
|
87
|
-
op://*)
|
|
88
|
-
command -v op >/dev/null 2>&1 || die "1Password CLI is required for the configured Plane credential"
|
|
89
|
-
op read "$value" || die "failed to resolve the configured Plane credential"
|
|
90
|
-
;;
|
|
91
|
-
*) printf '%s' "$value" ;;
|
|
92
|
-
esac
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
tp_cfg() {
|
|
96
|
-
[ -f "$ROLE_YAML" ] || return 0
|
|
97
|
-
python3 - "$ROLE_YAML" "$1" <<'PY'
|
|
98
|
-
import sys, re, pathlib
|
|
99
|
-
text = pathlib.Path(sys.argv[1]).read_text()
|
|
100
|
-
m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', text + "\n\x00")
|
|
101
|
-
block = m.group(1) if m else ""
|
|
102
|
-
mm = re.search(rf'(?m)^\s*{re.escape(sys.argv[2])}:\s*"?([^"\n]*)"?\s*$', block)
|
|
103
|
-
print(mm.group(1).strip() if mm else "")
|
|
104
|
-
PY
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
# pj_cfg KEY — read ticket_provider.<KEY> from the repo-root .project.json (the
|
|
108
|
-
# SOT), walking up from the role dir. This is preferred over role.yaml so all of
|
|
109
|
-
# a repo's agents resolve to the same board.
|
|
110
|
-
pj_cfg() {
|
|
111
|
-
python3 - "$ROLE_DIR" "$1" <<'PY'
|
|
112
|
-
import sys, json, pathlib
|
|
113
|
-
start = pathlib.Path(sys.argv[1]).resolve(); key = sys.argv[2]
|
|
114
|
-
for parent in [start, *start.parents]:
|
|
115
|
-
f = parent / ".project.json"
|
|
116
|
-
if f.is_file():
|
|
117
|
-
try: tp = (json.loads(f.read_text()).get("ticket_provider") or {})
|
|
118
|
-
except Exception: tp = {}
|
|
119
|
-
print(tp.get(key, "") if isinstance(tp, dict) else ""); break
|
|
120
|
-
else:
|
|
121
|
-
print("")
|
|
122
|
-
PY
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
# Board binding: .project.json (SOT) first, then legacy role.yaml, then env.
|
|
126
|
-
WS="$(pj_cfg workspace)"; [ -n "$WS" ] || WS="$(tp_cfg workspace)"; WS="${WS:-${PLANE_WORKSPACE:-}}"
|
|
127
|
-
PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg board_id)"
|
|
128
|
-
SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
|
|
129
|
-
SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
|
|
130
|
-
SM_CANCELLED="$(tp_cfg cancelled)"; SM_CANCELLED="${SM_CANCELLED:-Cancelled}"
|
|
131
|
-
SM_STARTED="$(tp_cfg started)"
|
|
132
|
-
SM_UNSTARTED="$(tp_cfg unstarted)"
|
|
133
|
-
SM_BACKLOG="$(tp_cfg backlog)"
|
|
134
|
-
CALENDAR_TZ="$(pj_cfg timezone)"; [ -n "$CALENDAR_TZ" ] || CALENDAR_TZ="$(tp_cfg timezone)"
|
|
135
|
-
CALENDAR_TZ="${CALENDAR_TZ:-${TICKET_PROVIDER_TIMEZONE:-${TZ:-}}}"
|
|
136
|
-
API="$BASE/api/v1/workspaces/$WS"
|
|
137
|
-
|
|
138
|
-
READ_MAX_ATTEMPTS="$(validated_uint PLANE_READ_MAX_ATTEMPTS "${PLANE_READ_MAX_ATTEMPTS:-4}" 1 20)"
|
|
139
|
-
RETRY_DEFAULT_DELAY="$(validated_uint PLANE_429_RETRY_DELAY "${PLANE_429_RETRY_DELAY:-1}" 0 3600)"
|
|
140
|
-
RETRY_MAX_DELAY="$(validated_uint PLANE_429_MAX_DELAY "${PLANE_429_MAX_DELAY:-30}" 0 3600)"
|
|
141
|
-
MAX_PAGES="$(validated_uint PLANE_MAX_PAGES "${PLANE_MAX_PAGES:-1000}" 1 1000)"
|
|
142
|
-
|
|
143
|
-
if [ -z "${PLANE_API_KEY:-}" ]; then
|
|
144
|
-
KEY="$(workspace_key "$WS")"
|
|
145
|
-
PLANE_API_KEY="$(printenv "$KEY" 2>/dev/null || true)"
|
|
146
|
-
if [ -z "${PLANE_API_KEY:-}" ] && [ -f "$FLEET_ENV" ]; then
|
|
147
|
-
PLANE_API_KEY="$(dotenv_value "$FLEET_ENV" "$KEY")"
|
|
148
|
-
fi
|
|
149
|
-
fi
|
|
150
|
-
PLANE_API_KEY="$(resolve_secret_value "${PLANE_API_KEY:-}")"
|
|
151
|
-
export PLANE_API_KEY
|
|
152
|
-
|
|
153
|
-
# api METHOD PATH [JSON_BODY] — call Plane REST, print response body.
|
|
154
|
-
# Reads (GET) are idempotent: on HTTP 429 they retry a bounded number of times,
|
|
155
|
-
# sleeping the server's Retry-After (capped, default when absent). Mutations
|
|
156
|
-
# are never retried here — a caller must check whether a failed mutation landed
|
|
157
|
-
# before repeating it. Any non-2xx outcome dies with the explicit status.
|
|
158
|
-
api() {
|
|
159
|
-
need_key
|
|
160
|
-
method="$1"; path="$2"; body="${3:-}"
|
|
161
|
-
max_attempts=1
|
|
162
|
-
case "$method" in GET) max_attempts="$READ_MAX_ATTEMPTS" ;; esac
|
|
163
|
-
api_scratch="$(mktemp -d "${TMPDIR:-/tmp}/plane-api.XXXXXX")" \
|
|
164
|
-
|| die "could not create API scratch directory"
|
|
165
|
-
response_file="$api_scratch/response"
|
|
166
|
-
headers_file="$api_scratch/headers"
|
|
167
|
-
cleanup_api_scratch() {
|
|
168
|
-
rm -f "$response_file" "$headers_file"
|
|
169
|
-
rmdir "$api_scratch" 2>/dev/null || true
|
|
170
|
-
}
|
|
171
|
-
trap cleanup_api_scratch 0
|
|
172
|
-
trap 'cleanup_api_scratch; exit 129' HUP
|
|
173
|
-
trap 'cleanup_api_scratch; exit 130' INT
|
|
174
|
-
trap 'cleanup_api_scratch; exit 143' TERM
|
|
175
|
-
attempt=0
|
|
176
|
-
while :; do
|
|
177
|
-
attempt=$((attempt + 1))
|
|
178
|
-
: > "$response_file"
|
|
179
|
-
: > "$headers_file"
|
|
180
|
-
curl_exit=0
|
|
181
|
-
if [ -n "$body" ]; then
|
|
182
|
-
captured="$(curl -sS -o "$response_file" -D "$headers_file" -w '%{http_code}' -X "$method" "$API/$path" \
|
|
183
|
-
-H "X-API-Key: $PLANE_API_KEY" -H "Content-Type: application/json" \
|
|
184
|
-
-H "User-Agent: curl/8.0" \
|
|
185
|
-
-d "$body")" || curl_exit=$?
|
|
186
|
-
else
|
|
187
|
-
captured="$(curl -sS -o "$response_file" -D "$headers_file" -w '%{http_code}' -X "$method" "$API/$path" \
|
|
188
|
-
-H "X-API-Key: $PLANE_API_KEY" \
|
|
189
|
-
-H "User-Agent: curl/8.0")" || curl_exit=$?
|
|
190
|
-
fi
|
|
191
|
-
if [ "$curl_exit" -ne 0 ]; then
|
|
192
|
-
cleanup_api_scratch
|
|
193
|
-
trap - 0 HUP INT TERM
|
|
194
|
-
die "$method $path failed at transport (curl exit $curl_exit)"
|
|
195
|
-
fi
|
|
196
|
-
case "$captured" in
|
|
197
|
-
[1-5][0-9][0-9])
|
|
198
|
-
status="$captured"
|
|
199
|
-
;;
|
|
200
|
-
*)
|
|
201
|
-
cleanup_api_scratch
|
|
202
|
-
trap - 0 HUP INT TERM
|
|
203
|
-
die "$method $path returned invalid HTTP status ${captured:-empty}"
|
|
204
|
-
;;
|
|
205
|
-
esac
|
|
206
|
-
output="$(cat "$response_file")"
|
|
207
|
-
if [ "$status" = 429 ] && [ "$attempt" -lt "$max_attempts" ]; then
|
|
208
|
-
delay="$(python3 - "$headers_file" "$RETRY_DEFAULT_DELAY" "$RETRY_MAX_DELAY" <<'PY'
|
|
209
|
-
import datetime
|
|
210
|
-
import email.utils
|
|
211
|
-
import math
|
|
212
|
-
import sys
|
|
213
|
-
|
|
214
|
-
raw = ""
|
|
215
|
-
with open(sys.argv[1], encoding="utf-8", errors="replace") as stream:
|
|
216
|
-
for line in stream:
|
|
217
|
-
name, _, value = line.partition(":")
|
|
218
|
-
if name.strip().lower() == "retry-after":
|
|
219
|
-
raw = value.strip()
|
|
220
|
-
fallback = int(sys.argv[2])
|
|
221
|
-
maximum = int(sys.argv[3])
|
|
222
|
-
if raw.isdigit():
|
|
223
|
-
delay = int(raw)
|
|
224
|
-
else:
|
|
225
|
-
try:
|
|
226
|
-
target = email.utils.parsedate_to_datetime(raw)
|
|
227
|
-
if target.tzinfo is None:
|
|
228
|
-
target = target.replace(tzinfo=datetime.timezone.utc)
|
|
229
|
-
delay = max(
|
|
230
|
-
0,
|
|
231
|
-
math.ceil(
|
|
232
|
-
(target - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
|
|
233
|
-
),
|
|
234
|
-
)
|
|
235
|
-
except (TypeError, ValueError, OverflowError):
|
|
236
|
-
delay = fallback
|
|
237
|
-
print(min(delay, maximum))
|
|
238
|
-
PY
|
|
239
|
-
)"
|
|
240
|
-
sleep "$delay"
|
|
241
|
-
continue
|
|
242
|
-
fi
|
|
243
|
-
case "$status" in
|
|
244
|
-
2*)
|
|
245
|
-
printf '%s' "$output"
|
|
246
|
-
cleanup_api_scratch
|
|
247
|
-
trap - 0 HUP INT TERM
|
|
248
|
-
return 0
|
|
249
|
-
;;
|
|
250
|
-
esac
|
|
251
|
-
detail="$(printf '%s' "$output" | head -c 300 | tr '\n' ' ')"
|
|
252
|
-
cleanup_api_scratch
|
|
253
|
-
trap - 0 HUP INT TERM
|
|
254
|
-
die "$method $path failed (HTTP $status)${detail:+: $detail}"
|
|
255
|
-
done
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
# api_all PATH — GET every page of a Plane list endpoint and print one merged
|
|
259
|
-
# JSON array. Plane v1 paginates with a next_cursor query token and a
|
|
260
|
-
# next_page_results flag; bare-list and single-page responses pass through.
|
|
261
|
-
api_all() {
|
|
262
|
-
path="$1"
|
|
263
|
-
page_scratch="$(mktemp -d "${TMPDIR:-/tmp}/plane-pages.XXXXXX")" \
|
|
264
|
-
|| die "could not create pagination scratch directory"
|
|
265
|
-
pages_file="$page_scratch/pages"
|
|
266
|
-
cursors_file="$page_scratch/cursors"
|
|
267
|
-
: > "$pages_file"
|
|
268
|
-
: > "$cursors_file"
|
|
269
|
-
cleanup_page_scratch() {
|
|
270
|
-
rm -f "$pages_file" "$cursors_file"
|
|
271
|
-
rmdir "$page_scratch" 2>/dev/null || true
|
|
272
|
-
}
|
|
273
|
-
trap cleanup_page_scratch 0
|
|
274
|
-
trap 'cleanup_page_scratch; exit 129' HUP
|
|
275
|
-
trap 'cleanup_page_scratch; exit 130' INT
|
|
276
|
-
trap 'cleanup_page_scratch; exit 143' TERM
|
|
277
|
-
cursor=""
|
|
278
|
-
page_count=0
|
|
279
|
-
while :; do
|
|
280
|
-
page_count=$((page_count + 1))
|
|
281
|
-
[ "$page_count" -le "$MAX_PAGES" ] \
|
|
282
|
-
|| die "pagination for $path exceeded PLANE_MAX_PAGES=$MAX_PAGES"
|
|
283
|
-
case "$path" in *\?*) sep="&" ;; *) sep="?" ;; esac
|
|
284
|
-
if [ -n "$cursor" ]; then
|
|
285
|
-
encoded_cursor="$(python3 -c 'import sys,urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$cursor")"
|
|
286
|
-
page_path="$path${sep}cursor=$encoded_cursor"
|
|
287
|
-
else
|
|
288
|
-
page_path="$path"
|
|
289
|
-
fi
|
|
290
|
-
if ! page="$(api GET "$page_path")"; then
|
|
291
|
-
cleanup_page_scratch
|
|
292
|
-
trap - 0 HUP INT TERM
|
|
293
|
-
return 1
|
|
294
|
-
fi
|
|
295
|
-
printf '%s\0' "$page" >> "$pages_file"
|
|
296
|
-
page_meta="$(printf '%s' "$page" | python3 -c 'import sys,json
|
|
297
|
-
d=json.load(sys.stdin)
|
|
298
|
-
if isinstance(d,list):
|
|
299
|
-
print("done")
|
|
300
|
-
elif isinstance(d,dict):
|
|
301
|
-
more=d.get("next_page_results", False)
|
|
302
|
-
if more is True:
|
|
303
|
-
cursor=str(d.get("next_cursor") or "")
|
|
304
|
-
if not cursor:
|
|
305
|
-
raise SystemExit("plane: pagination reported another page without a cursor")
|
|
306
|
-
print("next\t"+cursor)
|
|
307
|
-
else:
|
|
308
|
-
print("done")
|
|
309
|
-
else:
|
|
310
|
-
raise SystemExit("plane: paginated endpoint returned neither an object nor a list")')"
|
|
311
|
-
case "$page_meta" in
|
|
312
|
-
done) break ;;
|
|
313
|
-
next*) next="${page_meta#* }" ;;
|
|
314
|
-
*) die "invalid pagination metadata for $path" ;;
|
|
315
|
-
esac
|
|
316
|
-
cursor_fingerprint="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "$next")"
|
|
317
|
-
if grep -Fqx "$cursor_fingerprint" "$cursors_file"; then
|
|
318
|
-
die "pagination cursor for $path repeated"
|
|
319
|
-
fi
|
|
320
|
-
printf '%s\n' "$cursor_fingerprint" >> "$cursors_file"
|
|
321
|
-
cursor="$next"
|
|
322
|
-
done
|
|
323
|
-
python3 - "$pages_file" <<'PY'
|
|
324
|
-
import json
|
|
325
|
-
import pathlib
|
|
326
|
-
import sys
|
|
327
|
-
|
|
328
|
-
rows = []
|
|
329
|
-
for chunk in pathlib.Path(sys.argv[1]).read_bytes().split(b"\0"):
|
|
330
|
-
if not chunk:
|
|
331
|
-
continue
|
|
332
|
-
d = json.loads(chunk)
|
|
333
|
-
if isinstance(d, list):
|
|
334
|
-
rows.extend(d)
|
|
335
|
-
elif isinstance(d, dict):
|
|
336
|
-
rows.extend(d.get("results") or [])
|
|
337
|
-
print(json.dumps(rows))
|
|
338
|
-
PY
|
|
339
|
-
cleanup_page_scratch
|
|
340
|
-
trap - 0 HUP INT TERM
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
# Select the one date-current cycle. An empty result is explicit and must never
|
|
344
|
-
# fall back to the first historical cycle returned by Plane.
|
|
345
|
-
current_cycle() {
|
|
346
|
-
CALENDAR_TZ="$CALENDAR_TZ" TICKET_PROVIDER_NOW="${TICKET_PROVIDER_NOW:-}" python3 -c 'import sys,json,datetime,os
|
|
347
|
-
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
348
|
-
d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
|
|
349
|
-
tz_name=os.environ.get("CALENDAR_TZ", "").strip()
|
|
350
|
-
try:
|
|
351
|
-
calendar_tz=ZoneInfo(tz_name) if tz_name else (datetime.datetime.now().astimezone().tzinfo or datetime.timezone.utc)
|
|
352
|
-
except (ZoneInfoNotFoundError, ValueError):
|
|
353
|
-
raise SystemExit(f"plane: invalid project timezone {tz_name!r}")
|
|
354
|
-
clock=os.environ.get("TICKET_PROVIDER_NOW", "").strip()
|
|
355
|
-
if clock:
|
|
356
|
-
try: now=datetime.datetime.fromisoformat(clock.replace("Z", "+00:00"))
|
|
357
|
-
except ValueError: raise SystemExit(f"plane: invalid TICKET_PROVIDER_NOW {clock!r}")
|
|
358
|
-
if now.tzinfo is None: raise SystemExit("plane: TICKET_PROVIDER_NOW must include a UTC offset")
|
|
359
|
-
else:
|
|
360
|
-
now=datetime.datetime.now(datetime.timezone.utc)
|
|
361
|
-
today=now.astimezone(calendar_tz).date()
|
|
362
|
-
def boundary(value):
|
|
363
|
-
try: return datetime.date.fromisoformat(str(value)[:10])
|
|
364
|
-
except (TypeError, ValueError): return None
|
|
365
|
-
def current(c):
|
|
366
|
-
start,end=boundary(c.get("start_date")),boundary(c.get("end_date"))
|
|
367
|
-
return bool(start and end and start <= today <= end)
|
|
368
|
-
active=sorted((c for c in rows if current(c)), key=lambda c:(str(c.get("start_date") or ""),str(c.get("id") or "")), reverse=True)
|
|
369
|
-
m=active[0] if active else {}
|
|
370
|
-
print(json.dumps({"id":m.get("id", ""),"name":m.get("name", ""),"state":"active" if m else "inactive"}))'
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
# Map a normalized state -> exactly one concrete Plane state id. A configured
|
|
374
|
-
# name must exist in the expected group; it never falls back to another state in
|
|
375
|
-
# the same group. An unnamed group is safe only when the group has one member.
|
|
376
|
-
resolve_state_id() {
|
|
377
|
-
want="$1"
|
|
378
|
-
[ -n "$PROJ" ] || die "ticket_provider.project not set"
|
|
379
|
-
case "$want" in
|
|
380
|
-
completed) grp=completed; nm="$SM_DONE" ;;
|
|
381
|
-
cancelled) grp=cancelled; nm="$SM_CANCELLED" ;;
|
|
382
|
-
in_review) grp=started; nm="$SM_IN_REVIEW" ;;
|
|
383
|
-
started) grp=started; nm="$SM_STARTED" ;;
|
|
384
|
-
unstarted) grp=unstarted; nm="$SM_UNSTARTED" ;;
|
|
385
|
-
backlog) grp=backlog; nm="$SM_BACKLOG" ;;
|
|
386
|
-
*) die "invalid normalized state: $want" ;;
|
|
387
|
-
esac
|
|
388
|
-
api_all "projects/$PROJ/states/" | GRP="$grp" NM="$nm" WANT="$want" python3 -c 'import sys,json,os
|
|
389
|
-
d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
|
|
390
|
-
grp=os.environ["GRP"]; nm=os.environ.get("NM","").strip(); want=os.environ["WANT"]
|
|
391
|
-
grouped=[s for s in rows if s.get("group")==grp]
|
|
392
|
-
if nm:
|
|
393
|
-
candidates=[s for s in grouped if str(s.get("name","")).strip().casefold()==nm.casefold()]
|
|
394
|
-
if len(candidates) != 1:
|
|
395
|
-
raise SystemExit(f"plane: exact Plane state {nm!r} for normalized {want!r} was not resolved uniquely in group {grp!r}")
|
|
396
|
-
else:
|
|
397
|
-
candidates=grouped
|
|
398
|
-
if len(candidates) != 1:
|
|
399
|
-
names=", ".join(str(s.get("name") or "") for s in candidates) or "none"
|
|
400
|
-
raise SystemExit(f"plane: normalized state {want!r} is ambiguous in group {grp!r} ({names}); configure ticket_provider.{want}")
|
|
401
|
-
state_id=str(candidates[0].get("id") or "")
|
|
402
|
-
if not state_id:
|
|
403
|
-
raise SystemExit(f"plane: resolved Plane state for normalized {want!r} has no id")
|
|
404
|
-
print(state_id)'
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
# All Plane ops except the explicit-workspace read below require the bound
|
|
408
|
-
# workspace API key; fail fast and clean before any pipe.
|
|
409
|
-
case "$OP" in describe_board) ;; *) need_key ;; esac
|
|
410
|
-
|
|
411
|
-
case "$OP" in
|
|
412
|
-
resolve)
|
|
413
|
-
[ -n "$WS" ] || die "workspace not set (.project.json ticket_provider.workspace or PLANE_WORKSPACE)"
|
|
414
|
-
[ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
|
|
415
|
-
PROJECT_DETAIL="$(api GET "projects/$PROJ/")"
|
|
416
|
-
LIVE_IDENTIFIER="$(printf '%s' "$PROJECT_DETAIL" | python3 -c 'import sys,json
|
|
417
|
-
try: print(str(json.load(sys.stdin).get("identifier") or ""))
|
|
418
|
-
except Exception: print("")')"
|
|
419
|
-
[ -n "$LIVE_IDENTIFIER" ] || die "live Plane project omitted its authoritative identifier"
|
|
420
|
-
printf '{"provider":"plane","board_id":"%s","board_url":"%s/%s/projects/%s/issues/","identifier":"%s"}\n' \
|
|
421
|
-
"$PROJ" "$BASE" "$WS" "$PROJ" "$LIVE_IDENTIFIER"
|
|
422
|
-
;;
|
|
423
|
-
|
|
424
|
-
active_milestone)
|
|
425
|
-
[ -n "$PROJ" ] || die "project not set"
|
|
426
|
-
api_all "projects/$PROJ/cycles/" | current_cycle
|
|
427
|
-
;;
|
|
428
|
-
|
|
429
|
-
list_issues)
|
|
430
|
-
[ -n "$PROJ" ] || die "project not set"
|
|
431
|
-
# Plane v1 returns issue.state as a bare UUID, so join against the states map.
|
|
432
|
-
STATES="$(api_all "projects/$PROJ/states/")"
|
|
433
|
-
ISSUES="$(api_all "projects/$PROJ/issues/")"
|
|
434
|
-
MILESTONE="$(api_all "projects/$PROJ/cycles/" | current_cycle)"
|
|
435
|
-
MILESTONE_ID="$(printf '%s' "$MILESTONE" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id", ""))')"
|
|
436
|
-
MILESTONE_ISSUES='[]'
|
|
437
|
-
if [ -n "$MILESTONE_ID" ]; then
|
|
438
|
-
MILESTONE_ISSUES="$(api_all "projects/$PROJ/cycles/$MILESTONE_ID/cycle-issues/")"
|
|
439
|
-
fi
|
|
440
|
-
printf '%s\0%s\0%s\0%s' "$STATES" "$ISSUES" "$MILESTONE" "$MILESTONE_ISSUES" \
|
|
441
|
-
| BASE="$BASE" WS="$WS" PROJ="$PROJ" python3 -c 'import sys,json,os
|
|
442
|
-
parts=sys.stdin.buffer.read().split(b"\0",3)
|
|
443
|
-
srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
|
|
444
|
-
smap={s.get("id"):(s.get("name",""),s.get("group","")) for s in srows}
|
|
445
|
-
d=json.loads(parts[1] or "{}"); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
|
|
446
|
-
milestone=json.loads(parts[2] or "{}"); milestone_id=str(milestone.get("id") or ""); milestone_name=str(milestone.get("name") or "")
|
|
447
|
-
md=json.loads(parts[3] or "{}"); mrows=md if isinstance(md,list) else md.get("results", []) if isinstance(md,dict) else []
|
|
448
|
-
member_ids={str(issue.get("id") or "") for issue in mrows}
|
|
449
|
-
base,ws,proj=os.environ["BASE"],os.environ["WS"],os.environ["PROJ"]
|
|
450
|
-
out=[]
|
|
451
|
-
for n in rows:
|
|
452
|
-
iid=n.get("id","")
|
|
453
|
-
name,group=smap.get(n.get("state",""),("",""))
|
|
454
|
-
out.append({"id":iid,"key":n.get("sequence_id",iid),
|
|
455
|
-
"title":n.get("name",""),"state":name,"state_type":group,
|
|
456
|
-
"updated_at":n.get("updated_at",""),"assignee":"",
|
|
457
|
-
"active_milestone_id":milestone_id,
|
|
458
|
-
"active_milestone_name":milestone_name,
|
|
459
|
-
"in_active_milestone":bool(milestone_id and str(iid) in member_ids),
|
|
460
|
-
"url":base+"/"+ws+"/projects/"+proj+"/issues/"+str(iid)})
|
|
461
|
-
print(json.dumps(out))'
|
|
462
|
-
;;
|
|
463
|
-
|
|
464
|
-
get_issue)
|
|
465
|
-
ID="${1:?usage: get_issue <id>}"
|
|
466
|
-
STATES="$(api_all "projects/$PROJ/states/")"
|
|
467
|
-
ISSUE="$(api GET "projects/$PROJ/issues/$ID/")"
|
|
468
|
-
COMM="$(api_all "projects/$PROJ/issues/$ID/comments/" 2>/dev/null || echo '[]')"
|
|
469
|
-
ATTACH="$(api_all "projects/$PROJ/issues/$ID/issue-attachments/" 2>/dev/null || echo '[]')"
|
|
470
|
-
printf '%s\n%s\n%s\n%s\n' "$STATES" "$ISSUE" "$COMM" "$ATTACH" | python3 -c 'import sys,json,re
|
|
471
|
-
parts=sys.stdin.read().split("\n",3)
|
|
472
|
-
srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
|
|
473
|
-
smap={s.get("id"):(s.get("name",""),s.get("group","")) for s in srows}
|
|
474
|
-
i=json.loads(parts[1] or "{}"); c=json.loads(parts[2] or "[]"); a=json.loads(parts[3] or "[]")
|
|
475
|
-
rows=c if isinstance(c,list) else c.get("results", []) if isinstance(c,dict) else []
|
|
476
|
-
arows=a if isinstance(a,list) else a.get("results", []) if isinstance(a,dict) else []
|
|
477
|
-
def strip(h): return re.sub(r"<[^>]+>","",h or "").strip()
|
|
478
|
-
name,group=smap.get(i.get("state",""),("",""))
|
|
479
|
-
desc=strip(i.get("description_html",""))
|
|
480
|
-
cs=[{"id":x.get("id",""),"body":strip(x.get("comment_html","")),"author":""} for x in rows]
|
|
481
|
-
ats=[]
|
|
482
|
-
for x in arows:
|
|
483
|
-
attrs=x.get("attributes") or {}
|
|
484
|
-
ats.append({"id":x.get("id",""),"name":attrs.get("name",x.get("name","")),
|
|
485
|
-
"type":attrs.get("type",x.get("type","")),
|
|
486
|
-
"size":attrs.get("size",x.get("size",0)),"asset":x.get("asset",""),
|
|
487
|
-
"url":x.get("asset_url",x.get("url","")),
|
|
488
|
-
"created_at":x.get("created_at",""),"updated_at":x.get("updated_at",""),
|
|
489
|
-
"is_uploaded":x.get("is_uploaded",False)})
|
|
490
|
-
print(json.dumps({"id":i.get("id",""),"key":i.get("sequence_id",""),"title":i.get("name",""),
|
|
491
|
-
"description":desc,"acceptance":desc,
|
|
492
|
-
"state":name,"state_type":group,"comments":cs,"attachments":ats}))'
|
|
493
|
-
;;
|
|
494
|
-
|
|
495
|
-
comment)
|
|
496
|
-
ID="${1:?usage: comment <id> <body>}"; BODY="${2:?}"
|
|
497
|
-
api POST "projects/$PROJ/issues/$ID/comments/" \
|
|
498
|
-
"$(python3 -c 'import json,sys; print(json.dumps({"comment_html":"<p>"+sys.argv[1]+"</p>"}))' "$BODY")" \
|
|
499
|
-
| EXPECTED_ID="$ID" python3 -c 'import sys,json,os
|
|
500
|
-
comment=json.load(sys.stdin)
|
|
501
|
-
if not isinstance(comment,dict):
|
|
502
|
-
raise SystemExit("plane: comment response was not an object")
|
|
503
|
-
comment_id=comment.get("id")
|
|
504
|
-
if not isinstance(comment_id,str) or not comment_id.strip():
|
|
505
|
-
raise SystemExit("plane: comment response omitted its comment id")
|
|
506
|
-
for key in ("issue", "issue_id"):
|
|
507
|
-
if key not in comment:
|
|
508
|
-
continue
|
|
509
|
-
linked=comment[key]
|
|
510
|
-
linked_id=linked.get("id") if isinstance(linked,dict) else linked
|
|
511
|
-
if not isinstance(linked_id,str) or linked_id.strip()!=os.environ["EXPECTED_ID"]:
|
|
512
|
-
raise SystemExit("plane: comment response identified a different issue")
|
|
513
|
-
print(comment_id.strip())'
|
|
514
|
-
;;
|
|
515
|
-
|
|
516
|
-
transition)
|
|
517
|
-
ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
|
|
518
|
-
SID="$(resolve_state_id "$TARGET")"
|
|
519
|
-
# Without a version or precondition guard, a repeated PATCH can stomp a
|
|
520
|
-
# concurrent actor, so exactly one PATCH mutation attempt is ever sent.
|
|
521
|
-
# The live read-back after that attempt is the only success proof: an exact
|
|
522
|
-
# same-issue match on the intended state id yields `ok`. A 2xx PATCH whose
|
|
523
|
-
# read-back disagrees is reported as concurrent divergence; an ambiguous
|
|
524
|
-
# (transport-level or non-2xx) PATCH may still be confirmed landed by an
|
|
525
|
-
# exact read-back. Any other read-back outcome fails without a second PATCH.
|
|
526
|
-
# Subshell: a transport-level die inside api must not kill this op.
|
|
527
|
-
if ( api PATCH "projects/$PROJ/issues/$ID/" "$(printf '{"state":"%s"}' "$SID")" ) >/dev/null; then
|
|
528
|
-
PATCH_2XX=1
|
|
529
|
-
else
|
|
530
|
-
PATCH_2XX=0
|
|
531
|
-
fi
|
|
532
|
-
if ! READBACK="$(api GET "projects/$PROJ/issues/$ID/")"; then
|
|
533
|
-
die "transition read-back failed after the single PATCH attempt; refusing to repeat the PATCH"
|
|
534
|
-
fi
|
|
535
|
-
if ! READBACK_RESULT="$(printf '%s' "$READBACK" | EXPECTED_ID="$ID" EXPECTED_SID="$SID" python3 -c 'import sys,json,os
|
|
536
|
-
try:
|
|
537
|
-
d=json.load(sys.stdin)
|
|
538
|
-
except (TypeError, ValueError) as exc:
|
|
539
|
-
raise SystemExit(f"plane: transition read-back was not valid JSON: {exc}")
|
|
540
|
-
if not isinstance(d,dict) or str(d.get("id") or "") != os.environ["EXPECTED_ID"]:
|
|
541
|
-
raise SystemExit("plane: transition read-back did not identify the requested issue")
|
|
542
|
-
state=d.get("state", "")
|
|
543
|
-
actual=str(state.get("id") or "") if isinstance(state,dict) else str(state or "")
|
|
544
|
-
if not actual:
|
|
545
|
-
raise SystemExit("plane: transition read-back omitted its state id")
|
|
546
|
-
if actual == os.environ["EXPECTED_SID"]:
|
|
547
|
-
sequence=str(d.get("sequence_id") or "")
|
|
548
|
-
if not sequence:
|
|
549
|
-
raise SystemExit("plane: transition read-back omitted its sequence id")
|
|
550
|
-
print("match\t"+sequence)
|
|
551
|
-
else:
|
|
552
|
-
print("mismatch\t"+actual)')"; then
|
|
553
|
-
die "transition read-back could not be verified after the single PATCH attempt; refusing to repeat the PATCH"
|
|
554
|
-
fi
|
|
555
|
-
case "$READBACK_RESULT" in
|
|
556
|
-
match*)
|
|
557
|
-
printf 'ok %s\n' "${READBACK_RESULT#* }"
|
|
558
|
-
;;
|
|
559
|
-
mismatch*)
|
|
560
|
-
ACTUAL="${READBACK_RESULT#* }"
|
|
561
|
-
if [ "$PATCH_2XX" -eq 1 ]; then
|
|
562
|
-
die "transition read-back state $ACTUAL did not match intended state id $SID after a 2xx PATCH response; concurrent divergence suspected; refusing to claim the transition completed"
|
|
563
|
-
fi
|
|
564
|
-
die "transition PATCH outcome was ambiguous and the read-back state $ACTUAL did not match intended state id $SID; refusing to claim the transition completed"
|
|
565
|
-
;;
|
|
566
|
-
*)
|
|
567
|
-
die "transition read-back returned an invalid verification outcome; refusing to repeat the PATCH"
|
|
568
|
-
;;
|
|
569
|
-
esac
|
|
570
|
-
;;
|
|
571
|
-
|
|
572
|
-
describe_board)
|
|
573
|
-
# Read-only board lookup against an EXPLICIT workspace argument, so the
|
|
574
|
-
# .project.json / role.yaml / env workspace precedence can never silently
|
|
575
|
-
# query the wrong workspace. Emits Plane's own identifier, never a guess.
|
|
576
|
-
DWS="${1:?usage: describe_board <workspace> <board_id>}"
|
|
577
|
-
DBID="${2:?usage: describe_board <workspace> <board_id>}"
|
|
578
|
-
DKEYVAR="$(workspace_key "$DWS")"
|
|
579
|
-
DKEY="$(printenv "$DKEYVAR" 2>/dev/null || true)"
|
|
580
|
-
if [ -z "$DKEY" ] && [ -f "$FLEET_ENV" ]; then
|
|
581
|
-
DKEY="$(dotenv_value "$FLEET_ENV" "$DKEYVAR")"
|
|
582
|
-
fi
|
|
583
|
-
[ -n "$DKEY" ] || DKEY="${PLANE_API_KEY:-}"
|
|
584
|
-
DKEY="$(resolve_secret_value "$DKEY")"
|
|
585
|
-
[ -n "$DKEY" ] || die "no Plane API key for workspace '$DWS' (looked for $DKEYVAR)"
|
|
586
|
-
DETAIL="$(curl -fsS "$BASE/api/v1/workspaces/$DWS/projects/$DBID/" \
|
|
587
|
-
-H "X-API-Key: $DKEY" -H "User-Agent: curl/8.0")" \
|
|
588
|
-
|| die "describe_board failed for $DWS/$DBID"
|
|
589
|
-
printf '%s' "$DETAIL" | WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
|
|
590
|
-
d = json.load(sys.stdin)
|
|
591
|
-
ident = str(d.get("identifier") or "")
|
|
592
|
-
if not ident:
|
|
593
|
-
raise SystemExit("plane: live Plane project omitted its authoritative identifier")
|
|
594
|
-
print(json.dumps({
|
|
595
|
-
"board_id": str(d.get("id") or os.environ["BID"]),
|
|
596
|
-
"identifier": ident,
|
|
597
|
-
"workspace": os.environ["WS"],
|
|
598
|
-
"name": str(d.get("name") or ""),
|
|
599
|
-
}))'
|
|
600
|
-
;;
|
|
601
|
-
|
|
602
|
-
create_board)
|
|
603
|
-
NAME="${1:?usage: create_board <name> <ident> <desc>}"; IDENT="${2:-}"; DESC="${3:-}"
|
|
604
|
-
[ -n "$WS" ] || die "workspace not set"
|
|
605
|
-
EXIST="$(api_all "projects/?per_page=200" | NAME="$NAME" IDENT="$IDENT" python3 -c 'import sys,json,os
|
|
606
|
-
d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
|
|
607
|
-
name=os.environ["NAME"].strip().lower(); ident=os.environ["IDENT"].upper()
|
|
608
|
-
# Repo NAME is the primary key — links an existing repo board even if its
|
|
609
|
-
# identifier differs (Plane does not enforce unique names, so this prevents
|
|
610
|
-
# duplicate boards). Fall back to identifier match; empty -> create new.
|
|
611
|
-
pid=next((p["id"] for p in rows if str(p.get("name","")).strip().lower()==name), "")
|
|
612
|
-
if not pid and ident:
|
|
613
|
-
pid=next((p["id"] for p in rows if (p.get("identifier") or "").upper()==ident), "")
|
|
614
|
-
print(pid)')"
|
|
615
|
-
LIVE_IDENTIFIER=""
|
|
616
|
-
if [ -n "$EXIST" ]; then PID="$EXIST"; else
|
|
617
|
-
CREATED="$(api POST "projects/" \
|
|
618
|
-
"$(python3 -c 'import json,sys; print(json.dumps({"name":sys.argv[1],"identifier":sys.argv[2],"description":sys.argv[3]}))' "$NAME" "$IDENT" "$DESC")")"
|
|
619
|
-
PID="$(printf '%s' "$CREATED" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
|
|
620
|
-
LIVE_IDENTIFIER="$(printf '%s' "$CREATED" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("identifier","") or "")')"
|
|
621
|
-
fi
|
|
622
|
-
[ -n "$PID" ] || die "create_board failed"
|
|
623
|
-
if [ -z "$LIVE_IDENTIFIER" ]; then
|
|
624
|
-
DETAIL="$(api GET "projects/$PID/")"
|
|
625
|
-
LIVE_IDENTIFIER="$(printf '%s' "$DETAIL" | python3 -c 'import sys,json
|
|
626
|
-
try: print(str(json.load(sys.stdin).get("identifier") or ""))
|
|
627
|
-
except Exception: print("")')"
|
|
628
|
-
fi
|
|
629
|
-
[ -n "$LIVE_IDENTIFIER" ] || die "live Plane project omitted its authoritative identifier"
|
|
630
|
-
printf '{"board_id":"%s","board_url":"%s/%s/projects/%s/issues/","identifier":"%s"}\n' \
|
|
631
|
-
"$PID" "$BASE" "$WS" "$PID" "$LIVE_IDENTIFIER"
|
|
632
|
-
;;
|
|
633
|
-
|
|
634
|
-
create_issue)
|
|
635
|
-
# File a new ticket on the bound board. Board/workspace come from resolved
|
|
636
|
-
# config, never from an argument. Deliberately NOT idempotent by default:
|
|
637
|
-
# two issues may legitimately share a title. Pass --if-absent to reuse an
|
|
638
|
-
# issue whose title already matches exactly (case-insensitive) instead.
|
|
639
|
-
IF_ABSENT=0
|
|
640
|
-
case "${1:-}" in --if-absent) IF_ABSENT=1; shift ;; esac
|
|
641
|
-
TITLE="${1:?usage: create_issue [--if-absent] <title> [description]}"; DESC="${2:-}"
|
|
642
|
-
[ -n "$WS" ] || die "workspace not set (.project.json ticket_provider.workspace or PLANE_WORKSPACE)"
|
|
643
|
-
[ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
|
|
644
|
-
IID=""; SEQ=""; CREATED=true
|
|
645
|
-
if [ "$IF_ABSENT" = 1 ]; then
|
|
646
|
-
HIT="$(api_all "projects/$PROJ/issues/?per_page=200" | TITLE="$TITLE" python3 -c 'import sys,json,os
|
|
647
|
-
d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
|
|
648
|
-
want=os.environ["TITLE"].strip().lower()
|
|
649
|
-
m=next((i for i in rows if (i.get("name") or "").strip().lower()==want), None)
|
|
650
|
-
print((str(m.get("id","")) + " " + str(m.get("sequence_id","") or "")) if m else "")')"
|
|
651
|
-
if [ -n "$HIT" ]; then IID="${HIT%% *}"; SEQ="${HIT#* }"; CREATED=false; fi
|
|
652
|
-
fi
|
|
653
|
-
if [ -z "$IID" ]; then
|
|
654
|
-
NEW="$(api POST "projects/$PROJ/issues/" \
|
|
655
|
-
"$(python3 -c 'import html,json,sys
|
|
656
|
-
body={"name": sys.argv[1]}
|
|
657
|
-
desc=sys.argv[2]
|
|
658
|
-
if desc: body["description_html"]="<p>"+html.escape(desc)+"</p>"
|
|
659
|
-
print(json.dumps(body))' "$TITLE" "$DESC")" \
|
|
660
|
-
| python3 -c 'import sys,json
|
|
661
|
-
d=json.load(sys.stdin)
|
|
662
|
-
print(str(d.get("id","")) + " " + str(d.get("sequence_id","") or ""))')"
|
|
663
|
-
IID="${NEW%% *}"; SEQ="${NEW#* }"
|
|
664
|
-
fi
|
|
665
|
-
[ -n "$IID" ] || die "create_issue failed"
|
|
666
|
-
printf '{"issue_id":"%s","key":"%s","issue_url":"%s/%s/projects/%s/issues/%s","created":%s}\n' \
|
|
667
|
-
"$IID" "$SEQ" "$BASE" "$WS" "$PROJ" "$IID" "$CREATED"
|
|
668
|
-
;;
|
|
669
|
-
|
|
670
|
-
*) die "unknown op: $OP" ;;
|
|
671
|
-
esac
|