@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.
- package/README.md +528 -0
- package/contracts/fleet-contract.yaml +513 -0
- package/dist/index.js +6135 -288
- package/dist/mcp-server.js +5335 -479
- package/package.json +4 -2
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +84 -4
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +114 -24
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +354 -43
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +29 -4
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +253 -28
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -11
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
- package/templates/hermes-agent/template/role.yaml.jinja +4 -0
- package/dist/index.js.map +0 -7
- package/dist/mcp-server.js.map +0 -7
- package/dist/prompt.js.map +0 -7
|
@@ -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
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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="$(
|
|
197
|
-
ISSUES="$(
|
|
198
|
-
|
|
199
|
-
|
|
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="$(
|
|
466
|
+
STATES="$(api_all "projects/$PROJ/states/")"
|
|
218
467
|
ISSUE="$(api GET "projects/$PROJ/issues/$ID/")"
|
|
219
|
-
COMM="$(
|
|
220
|
-
ATTACH="$(
|
|
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
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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="$(
|
|
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="$(
|
|
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"].
|
|
85
|
-
|
|
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
|
|
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"
|
|
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)
|