@delorenj/pjangler 1.4.3 → 1.4.4

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.
@@ -7,8 +7,18 @@
7
7
  # Board binding (repo-root .project.json `ticket_provider:`):
8
8
  # workspace: <workspace-slug> (or env PLANE_WORKSPACE)
9
9
  # board_id: <project-uuid> (set by create_board / 42-ticket-provider)
10
- # state_map: { in_review: "In Review", completed: "Done",
11
- # cancelled: "Cancelled" } optional
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.
12
22
  #
13
23
  # Plane model: project = board, cycle = milestone, state.group in
14
24
  # backlog|unstarted|started|completed|cancelled.
@@ -27,6 +37,14 @@ FLEET_ENV="${HERMES_FLEET_ENV:-$HOME/.hermes/fleet.env}"
27
37
  die() { echo "plane: $*" >&2; exit 1; }
28
38
  need_key() { [ -n "${PLANE_API_KEY:-}" ] || die "PLANE_API_KEY is not set"; }
29
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
+
30
48
  workspace_key() {
31
49
  key="$(printf '%s' "${1:-default}" | tr '[:lower:]' '[:upper:]' | sed 's/[^A-Z0-9]/_/g')"
32
50
  [ -n "$key" ] || key="DEFAULT"
@@ -110,8 +128,18 @@ PROJ="$(pj_cfg board_id)"; [ -n "$PROJ" ] || PROJ="$(tp_cfg project)"; [ -n "$PR
110
128
  SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
111
129
  SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
112
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:-}}}"
113
136
  API="$BASE/api/v1/workspaces/$WS"
114
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
+
115
143
  if [ -z "${PLANE_API_KEY:-}" ]; then
116
144
  KEY="$(workspace_key "$WS")"
117
145
  PLANE_API_KEY="$(printenv "$KEY" 2>/dev/null || true)"
@@ -123,22 +151,228 @@ PLANE_API_KEY="$(resolve_secret_value "${PLANE_API_KEY:-}")"
123
151
  export PLANE_API_KEY
124
152
 
125
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.
126
158
  api() {
127
159
  need_key
128
160
  method="$1"; path="$2"; body="${3:-}"
129
- if [ -n "$body" ]; then
130
- curl -fsS -X "$method" "$API/$path" \
131
- -H "X-API-Key: $PLANE_API_KEY" -H "Content-Type: application/json" \
132
- -H "User-Agent: curl/8.0" \
133
- -d "$body"
134
- else
135
- curl -fsS -X "$method" "$API/$path" \
136
- -H "X-API-Key: $PLANE_API_KEY" \
137
- -H "User-Agent: curl/8.0"
138
- fi
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
139
256
  }
140
257
 
141
- # Map a normalized state -> a concrete Plane state id in this project.
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.
142
376
  resolve_state_id() {
143
377
  want="$1"
144
378
  [ -n "$PROJ" ] || die "ticket_provider.project not set"
@@ -146,18 +380,28 @@ resolve_state_id() {
146
380
  completed) grp=completed; nm="$SM_DONE" ;;
147
381
  cancelled) grp=cancelled; nm="$SM_CANCELLED" ;;
148
382
  in_review) grp=started; nm="$SM_IN_REVIEW" ;;
149
- started) grp=started; nm="" ;;
150
- unstarted) grp=unstarted; nm="" ;;
151
- backlog) grp=backlog; nm="" ;;
383
+ started) grp=started; nm="$SM_STARTED" ;;
384
+ unstarted) grp=unstarted; nm="$SM_UNSTARTED" ;;
385
+ backlog) grp=backlog; nm="$SM_BACKLOG" ;;
152
386
  *) die "invalid normalized state: $want" ;;
153
387
  esac
154
- api GET "projects/$PROJ/states/" | GRP="$grp" NM="$nm" python3 -c 'import sys,json,os
388
+ api_all "projects/$PROJ/states/" | GRP="$grp" NM="$nm" WANT="$want" python3 -c 'import sys,json,os
155
389
  d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
156
- grp=os.environ["GRP"]; nm=os.environ.get("NM","")
157
- named=[s for s in rows if nm and (s.get("name","").lower()==nm.lower())]
390
+ grp=os.environ["GRP"]; nm=os.environ.get("NM","").strip(); want=os.environ["WANT"]
158
391
  grouped=[s for s in rows if s.get("group")==grp]
159
- pick=(named or grouped or [{}])[0]
160
- print(pick.get("id",""))'
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)'
161
405
  }
162
406
 
163
407
  # All Plane ops except the explicit-workspace read below require the bound
@@ -179,27 +423,29 @@ except Exception: print("")')"
179
423
 
180
424
  active_milestone)
181
425
  [ -n "$PROJ" ] || die "project not set"
182
- api GET "projects/$PROJ/cycles/" | python3 -c 'import sys,json,datetime
183
- d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
184
- now=datetime.datetime.now(datetime.timezone.utc)
185
- def cur(c):
186
- s,e=c.get("start_date"),c.get("end_date")
187
- return bool(s and e and s<=now.date().isoformat()<=e)
188
- active=[c for c in rows if cur(c)] or rows
189
- m=active[0] if active else {}
190
- print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":"active" if active else ""}))'
426
+ api_all "projects/$PROJ/cycles/" | current_cycle
191
427
  ;;
192
428
 
193
429
  list_issues)
194
430
  [ -n "$PROJ" ] || die "project not set"
195
431
  # Plane v1 returns issue.state as a bare UUID, so join against the states map.
196
- STATES="$(api GET "projects/$PROJ/states/")"
197
- ISSUES="$(api GET "projects/$PROJ/issues/")"
198
- printf '%s\n%s\n' "$STATES" "$ISSUES" | BASE="$BASE" WS="$WS" PROJ="$PROJ" python3 -c 'import sys,json,os
199
- parts=sys.stdin.read().split("\n",1)
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)
200
443
  srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
201
444
  smap={s.get("id"):(s.get("name",""),s.get("group","")) for s in srows}
202
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}
203
449
  base,ws,proj=os.environ["BASE"],os.environ["WS"],os.environ["PROJ"]
204
450
  out=[]
205
451
  for n in rows:
@@ -208,16 +454,19 @@ for n in rows:
208
454
  out.append({"id":iid,"key":n.get("sequence_id",iid),
209
455
  "title":n.get("name",""),"state":name,"state_type":group,
210
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),
211
460
  "url":base+"/"+ws+"/projects/"+proj+"/issues/"+str(iid)})
212
461
  print(json.dumps(out))'
213
462
  ;;
214
463
 
215
464
  get_issue)
216
465
  ID="${1:?usage: get_issue <id>}"
217
- STATES="$(api GET "projects/$PROJ/states/")"
466
+ STATES="$(api_all "projects/$PROJ/states/")"
218
467
  ISSUE="$(api GET "projects/$PROJ/issues/$ID/")"
219
- COMM="$(api GET "projects/$PROJ/issues/$ID/comments/" 2>/dev/null || echo '[]')"
220
- ATTACH="$(api GET "projects/$PROJ/issues/$ID/issue-attachments/" 2>/dev/null || echo '[]')"
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 '[]')"
221
470
  printf '%s\n%s\n%s\n%s\n' "$STATES" "$ISSUE" "$COMM" "$ATTACH" | python3 -c 'import sys,json,re
222
471
  parts=sys.stdin.read().split("\n",3)
223
472
  srows=json.loads(parts[0] or "{}"); srows=srows if isinstance(srows,list) else srows.get("results", []) if isinstance(srows,dict) else []
@@ -247,15 +496,77 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("sequence_id",""),"title":i.ge
247
496
  ID="${1:?usage: comment <id> <body>}"; BODY="${2:?}"
248
497
  api POST "projects/$PROJ/issues/$ID/comments/" \
249
498
  "$(python3 -c 'import json,sys; print(json.dumps({"comment_html":"<p>"+sys.argv[1]+"</p>"}))' "$BODY")" \
250
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))'
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())'
251
514
  ;;
252
515
 
253
516
  transition)
254
517
  ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
255
518
  SID="$(resolve_state_id "$TARGET")"
256
- [ -n "$SID" ] || die "no Plane state for normalized '$TARGET'"
257
- api PATCH "projects/$PROJ/issues/$ID/" "$(printf '{"state":"%s"}' "$SID")" \
258
- | python3 -c 'import sys,json; d=json.load(sys.stdin); print("ok "+str(d.get("sequence_id","")) )'
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
259
570
  ;;
260
571
 
261
572
  describe_board)
@@ -291,7 +602,7 @@ print(json.dumps({
291
602
  create_board)
292
603
  NAME="${1:?usage: create_board <name> <ident> <desc>}"; IDENT="${2:-}"; DESC="${3:-}"
293
604
  [ -n "$WS" ] || die "workspace not set"
294
- EXIST="$(api GET "projects/?per_page=200" | NAME="$NAME" IDENT="$IDENT" python3 -c 'import sys,json,os
605
+ EXIST="$(api_all "projects/?per_page=200" | NAME="$NAME" IDENT="$IDENT" python3 -c 'import sys,json,os
295
606
  d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
296
607
  name=os.environ["NAME"].strip().lower(); ident=os.environ["IDENT"].upper()
297
608
  # Repo NAME is the primary key — links an existing repo board even if its
@@ -332,7 +643,7 @@ except Exception: print("")')"
332
643
  [ -n "$PROJ" ] || die "project not set (.project.json ticket_provider.board_id; run 42-ticket-provider.sh)"
333
644
  IID=""; SEQ=""; CREATED=true
334
645
  if [ "$IF_ABSENT" = 1 ]; then
335
- HIT="$(api GET "projects/$PROJ/issues/?per_page=200" | TITLE="$TITLE" python3 -c 'import sys,json,os
646
+ HIT="$(api_all "projects/$PROJ/issues/?per_page=200" | TITLE="$TITLE" python3 -c 'import sys,json,os
336
647
  d=json.load(sys.stdin); rows=d if isinstance(d,list) else d.get("results", []) if isinstance(d,dict) else []
337
648
  want=os.environ["TITLE"].strip().lower()
338
649
  m=next((i for i in rows if (i.get("name") or "").strip().lower()==want), None)
@@ -81,8 +81,14 @@ list_id_for() {
81
81
  [ -n "$BOARD" ] || die "ticket_provider.board not set"
82
82
  want="$(list_name_for "$1")"
83
83
  api GET "boards/$BOARD/lists" | NM="$want" python3 -c 'import sys,json,os
84
- rows=json.load(sys.stdin); nm=os.environ["NM"].lower()
85
- print(next((l["id"] for l in rows if l.get("name","").lower()==nm), ""))'
84
+ rows=json.load(sys.stdin); nm=os.environ["NM"].strip().casefold()
85
+ matches=[row for row in rows if str(row.get("name") or "").strip().casefold()==nm]
86
+ if len(matches) != 1:
87
+ raise SystemExit("trello: exact list name %r resolved %d lists; exactly one is required" % (os.environ["NM"], len(matches)))
88
+ list_id=str(matches[0].get("id") or "")
89
+ if not list_id:
90
+ raise SystemExit("trello: resolved list omitted its id")
91
+ print(list_id)'
86
92
  }
87
93
 
88
94
  # All Trello ops require credentials; fail fast and clean before any pipe.
@@ -134,14 +140,33 @@ print(json.dumps({"id":c.get("id",""),"key":c.get("id",""),"title":c.get("name",
134
140
  comment)
135
141
  ID="${1:?usage: comment <id> <body>}"; BODY="${2:?}"
136
142
  api POST "cards/$ID/actions/comments" "text=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$BODY")" \
137
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))'
143
+ | EXPECTED_ID="$ID" python3 -c 'import sys,json,os
144
+ action=json.load(sys.stdin)
145
+ if not isinstance(action,dict):
146
+ raise SystemExit("trello: comment response was not an action object")
147
+ action_id=action.get("id")
148
+ if not isinstance(action_id,str) or not action_id.strip():
149
+ raise SystemExit("trello: comment response omitted its action id")
150
+ data=action.get("data")
151
+ card=data.get("card") if isinstance(data,dict) else None
152
+ if not isinstance(card,dict) or str(card.get("id") or "").strip()!=os.environ["EXPECTED_ID"]:
153
+ raise SystemExit("trello: comment response did not identify the requested card")
154
+ print(action_id.strip())'
138
155
  ;;
139
156
 
140
157
  transition)
141
158
  ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
142
159
  LID="$(list_id_for "$TARGET")"
143
160
  [ -n "$LID" ] || die "no Trello list mapped for normalized '$TARGET' (check state_map)"
144
- api PUT "cards/$ID" "idList=$LID" | python3 -c 'import sys,json; c=json.load(sys.stdin); print("ok "+c.get("id",""))'
161
+ api PUT "cards/$ID" "idList=$LID" >/dev/null
162
+ api GET "cards/$ID" "fields=id,idList" \
163
+ | EXPECTED_ID="$ID" EXPECTED_LIST_ID="$LID" python3 -c 'import sys,json,os
164
+ card=json.load(sys.stdin)
165
+ if str(card.get("id") or "") != os.environ["EXPECTED_ID"]:
166
+ raise SystemExit("trello: transition read-back did not identify the requested card")
167
+ if str(card.get("idList") or "") != os.environ["EXPECTED_LIST_ID"]:
168
+ raise SystemExit("trello: transition read-back did not confirm the exact target list")
169
+ print("ok " + str(card.get("id") or ""))'
145
170
  ;;
146
171
 
147
172
  describe_board)