@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@delorenj/pjangler",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.4",
|
|
4
4
|
"description": "Project subsystem bootstrapper CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"dist",
|
|
15
|
+
"!dist/**/*.map",
|
|
16
|
+
"contracts",
|
|
15
17
|
"templates/commonproject/copier.yml",
|
|
16
18
|
"templates/commonproject/template",
|
|
17
19
|
"templates/hermes-agent/copier.yml",
|
|
@@ -39,7 +41,7 @@
|
|
|
39
41
|
"migrate:create": "node-pg-migrate --migrations-dir migrations create",
|
|
40
42
|
"prepublishOnly": "npm run check:lock && npm run check:submodules -- --remote --recursive --archive --npm && npm run build && npm run check:tracked-secrets",
|
|
41
43
|
"test:hermes-profile-inheritance": "node tests/hermes-profile-inheritance-regressions.mjs",
|
|
42
|
-
"test:coverage": "npm run build && c8 --reporter=json-summary --reporter=text-summary --src=src --report-dir=coverage node scripts/run-tests.mjs",
|
|
44
|
+
"test:coverage": "npm run build && NODE_OPTIONS=--max-old-space-size=12288 c8 --exclude='**/.pjan-*/**' --reporter=json-summary --reporter=text-summary --src=src --report-dir=coverage node scripts/run-tests.mjs",
|
|
43
45
|
"coverage:check": "node scripts/coverage-ratchet.mjs",
|
|
44
46
|
"coverage:apply": "node scripts/coverage-ratchet.mjs --apply"
|
|
45
47
|
},
|
|
@@ -10,12 +10,13 @@
|
|
|
10
10
|
# resolve -> JSON {provider, board_id, board_url}
|
|
11
11
|
# active_milestone -> JSON {id, name, state}
|
|
12
12
|
# list_issues -> JSON [ {id,key,title,state,state_type,
|
|
13
|
-
# updated_at,assignee,url}, ... ]
|
|
14
|
-
# get_issue <
|
|
13
|
+
# updated_at,assignee,url,...}, ... ]
|
|
14
|
+
# get_issue <issue-ref> -> JSON {id,key,title,description,acceptance,
|
|
15
15
|
# state,state_type,comments:[...],
|
|
16
16
|
# attachments:[...]}
|
|
17
|
-
# comment <
|
|
18
|
-
# transition <
|
|
17
|
+
# comment <issue-ref> <body> -> prints comment id
|
|
18
|
+
# transition <issue-ref> <normalized>
|
|
19
|
+
# -> resolves id/human key, then moves issue; normalized in
|
|
19
20
|
# backlog|unstarted|started|in_review|completed|
|
|
20
21
|
# cancelled
|
|
21
22
|
# create_board <name> <id> <d> -> JSON {board_id, board_url}
|
|
@@ -38,6 +39,63 @@
|
|
|
38
39
|
# Each provider reads its credentials from the environment (see providers/*.sh
|
|
39
40
|
# headers) and the board binding from repo-root .project.json.
|
|
40
41
|
|
|
42
|
+
# Read the human key prefix from the project binding. Plane list_issues exposes
|
|
43
|
+
# sequence_id as a number while people use PREFIX-number; the dispatcher owns
|
|
44
|
+
# that provider-neutral seam so callers never have to know the native id shape.
|
|
45
|
+
tp_project_identifier() {
|
|
46
|
+
local role_dir
|
|
47
|
+
role_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." 2>/dev/null && pwd)"
|
|
48
|
+
python3 - "$role_dir" <<'PY' 2>/dev/null
|
|
49
|
+
import json
|
|
50
|
+
import pathlib
|
|
51
|
+
import sys
|
|
52
|
+
|
|
53
|
+
start = pathlib.Path(sys.argv[1]).resolve()
|
|
54
|
+
for parent in [start, *start.parents]:
|
|
55
|
+
manifest = parent / ".project.json"
|
|
56
|
+
if manifest.is_file():
|
|
57
|
+
try:
|
|
58
|
+
provider = json.loads(manifest.read_text()).get("ticket_provider") or {}
|
|
59
|
+
print(str(provider.get("identifier") or ""))
|
|
60
|
+
except Exception:
|
|
61
|
+
print("")
|
|
62
|
+
break
|
|
63
|
+
else:
|
|
64
|
+
print("")
|
|
65
|
+
PY
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
# Resolve an id, provider key, or project-prefixed numeric key against the
|
|
69
|
+
# normalized list contract. Mutating operations fail before reaching a provider
|
|
70
|
+
# if the reference is absent or ambiguous.
|
|
71
|
+
tp_resolve_issue_reference() {
|
|
72
|
+
local name="$1" impl="$2" reference="$3" identifier issues
|
|
73
|
+
identifier="$(tp_project_identifier)"
|
|
74
|
+
issues="$(TICKET_PROVIDER="$name" sh "$impl" list_issues)" || return 1
|
|
75
|
+
TP_REFERENCE="$reference" TP_IDENTIFIER="$identifier" python3 -c 'import json,os,sys
|
|
76
|
+
reference=os.environ["TP_REFERENCE"].strip(); identifier=os.environ.get("TP_IDENTIFIER", "").strip()
|
|
77
|
+
try:
|
|
78
|
+
data=json.load(sys.stdin)
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
raise SystemExit(f"tp: could not resolve issue reference {reference!r}: list_issues returned invalid JSON ({exc})")
|
|
81
|
+
rows=data if isinstance(data,list) else data.get("results", []) if isinstance(data,dict) else []
|
|
82
|
+
want=reference.casefold(); matches=[]
|
|
83
|
+
for issue in rows:
|
|
84
|
+
native=str(issue.get("id") or "").strip(); key=str(issue.get("key") or "").strip()
|
|
85
|
+
aliases={native.casefold(), key.casefold()}
|
|
86
|
+
if identifier and key.isdigit():
|
|
87
|
+
aliases.add(f"{identifier}-{key}".casefold())
|
|
88
|
+
if native and want in aliases:
|
|
89
|
+
matches.append(native)
|
|
90
|
+
matches=list(dict.fromkeys(matches))
|
|
91
|
+
if len(matches) != 1:
|
|
92
|
+
detail="not found" if not matches else "ambiguous"
|
|
93
|
+
raise SystemExit(f"tp: could not resolve issue reference {reference!r}: {detail}")
|
|
94
|
+
print(matches[0])' <<EOF
|
|
95
|
+
$issues
|
|
96
|
+
EOF
|
|
97
|
+
}
|
|
98
|
+
|
|
41
99
|
# Resolve the provider name: explicit env wins, then repo-root .project.json
|
|
42
100
|
# (the SOT), then role.yaml (self-parsed so this works even when _lib.sh /
|
|
43
101
|
# yaml_get is not loaded), then default.
|
|
@@ -96,6 +154,19 @@ tp() {
|
|
|
96
154
|
local op="${1:-}"; shift || true
|
|
97
155
|
[ -n "$op" ] || { echo "tp: missing operation" >&2; return 2; }
|
|
98
156
|
|
|
157
|
+
# Reject blank references before provider discovery or list_issues. Besides
|
|
158
|
+
# being invalid input, an empty value can equal a provider's missing `key`
|
|
159
|
+
# field and must never become authority to read or mutate that issue.
|
|
160
|
+
case "$op" in
|
|
161
|
+
get_issue|comment|transition)
|
|
162
|
+
[ "$#" -ge 1 ] || { echo "tp: $op requires a non-blank issue reference" >&2; return 2; }
|
|
163
|
+
if [[ "$1" =~ ^[[:space:]]*$ ]]; then
|
|
164
|
+
echo "tp: $op requires a non-blank issue reference" >&2
|
|
165
|
+
return 2
|
|
166
|
+
fi
|
|
167
|
+
;;
|
|
168
|
+
esac
|
|
169
|
+
|
|
99
170
|
local name impl
|
|
100
171
|
name="$(tp_provider_name)"
|
|
101
172
|
impl="$(tp_providers_dir)/${name}.sh"
|
|
@@ -105,6 +176,15 @@ tp() {
|
|
|
105
176
|
return 2
|
|
106
177
|
fi
|
|
107
178
|
|
|
179
|
+
case "$op" in
|
|
180
|
+
get_issue|comment|transition)
|
|
181
|
+
local reference native_id
|
|
182
|
+
reference="$1"; shift
|
|
183
|
+
native_id="$(tp_resolve_issue_reference "$name" "$impl" "$reference")" || return 1
|
|
184
|
+
set -- "$native_id" "$@"
|
|
185
|
+
;;
|
|
186
|
+
esac
|
|
187
|
+
|
|
108
188
|
TICKET_PROVIDER="$name" sh "$impl" "$op" "$@"
|
|
109
189
|
}
|
|
110
190
|
|
|
@@ -11,15 +11,27 @@
|
|
|
11
11
|
#
|
|
12
12
|
# Implements the contract in lib/ticket-provider.sh. All Linear access goes
|
|
13
13
|
# through GraphQL so the same envelope works in unattended runs.
|
|
14
|
+
# LINEAR_MAX_PAGES bounds issue pagination (default 1000).
|
|
15
|
+
# GraphQL variable references are intentionally single-quoted shell literals.
|
|
16
|
+
# shellcheck disable=SC2016
|
|
14
17
|
set -eu
|
|
15
18
|
|
|
16
19
|
OP="${1:-}"; shift 2>/dev/null || true
|
|
17
20
|
ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
18
21
|
ROLE_YAML="$ROLE_DIR/role.yaml"
|
|
22
|
+
GRAPHQL_URL="${LINEAR_GRAPHQL_URL:-https://api.linear.app/graphql}"
|
|
19
23
|
|
|
20
24
|
die() { echo "linear: $*" >&2; exit 1; }
|
|
21
25
|
need_key() { [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is not set"; }
|
|
22
26
|
|
|
27
|
+
validated_uint() {
|
|
28
|
+
setting="$1"; value="$2"; minimum="$3"; maximum="$4"
|
|
29
|
+
case "$value" in ''|*[!0-9]*) die "$setting must be an integer from $minimum through $maximum" ;; esac
|
|
30
|
+
[ "$value" -ge "$minimum" ] && [ "$value" -le "$maximum" ] \
|
|
31
|
+
|| die "$setting must be an integer from $minimum through $maximum"
|
|
32
|
+
printf '%s' "$value"
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
# tp_cfg KEY — read ticket_provider.<KEY> from role.yaml (best-effort, flat).
|
|
24
36
|
tp_cfg() {
|
|
25
37
|
[ -f "$ROLE_YAML" ] || return 0
|
|
@@ -55,11 +67,11 @@ PY
|
|
|
55
67
|
gql() {
|
|
56
68
|
need_key
|
|
57
69
|
_vars="${2:-}"; [ -n "$_vars" ] || _vars='{}'
|
|
58
|
-
python3 - "$1" "$_vars" <<'PY'
|
|
70
|
+
python3 - "$1" "$_vars" "$GRAPHQL_URL" <<'PY'
|
|
59
71
|
import json, os, sys, urllib.request, urllib.error
|
|
60
72
|
q, variables = sys.argv[1], json.loads(sys.argv[2])
|
|
61
73
|
req = urllib.request.Request(
|
|
62
|
-
|
|
74
|
+
sys.argv[3],
|
|
63
75
|
data=json.dumps({"query": q, "variables": variables}).encode(),
|
|
64
76
|
headers={"Authorization": os.environ["LINEAR_API_KEY"],
|
|
65
77
|
"Content-Type": "application/json"},
|
|
@@ -81,6 +93,10 @@ PROJECT="$(pj_cfg project)"; [ -n "$PROJECT" ] || PROJECT="$(tp_cfg project)"
|
|
|
81
93
|
SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
|
|
82
94
|
SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
|
|
83
95
|
SM_CANCELLED="$(tp_cfg cancelled)"; SM_CANCELLED="${SM_CANCELLED:-Canceled}"
|
|
96
|
+
SM_STARTED="$(tp_cfg started)"
|
|
97
|
+
SM_UNSTARTED="$(tp_cfg unstarted)"
|
|
98
|
+
SM_BACKLOG="$(tp_cfg backlog)"
|
|
99
|
+
MAX_PAGES="$(validated_uint LINEAR_MAX_PAGES "${LINEAR_MAX_PAGES:-1000}" 1 1000)"
|
|
84
100
|
|
|
85
101
|
# All Linear ops require the API key; fail fast and clean before any pipe.
|
|
86
102
|
need_key
|
|
@@ -107,17 +123,57 @@ print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":p.get("sta
|
|
|
107
123
|
|
|
108
124
|
list_issues)
|
|
109
125
|
[ -n "$TEAM" ] || die "ticket_provider.team not set"
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
ISSUE_PAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/linear-issues.XXXXXX")" || die "could not create pagination scratch directory"
|
|
127
|
+
ISSUE_PAGE_ROWS="$ISSUE_PAGE_DIR/rows"
|
|
128
|
+
ISSUE_PAGE_CURSORS="$ISSUE_PAGE_DIR/cursors"
|
|
129
|
+
: > "$ISSUE_PAGE_ROWS"
|
|
130
|
+
: > "$ISSUE_PAGE_CURSORS"
|
|
131
|
+
cleanup_issue_pages() {
|
|
132
|
+
rm -f "$ISSUE_PAGE_ROWS" "$ISSUE_PAGE_CURSORS"
|
|
133
|
+
rmdir "$ISSUE_PAGE_DIR" 2>/dev/null || true
|
|
134
|
+
}
|
|
135
|
+
trap cleanup_issue_pages 0
|
|
136
|
+
trap 'cleanup_issue_pages; exit 129' HUP
|
|
137
|
+
trap 'cleanup_issue_pages; exit 130' INT
|
|
138
|
+
trap 'cleanup_issue_pages; exit 143' TERM
|
|
139
|
+
AFTER=""
|
|
140
|
+
PAGE_COUNT=0
|
|
141
|
+
while :; do
|
|
142
|
+
PAGE_COUNT=$((PAGE_COUNT + 1))
|
|
143
|
+
[ "$PAGE_COUNT" -le "$MAX_PAGES" ] \
|
|
144
|
+
|| die "pagination exceeded LINEAR_MAX_PAGES=$MAX_PAGES"
|
|
145
|
+
VARS="$(python3 -c 'import json,sys; print(json.dumps({"k":sys.argv[1],"after":sys.argv[2] or None}))' "$TEAM" "$AFTER")"
|
|
146
|
+
PAGE="$(gql 'query($k:String!,$after:String){ issues(first:100, after:$after, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} } pageInfo{hasNextPage endCursor}} }' "$VARS")"
|
|
147
|
+
printf '%s' "$PAGE" | python3 -c 'import sys,json
|
|
148
|
+
d=json.load(sys.stdin)
|
|
149
|
+
for n in (d.get("issues") or {}).get("nodes") or []:
|
|
115
150
|
st=n.get("state") or {}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
151
|
+
print(json.dumps({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
|
|
152
|
+
"state":st.get("name",""),"state_type":st.get("type",""),
|
|
153
|
+
"updated_at":n.get("updatedAt",""),
|
|
154
|
+
"assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")}))' \
|
|
155
|
+
>> "$ISSUE_PAGE_ROWS"
|
|
156
|
+
HAS_NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print("true" if ((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("hasNextPage") else "false")')"
|
|
157
|
+
[ "$HAS_NEXT" = "true" ] || break
|
|
158
|
+
NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print(str(((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("endCursor") or ""))')"
|
|
159
|
+
[ -n "$NEXT" ] || die "Linear pagination reported another page without an end cursor"
|
|
160
|
+
CURSOR_FINGERPRINT="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "$NEXT")"
|
|
161
|
+
if grep -Fqx "$CURSOR_FINGERPRINT" "$ISSUE_PAGE_CURSORS"; then
|
|
162
|
+
die "Linear pagination cursor repeated"
|
|
163
|
+
fi
|
|
164
|
+
printf '%s\n' "$CURSOR_FINGERPRINT" >> "$ISSUE_PAGE_CURSORS"
|
|
165
|
+
AFTER="$NEXT"
|
|
166
|
+
done
|
|
167
|
+
python3 - "$ISSUE_PAGE_ROWS" <<'PY'
|
|
168
|
+
import json
|
|
169
|
+
import pathlib
|
|
170
|
+
import sys
|
|
171
|
+
|
|
172
|
+
rows = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if line]
|
|
173
|
+
print(json.dumps(rows))
|
|
174
|
+
PY
|
|
175
|
+
cleanup_issue_pages
|
|
176
|
+
trap - 0 HUP INT TERM
|
|
121
177
|
;;
|
|
122
178
|
|
|
123
179
|
get_issue)
|
|
@@ -135,9 +191,23 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get
|
|
|
135
191
|
|
|
136
192
|
comment)
|
|
137
193
|
ID="${1:?usage: comment <id> <body>}"; BODY="${2:?usage: comment <id> <body>}"
|
|
138
|
-
gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id} success } }' \
|
|
194
|
+
gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id issue{id}} success } }' \
|
|
139
195
|
"$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"b":sys.argv[2]}))' "$ID" "$BODY")" \
|
|
140
|
-
| python3 -c 'import sys,json
|
|
196
|
+
| EXPECTED_ID="$ID" python3 -c 'import sys,json,os
|
|
197
|
+
document=json.load(sys.stdin)
|
|
198
|
+
created=document.get("commentCreate") if isinstance(document,dict) else None
|
|
199
|
+
if not isinstance(created,dict) or created.get("success") is not True:
|
|
200
|
+
raise SystemExit("linear: commentCreate did not report success")
|
|
201
|
+
comment=created.get("comment")
|
|
202
|
+
if not isinstance(comment,dict):
|
|
203
|
+
raise SystemExit("linear: commentCreate response omitted its comment object")
|
|
204
|
+
comment_id=comment.get("id")
|
|
205
|
+
if not isinstance(comment_id,str) or not comment_id.strip():
|
|
206
|
+
raise SystemExit("linear: commentCreate response omitted its comment id")
|
|
207
|
+
issue=comment.get("issue")
|
|
208
|
+
if not isinstance(issue,dict) or str(issue.get("id") or "").strip()!=os.environ["EXPECTED_ID"]:
|
|
209
|
+
raise SystemExit("linear: commentCreate response did not identify the requested issue")
|
|
210
|
+
print(comment_id.strip())'
|
|
141
211
|
;;
|
|
142
212
|
|
|
143
213
|
transition)
|
|
@@ -147,9 +217,9 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get
|
|
|
147
217
|
completed) WANT_TYPE=completed; WANT_NAME="$SM_DONE" ;;
|
|
148
218
|
cancelled) WANT_TYPE=canceled; WANT_NAME="$SM_CANCELLED" ;;
|
|
149
219
|
in_review) WANT_TYPE=started; WANT_NAME="$SM_IN_REVIEW" ;;
|
|
150
|
-
started) WANT_TYPE=started; WANT_NAME="" ;;
|
|
151
|
-
unstarted) WANT_TYPE=unstarted; WANT_NAME="" ;;
|
|
152
|
-
backlog) WANT_TYPE=backlog; WANT_NAME="" ;;
|
|
220
|
+
started) WANT_TYPE=started; WANT_NAME="$SM_STARTED" ;;
|
|
221
|
+
unstarted) WANT_TYPE=unstarted; WANT_NAME="$SM_UNSTARTED" ;;
|
|
222
|
+
backlog) WANT_TYPE=backlog; WANT_NAME="$SM_BACKLOG" ;;
|
|
153
223
|
*) die "invalid normalized state: $TARGET" ;;
|
|
154
224
|
esac
|
|
155
225
|
STATE_ID="$(gql 'query($id:String!){ issue(id:$id){ team{ states{nodes{id name type}} } } }' \
|
|
@@ -158,15 +228,35 @@ print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get
|
|
|
158
228
|
d=json.load(sys.stdin)
|
|
159
229
|
states=((d.get("issue") or {}).get("team") or {}).get("states",{}).get("nodes") or []
|
|
160
230
|
want_t=os.environ["WANT_TYPE"]; want_n=os.environ.get("WANT_NAME","")
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
231
|
+
if want_n:
|
|
232
|
+
candidates=[s for s in states if str(s.get("name") or "").strip().casefold()==want_n.strip().casefold()]
|
|
233
|
+
basis=f"configured name {want_n!r}"
|
|
234
|
+
else:
|
|
235
|
+
candidates=[s for s in states if s.get("type")==want_t]
|
|
236
|
+
basis=f"workflow type {want_t!r}"
|
|
237
|
+
if len(candidates) != 1:
|
|
238
|
+
raise SystemExit(f"linear: {basis} resolved {len(candidates)} states; exactly one is required")
|
|
239
|
+
pick=candidates[0]
|
|
240
|
+
if pick.get("type") != want_t:
|
|
241
|
+
raise SystemExit(f"linear: configured state {want_n!r} has type {pick.get('type')!r}, expected {want_t!r}")
|
|
242
|
+
state_id=str(pick.get("id") or "")
|
|
243
|
+
if not state_id:
|
|
244
|
+
raise SystemExit("linear: resolved state omitted its id")
|
|
245
|
+
print(state_id)')"
|
|
165
246
|
[ -n "$STATE_ID" ] || die "no Linear state for normalized '$TARGET'"
|
|
166
|
-
gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{identifier state{name}} } }' \
|
|
247
|
+
gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{id identifier state{id name type}} } }' \
|
|
167
248
|
"$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"s":sys.argv[2]}))' "$ID" "$STATE_ID")" \
|
|
168
|
-
| python3 -c 'import sys,json
|
|
169
|
-
|
|
249
|
+
| EXPECTED_ID="$ID" EXPECTED_STATE_ID="$STATE_ID" python3 -c 'import sys,json,os
|
|
250
|
+
u=json.load(sys.stdin).get("issueUpdate") or {}
|
|
251
|
+
if u.get("success") is not True:
|
|
252
|
+
raise SystemExit("linear: issueUpdate did not report success")
|
|
253
|
+
issue=u.get("issue") or {}
|
|
254
|
+
if str(issue.get("id") or "") != os.environ["EXPECTED_ID"]:
|
|
255
|
+
raise SystemExit("linear: issueUpdate read-back did not identify the requested issue")
|
|
256
|
+
state=issue.get("state") or {}
|
|
257
|
+
if str(state.get("id") or "") != os.environ["EXPECTED_STATE_ID"]:
|
|
258
|
+
raise SystemExit("linear: issueUpdate read-back did not confirm the exact target state")
|
|
259
|
+
print("ok " + str(issue.get("identifier") or issue.get("id") or ""))'
|
|
170
260
|
;;
|
|
171
261
|
|
|
172
262
|
describe_board)
|