@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delorenj/pjangler",
3
- "version": "1.4.3",
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 <id> -> JSON {id,key,title,description,acceptance,
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 <id> <body> -> prints comment id
18
- # transition <id> <normalized> -> moves issue; normalized in
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
- "https://api.linear.app/graphql",
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
- gql 'query($k:String!){ issues(first:100, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} }} }' \
111
- "$(printf '{"k":"%s"}' "$TEAM")" \
112
- | python3 -c 'import sys,json
113
- d=json.load(sys.stdin); out=[]
114
- for n in d.get("issues",{}).get("nodes") or []:
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
- out.append({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
117
- "state":st.get("name",""),"state_type":st.get("type",""),
118
- "updated_at":n.get("updatedAt",""),
119
- "assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")})
120
- print(json.dumps(out))'
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; d=json.load(sys.stdin); print((d.get("commentCreate",{}).get("comment") or {}).get("id",""))'
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
- named=[s for s in states if want_n and s["name"].lower()==want_n.lower()]
162
- typed=[s for s in states if s.get("type")==want_t]
163
- pick=(named or typed or [{}])[0]
164
- print(pick.get("id",""))')"
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; u=json.load(sys.stdin).get("issueUpdate",{});
169
- print(("ok " + (u.get("issue") or {}).get("identifier","")) if u.get("success") else "FAILED"); sys.exit(0 if u.get("success") else 1)'
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)