shipwright-cli 1.7.1 → 1.10.0

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.
Files changed (115) hide show
  1. package/.claude/agents/code-reviewer.md +90 -0
  2. package/.claude/agents/devops-engineer.md +142 -0
  3. package/.claude/agents/pipeline-agent.md +80 -0
  4. package/.claude/agents/shell-script-specialist.md +150 -0
  5. package/.claude/agents/test-specialist.md +196 -0
  6. package/.claude/hooks/post-tool-use.sh +45 -0
  7. package/.claude/hooks/pre-tool-use.sh +25 -0
  8. package/.claude/hooks/session-started.sh +37 -0
  9. package/README.md +212 -814
  10. package/claude-code/CLAUDE.md.shipwright +54 -0
  11. package/claude-code/hooks/notify-idle.sh +2 -2
  12. package/claude-code/hooks/session-start.sh +24 -0
  13. package/claude-code/hooks/task-completed.sh +6 -2
  14. package/claude-code/settings.json.template +12 -0
  15. package/dashboard/public/app.js +4422 -0
  16. package/dashboard/public/index.html +816 -0
  17. package/dashboard/public/styles.css +4755 -0
  18. package/dashboard/server.ts +4315 -0
  19. package/docs/KNOWN-ISSUES.md +18 -10
  20. package/docs/TIPS.md +38 -26
  21. package/docs/patterns/README.md +33 -23
  22. package/package.json +9 -5
  23. package/scripts/adapters/iterm2-adapter.sh +1 -1
  24. package/scripts/adapters/tmux-adapter.sh +52 -23
  25. package/scripts/adapters/wezterm-adapter.sh +26 -14
  26. package/scripts/lib/compat.sh +200 -0
  27. package/scripts/lib/helpers.sh +72 -0
  28. package/scripts/postinstall.mjs +72 -13
  29. package/scripts/{cct → sw} +118 -22
  30. package/scripts/sw-adversarial.sh +274 -0
  31. package/scripts/sw-architecture-enforcer.sh +330 -0
  32. package/scripts/sw-checkpoint.sh +468 -0
  33. package/scripts/sw-cleanup.sh +359 -0
  34. package/scripts/sw-connect.sh +619 -0
  35. package/scripts/{cct-cost.sh → sw-cost.sh} +368 -34
  36. package/scripts/sw-daemon.sh +5574 -0
  37. package/scripts/sw-dashboard.sh +477 -0
  38. package/scripts/sw-developer-simulation.sh +252 -0
  39. package/scripts/sw-docs.sh +635 -0
  40. package/scripts/sw-doctor.sh +907 -0
  41. package/scripts/{cct-fix.sh → sw-fix.sh} +10 -6
  42. package/scripts/{cct-fleet.sh → sw-fleet.sh} +498 -22
  43. package/scripts/sw-github-checks.sh +521 -0
  44. package/scripts/sw-github-deploy.sh +533 -0
  45. package/scripts/sw-github-graphql.sh +972 -0
  46. package/scripts/sw-heartbeat.sh +293 -0
  47. package/scripts/{cct-init.sh → sw-init.sh} +144 -11
  48. package/scripts/sw-intelligence.sh +1196 -0
  49. package/scripts/sw-jira.sh +643 -0
  50. package/scripts/sw-launchd.sh +364 -0
  51. package/scripts/sw-linear.sh +648 -0
  52. package/scripts/{cct-logs.sh → sw-logs.sh} +72 -2
  53. package/scripts/sw-loop.sh +2217 -0
  54. package/scripts/{cct-memory.sh → sw-memory.sh} +514 -36
  55. package/scripts/sw-patrol-meta.sh +417 -0
  56. package/scripts/sw-pipeline-composer.sh +455 -0
  57. package/scripts/sw-pipeline-vitals.sh +1096 -0
  58. package/scripts/sw-pipeline.sh +7593 -0
  59. package/scripts/sw-predictive.sh +820 -0
  60. package/scripts/{cct-prep.sh → sw-prep.sh} +339 -49
  61. package/scripts/{cct-ps.sh → sw-ps.sh} +9 -6
  62. package/scripts/{cct-reaper.sh → sw-reaper.sh} +10 -6
  63. package/scripts/sw-remote.sh +687 -0
  64. package/scripts/sw-self-optimize.sh +1048 -0
  65. package/scripts/sw-session.sh +541 -0
  66. package/scripts/sw-setup.sh +234 -0
  67. package/scripts/sw-status.sh +796 -0
  68. package/scripts/{cct-templates.sh → sw-templates.sh} +9 -4
  69. package/scripts/sw-tmux.sh +591 -0
  70. package/scripts/sw-tracker-jira.sh +277 -0
  71. package/scripts/sw-tracker-linear.sh +292 -0
  72. package/scripts/sw-tracker.sh +409 -0
  73. package/scripts/{cct-upgrade.sh → sw-upgrade.sh} +103 -46
  74. package/scripts/{cct-worktree.sh → sw-worktree.sh} +3 -0
  75. package/templates/pipelines/autonomous.json +35 -6
  76. package/templates/pipelines/cost-aware.json +21 -0
  77. package/templates/pipelines/deployed.json +40 -6
  78. package/templates/pipelines/enterprise.json +16 -2
  79. package/templates/pipelines/fast.json +19 -0
  80. package/templates/pipelines/full.json +28 -2
  81. package/templates/pipelines/hotfix.json +19 -0
  82. package/templates/pipelines/standard.json +31 -0
  83. package/tmux/{claude-teams-overlay.conf → shipwright-overlay.conf} +27 -9
  84. package/tmux/templates/accessibility.json +34 -0
  85. package/tmux/templates/api-design.json +35 -0
  86. package/tmux/templates/architecture.json +1 -0
  87. package/tmux/templates/bug-fix.json +9 -0
  88. package/tmux/templates/code-review.json +1 -0
  89. package/tmux/templates/compliance.json +36 -0
  90. package/tmux/templates/data-pipeline.json +36 -0
  91. package/tmux/templates/debt-paydown.json +34 -0
  92. package/tmux/templates/devops.json +1 -0
  93. package/tmux/templates/documentation.json +1 -0
  94. package/tmux/templates/exploration.json +1 -0
  95. package/tmux/templates/feature-dev.json +1 -0
  96. package/tmux/templates/full-stack.json +8 -0
  97. package/tmux/templates/i18n.json +34 -0
  98. package/tmux/templates/incident-response.json +36 -0
  99. package/tmux/templates/migration.json +1 -0
  100. package/tmux/templates/observability.json +35 -0
  101. package/tmux/templates/onboarding.json +33 -0
  102. package/tmux/templates/performance.json +35 -0
  103. package/tmux/templates/refactor.json +1 -0
  104. package/tmux/templates/release.json +35 -0
  105. package/tmux/templates/security-audit.json +8 -0
  106. package/tmux/templates/spike.json +34 -0
  107. package/tmux/templates/testing.json +1 -0
  108. package/tmux/tmux.conf +98 -9
  109. package/scripts/cct-cleanup.sh +0 -172
  110. package/scripts/cct-daemon.sh +0 -3189
  111. package/scripts/cct-doctor.sh +0 -414
  112. package/scripts/cct-loop.sh +0 -1332
  113. package/scripts/cct-pipeline.sh +0 -3844
  114. package/scripts/cct-session.sh +0 -284
  115. package/scripts/cct-status.sh +0 -169
@@ -0,0 +1,972 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright github-graphql — GitHub GraphQL API Client ║
4
+ # ║ Code history · Blame data · Contributors · Security alerts ║
5
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
6
+ set -euo pipefail
7
+ trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
8
+
9
+ VERSION="1.10.0"
10
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
11
+ REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
12
+
13
+ # ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
14
+ CYAN='\033[38;2;0;212;255m' # #00d4ff — primary accent
15
+ PURPLE='\033[38;2;124;58;237m' # #7c3aed — secondary
16
+ BLUE='\033[38;2;0;102;255m' # #0066ff — tertiary
17
+ GREEN='\033[38;2;74;222;128m' # success
18
+ YELLOW='\033[38;2;250;204;21m' # warning
19
+ RED='\033[38;2;248;113;113m' # error
20
+ DIM='\033[2m'
21
+ BOLD='\033[1m'
22
+ RESET='\033[0m'
23
+
24
+ # ─── Cross-platform compatibility ──────────────────────────────────────────
25
+ # shellcheck source=lib/compat.sh
26
+ [[ -f "$SCRIPT_DIR/lib/compat.sh" ]] && source "$SCRIPT_DIR/lib/compat.sh"
27
+
28
+ # ─── Output Helpers ─────────────────────────────────────────────────────────
29
+ info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
30
+ success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
31
+ warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
32
+ error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
33
+
34
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
35
+ now_epoch() { date +%s; }
36
+
37
+ # ─── Structured Event Log ──────────────────────────────────────────────────
38
+ EVENTS_FILE="${HOME}/.shipwright/events.jsonl"
39
+
40
+ emit_event() {
41
+ local event_type="$1"
42
+ shift
43
+ local json_fields=""
44
+ for kv in "$@"; do
45
+ local key="${kv%%=*}"
46
+ local val="${kv#*=}"
47
+ if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
48
+ json_fields="${json_fields},\"${key}\":${val}"
49
+ else
50
+ val="${val//\"/\\\"}"
51
+ json_fields="${json_fields},\"${key}\":\"${val}\""
52
+ fi
53
+ done
54
+ mkdir -p "${HOME}/.shipwright"
55
+ echo "{\"ts\":\"$(now_iso)\",\"ts_epoch\":$(now_epoch),\"type\":\"${event_type}\"${json_fields}}" >> "$EVENTS_FILE"
56
+ }
57
+
58
+ # ─── Cache Configuration ───────────────────────────────────────────────────
59
+ GH_CACHE_DIR="${HOME}/.shipwright/github-cache"
60
+
61
+ # ═══════════════════════════════════════════════════════════════════════════════
62
+ # AVAILABILITY CHECK
63
+ # ═══════════════════════════════════════════════════════════════════════════════
64
+
65
+ _gh_graphql_available() {
66
+ if [[ "${NO_GITHUB:-false}" == "true" ]]; then
67
+ return 1
68
+ fi
69
+ if ! command -v gh >/dev/null 2>&1; then
70
+ return 1
71
+ fi
72
+ if ! gh auth status >/dev/null 2>&1; then
73
+ return 1
74
+ fi
75
+ return 0
76
+ }
77
+
78
+ # ═══════════════════════════════════════════════════════════════════════════════
79
+ # CACHE LAYER
80
+ # ═══════════════════════════════════════════════════════════════════════════════
81
+
82
+ _gh_cache_init() {
83
+ mkdir -p "$GH_CACHE_DIR"
84
+ }
85
+
86
+ _gh_cache_get() {
87
+ local cache_key="$1"
88
+ local ttl_seconds="${2:-3600}"
89
+ local cache_file="${GH_CACHE_DIR}/${cache_key}.json"
90
+
91
+ if [[ ! -f "$cache_file" ]]; then
92
+ return 1
93
+ fi
94
+
95
+ # Check file age
96
+ local file_epoch now
97
+ if [[ "$(uname)" == "Darwin" ]]; then
98
+ file_epoch=$(stat -f '%m' "$cache_file" 2>/dev/null || echo "0")
99
+ else
100
+ file_epoch=$(stat -c '%Y' "$cache_file" 2>/dev/null || echo "0")
101
+ fi
102
+ now=$(now_epoch)
103
+ local age=$(( now - file_epoch ))
104
+
105
+ if [[ "$age" -gt "$ttl_seconds" ]]; then
106
+ return 1
107
+ fi
108
+
109
+ cat "$cache_file"
110
+ return 0
111
+ }
112
+
113
+ _gh_cache_set() {
114
+ local cache_key="$1"
115
+ local content="$2"
116
+ _gh_cache_init
117
+ local cache_file="${GH_CACHE_DIR}/${cache_key}.json"
118
+ local tmp_file="${cache_file}.tmp.$$"
119
+ printf '%s\n' "$content" > "$tmp_file"
120
+ mv "$tmp_file" "$cache_file"
121
+ }
122
+
123
+ _gh_cache_clear() {
124
+ if [[ -d "$GH_CACHE_DIR" ]]; then
125
+ rm -rf "$GH_CACHE_DIR"
126
+ _gh_cache_init
127
+ success "GitHub cache cleared"
128
+ else
129
+ info "No cache to clear"
130
+ fi
131
+ }
132
+
133
+ _gh_cache_stats() {
134
+ _gh_cache_init
135
+ local count=0
136
+ local total_size=0
137
+ local oldest=""
138
+ local newest=""
139
+
140
+ for f in "$GH_CACHE_DIR"/*.json; do
141
+ [[ -f "$f" ]] || continue
142
+ count=$((count + 1))
143
+ local size
144
+ if [[ "$(uname)" == "Darwin" ]]; then
145
+ size=$(stat -f '%z' "$f" 2>/dev/null || echo "0")
146
+ else
147
+ size=$(stat -c '%s' "$f" 2>/dev/null || echo "0")
148
+ fi
149
+ total_size=$((total_size + size))
150
+ done
151
+
152
+ echo -e "${CYAN}${BOLD}GitHub API Cache${RESET}"
153
+ echo -e " ${DIM}Directory:${RESET} $GH_CACHE_DIR"
154
+ echo -e " ${DIM}Entries:${RESET} $count"
155
+ echo -e " ${DIM}Total size:${RESET} $((total_size / 1024))KB"
156
+ }
157
+
158
+ # ═══════════════════════════════════════════════════════════════════════════════
159
+ # REPO DETECTION
160
+ # ═══════════════════════════════════════════════════════════════════════════════
161
+
162
+ GH_OWNER=""
163
+ GH_REPO=""
164
+
165
+ _gh_detect_repo() {
166
+ if [[ -n "$GH_OWNER" && -n "$GH_REPO" ]]; then
167
+ return 0
168
+ fi
169
+
170
+ local remote_url
171
+ remote_url=$(git remote get-url origin 2>/dev/null || true)
172
+
173
+ if [[ -z "$remote_url" ]]; then
174
+ error "No git remote 'origin' found"
175
+ return 1
176
+ fi
177
+
178
+ # Handle SSH: git@github.com:owner/repo.git
179
+ if [[ "$remote_url" =~ git@github\.com:([^/]+)/([^/.]+)(\.git)?$ ]]; then
180
+ GH_OWNER="${BASH_REMATCH[1]}"
181
+ GH_REPO="${BASH_REMATCH[2]}"
182
+ return 0
183
+ fi
184
+
185
+ # Handle HTTPS: https://github.com/owner/repo.git
186
+ if [[ "$remote_url" =~ github\.com/([^/]+)/([^/.]+)(\.git)?$ ]]; then
187
+ GH_OWNER="${BASH_REMATCH[1]}"
188
+ GH_REPO="${BASH_REMATCH[2]}"
189
+ return 0
190
+ fi
191
+
192
+ error "Could not parse owner/repo from remote: $remote_url"
193
+ return 1
194
+ }
195
+
196
+ # ═══════════════════════════════════════════════════════════════════════════════
197
+ # CORE GRAPHQL EXECUTION
198
+ # ═══════════════════════════════════════════════════════════════════════════════
199
+
200
+ gh_graphql() {
201
+ local query="$1"
202
+ local variables="${2:-{\}}"
203
+
204
+ if ! _gh_graphql_available; then
205
+ echo '{"error": "GitHub API not available"}'
206
+ return 1
207
+ fi
208
+
209
+ local result
210
+ result=$(gh api graphql -f query="$query" --input - <<< "$variables" 2>/dev/null) || {
211
+ error "GraphQL query failed"
212
+ echo '{"error": "query failed"}'
213
+ return 1
214
+ }
215
+
216
+ # Check for GraphQL errors
217
+ local has_errors
218
+ has_errors=$(echo "$result" | jq 'has("errors")' 2>/dev/null || echo "false")
219
+ if [[ "$has_errors" == "true" ]]; then
220
+ local err_msg
221
+ err_msg=$(echo "$result" | jq -r '.errors[0].message // "unknown error"' 2>/dev/null)
222
+ warn "GraphQL error: $err_msg"
223
+ fi
224
+
225
+ echo "$result"
226
+ }
227
+
228
+ gh_graphql_cached() {
229
+ local cache_key="$1"
230
+ local ttl_seconds="$2"
231
+ local query="$3"
232
+ local variables="${4:-{\}}"
233
+
234
+ # Try cache first
235
+ local cached
236
+ cached=$(_gh_cache_get "$cache_key" "$ttl_seconds" 2>/dev/null) && {
237
+ emit_event "github.cache_hit" "key=$cache_key"
238
+ echo "$cached"
239
+ return 0
240
+ }
241
+
242
+ # Cache miss — execute query
243
+ local result
244
+ result=$(gh_graphql "$query" "$variables") || return $?
245
+
246
+ # Cache the result
247
+ _gh_cache_set "$cache_key" "$result"
248
+ emit_event "github.cache_miss" "key=$cache_key"
249
+
250
+ echo "$result"
251
+ }
252
+
253
+ # ═══════════════════════════════════════════════════════════════════════════════
254
+ # DATA QUERIES
255
+ # ═══════════════════════════════════════════════════════════════════════════════
256
+
257
+ # ──────────────────────────────────────────────────────────────────────────────
258
+ # File change frequency — commit count for a path in last N days
259
+ # ──────────────────────────────────────────────────────────────────────────────
260
+ gh_file_change_frequency() {
261
+ local owner="$1"
262
+ local repo="$2"
263
+ local path="$3"
264
+ local days="${4:-30}"
265
+
266
+ if ! _gh_graphql_available; then
267
+ echo "0"
268
+ return 0
269
+ fi
270
+
271
+ local since
272
+ if [[ "$(uname)" == "Darwin" ]]; then
273
+ since=$(date -u -v-"${days}d" +"%Y-%m-%dT%H:%M:%SZ")
274
+ else
275
+ since=$(date -u -d "${days} days ago" +"%Y-%m-%dT%H:%M:%SZ")
276
+ fi
277
+
278
+ local cache_key="freq_${owner}_${repo}_$(echo "$path" | tr '/' '_')_${days}d"
279
+
280
+ local query='query($owner: String!, $repo: String!, $since: GitTimestamp!) {
281
+ repository(owner: $owner, name: $repo) {
282
+ defaultBranchRef {
283
+ target {
284
+ ... on Commit {
285
+ history(since: $since) {
286
+ totalCount
287
+ }
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }'
293
+
294
+ local variables
295
+ variables=$(jq -n --arg owner "$owner" --arg repo "$repo" --arg since "$since" \
296
+ '{owner: $owner, repo: $repo, since: $since}')
297
+
298
+ local result
299
+ result=$(gh_graphql_cached "$cache_key" "3600" "$query" "$variables") || {
300
+ echo "0"
301
+ return 0
302
+ }
303
+
304
+ local count
305
+ count=$(echo "$result" | jq -r '.data.repository.defaultBranchRef.target.history.totalCount // 0' 2>/dev/null || echo "0")
306
+ echo "$count"
307
+ }
308
+
309
+ # ──────────────────────────────────────────────────────────────────────────────
310
+ # Blame data — commit authors and counts for a file
311
+ # ──────────────────────────────────────────────────────────────────────────────
312
+ gh_blame_data() {
313
+ local owner="$1"
314
+ local repo="$2"
315
+ local path="$3"
316
+
317
+ if ! _gh_graphql_available; then
318
+ echo "[]"
319
+ return 0
320
+ fi
321
+
322
+ local cache_key="blame_${owner}_${repo}_$(echo "$path" | tr '/' '_')"
323
+ local cached
324
+ cached=$(_gh_cache_get "$cache_key" "14400" 2>/dev/null) && {
325
+ emit_event "github.cache_hit" "key=$cache_key"
326
+ echo "$cached"
327
+ return 0
328
+ }
329
+
330
+ # Use REST API for commit history on path
331
+ local result
332
+ result=$(gh api "repos/${owner}/${repo}/commits?path=${path}&per_page=100" 2>/dev/null) || {
333
+ echo "[]"
334
+ return 0
335
+ }
336
+
337
+ # Parse commit authors and aggregate
338
+ local parsed
339
+ parsed=$(echo "$result" | jq '[group_by(.commit.author.name) | .[] |
340
+ {
341
+ author: .[0].commit.author.name,
342
+ commits: length,
343
+ last_commit: (sort_by(.commit.author.date) | last | .commit.author.date)
344
+ }] | sort_by(-.commits)' 2>/dev/null || echo "[]")
345
+
346
+ _gh_cache_set "$cache_key" "$parsed"
347
+ emit_event "github.cache_miss" "key=$cache_key"
348
+
349
+ echo "$parsed"
350
+ }
351
+
352
+ # ──────────────────────────────────────────────────────────────────────────────
353
+ # Contributors — repo contributor list with commit counts
354
+ # ──────────────────────────────────────────────────────────────────────────────
355
+ gh_contributors() {
356
+ local owner="$1"
357
+ local repo="$2"
358
+
359
+ if ! _gh_graphql_available; then
360
+ echo "[]"
361
+ return 0
362
+ fi
363
+
364
+ local cache_key="contrib_${owner}_${repo}"
365
+ local cached
366
+ cached=$(_gh_cache_get "$cache_key" "86400" 2>/dev/null) && {
367
+ emit_event "github.cache_hit" "key=$cache_key"
368
+ echo "$cached"
369
+ return 0
370
+ }
371
+
372
+ local result
373
+ result=$(gh api "repos/${owner}/${repo}/contributors?per_page=100" 2>/dev/null) || {
374
+ echo "[]"
375
+ return 0
376
+ }
377
+
378
+ local parsed
379
+ parsed=$(echo "$result" | jq '[.[] | {login: .login, contributions: .contributions}]' 2>/dev/null || echo "[]")
380
+
381
+ _gh_cache_set "$cache_key" "$parsed"
382
+ emit_event "github.cache_miss" "key=$cache_key"
383
+
384
+ echo "$parsed"
385
+ }
386
+
387
+ # ──────────────────────────────────────────────────────────────────────────────
388
+ # Similar issues — closed issues matching search text
389
+ # ──────────────────────────────────────────────────────────────────────────────
390
+ gh_similar_issues() {
391
+ local owner="$1"
392
+ local repo="$2"
393
+ local search_text="$3"
394
+ local limit="${4:-5}"
395
+
396
+ if ! _gh_graphql_available; then
397
+ echo "[]"
398
+ return 0
399
+ fi
400
+
401
+ # Truncate search text to 100 chars (API limit)
402
+ if [[ ${#search_text} -gt 100 ]]; then
403
+ search_text="${search_text:0:100}"
404
+ fi
405
+
406
+ local search_query="repo:${owner}/${repo} is:issue is:closed ${search_text}"
407
+ local cache_key="similar_${owner}_${repo}_$(echo "$search_text" | tr ' /' '__' | head -c 50)"
408
+
409
+ local query='query($q: String!, $limit: Int!) {
410
+ search(query: $q, type: ISSUE, first: $limit) {
411
+ nodes {
412
+ ... on Issue {
413
+ number
414
+ title
415
+ closedAt
416
+ labels(first: 10) {
417
+ nodes { name }
418
+ }
419
+ }
420
+ }
421
+ }
422
+ }'
423
+
424
+ local variables
425
+ variables=$(jq -n --arg q "$search_query" --argjson limit "$limit" \
426
+ '{q: $q, limit: $limit}')
427
+
428
+ local result
429
+ result=$(gh_graphql_cached "$cache_key" "3600" "$query" "$variables") || {
430
+ echo "[]"
431
+ return 0
432
+ }
433
+
434
+ echo "$result" | jq '[.data.search.nodes[] |
435
+ {
436
+ number: .number,
437
+ title: .title,
438
+ labels: [.labels.nodes[].name],
439
+ closedAt: .closedAt
440
+ }]' 2>/dev/null || echo "[]"
441
+ }
442
+
443
+ # ──────────────────────────────────────────────────────────────────────────────
444
+ # Commit history — recent commits touching a path
445
+ # ──────────────────────────────────────────────────────────────────────────────
446
+ gh_commit_history() {
447
+ local owner="$1"
448
+ local repo="$2"
449
+ local path="$3"
450
+ local limit="${4:-10}"
451
+
452
+ if ! _gh_graphql_available; then
453
+ echo "[]"
454
+ return 0
455
+ fi
456
+
457
+ local cache_key="history_${owner}_${repo}_$(echo "$path" | tr '/' '_')_${limit}"
458
+ local cached
459
+ cached=$(_gh_cache_get "$cache_key" "3600" 2>/dev/null) && {
460
+ emit_event "github.cache_hit" "key=$cache_key"
461
+ echo "$cached"
462
+ return 0
463
+ }
464
+
465
+ local result
466
+ result=$(gh api "repos/${owner}/${repo}/commits?path=${path}&per_page=${limit}" 2>/dev/null) || {
467
+ echo "[]"
468
+ return 0
469
+ }
470
+
471
+ local parsed
472
+ parsed=$(echo "$result" | jq '[.[] | {
473
+ sha: .sha[0:7],
474
+ message: (.commit.message | split("\n")[0]),
475
+ author: .commit.author.name,
476
+ date: .commit.author.date
477
+ }]' 2>/dev/null || echo "[]")
478
+
479
+ _gh_cache_set "$cache_key" "$parsed"
480
+ emit_event "github.cache_miss" "key=$cache_key"
481
+
482
+ echo "$parsed"
483
+ }
484
+
485
+ # ──────────────────────────────────────────────────────────────────────────────
486
+ # Branch protection — protection rules for a branch
487
+ # ──────────────────────────────────────────────────────────────────────────────
488
+ gh_branch_protection() {
489
+ local owner="$1"
490
+ local repo="$2"
491
+ local branch="${3:-main}"
492
+
493
+ if ! _gh_graphql_available; then
494
+ echo '{"protected": false}'
495
+ return 0
496
+ fi
497
+
498
+ local cache_key="protection_${owner}_${repo}_${branch}"
499
+ local cached
500
+ cached=$(_gh_cache_get "$cache_key" "3600" 2>/dev/null) && {
501
+ emit_event "github.cache_hit" "key=$cache_key"
502
+ echo "$cached"
503
+ return 0
504
+ }
505
+
506
+ local result
507
+ result=$(gh api "repos/${owner}/${repo}/branches/${branch}/protection" 2>/dev/null) || {
508
+ # 404 = no protection rules
509
+ local parsed='{"protected": false}'
510
+ _gh_cache_set "$cache_key" "$parsed"
511
+ echo "$parsed"
512
+ return 0
513
+ }
514
+
515
+ local parsed
516
+ parsed=$(echo "$result" | jq '{
517
+ protected: true,
518
+ required_reviewers: (.required_pull_request_reviews.required_approving_review_count // 0),
519
+ dismiss_stale_reviews: (.required_pull_request_reviews.dismiss_stale_reviews // false),
520
+ require_code_owner_reviews: (.required_pull_request_reviews.require_code_owner_reviews // false),
521
+ required_checks: [(.required_status_checks.contexts // [])[]],
522
+ enforce_admins: (.enforce_admins.enabled // false),
523
+ linear_history: (.required_linear_history.enabled // false)
524
+ }' 2>/dev/null || echo '{"protected": false}')
525
+
526
+ _gh_cache_set "$cache_key" "$parsed"
527
+ emit_event "github.cache_miss" "key=$cache_key"
528
+
529
+ echo "$parsed"
530
+ }
531
+
532
+ # ──────────────────────────────────────────────────────────────────────────────
533
+ # CODEOWNERS — parsed CODEOWNERS file
534
+ # ──────────────────────────────────────────────────────────────────────────────
535
+ gh_codeowners() {
536
+ local owner="$1"
537
+ local repo="$2"
538
+
539
+ if ! _gh_graphql_available; then
540
+ echo "[]"
541
+ return 0
542
+ fi
543
+
544
+ local cache_key="codeowners_${owner}_${repo}"
545
+ local cached
546
+ cached=$(_gh_cache_get "$cache_key" "86400" 2>/dev/null) && {
547
+ emit_event "github.cache_hit" "key=$cache_key"
548
+ echo "$cached"
549
+ return 0
550
+ }
551
+
552
+ # Try common CODEOWNERS locations
553
+ local content=""
554
+ local locations=("CODEOWNERS" ".github/CODEOWNERS" "docs/CODEOWNERS")
555
+ for loc in "${locations[@]}"; do
556
+ content=$(gh api "repos/${owner}/${repo}/contents/${loc}" --jq '.content' 2>/dev/null || true)
557
+ if [[ -n "$content" ]]; then
558
+ break
559
+ fi
560
+ done
561
+
562
+ if [[ -z "$content" ]]; then
563
+ _gh_cache_set "$cache_key" "[]"
564
+ echo "[]"
565
+ return 0
566
+ fi
567
+
568
+ # Decode base64 and parse
569
+ local decoded
570
+ decoded=$(echo "$content" | base64 -d 2>/dev/null || echo "")
571
+
572
+ if [[ -z "$decoded" ]]; then
573
+ _gh_cache_set "$cache_key" "[]"
574
+ echo "[]"
575
+ return 0
576
+ fi
577
+
578
+ # Parse CODEOWNERS format: pattern owners...
579
+ local parsed="["
580
+ local first=true
581
+ while IFS= read -r line; do
582
+ # Skip empty lines and comments
583
+ [[ -z "$line" ]] && continue
584
+ [[ "$line" =~ ^[[:space:]]*# ]] && continue
585
+
586
+ local pattern
587
+ pattern=$(echo "$line" | awk '{print $1}')
588
+ local owners_str
589
+ owners_str=$(echo "$line" | awk '{$1=""; print $0}' | xargs)
590
+
591
+ # Build JSON owners array
592
+ local owners_json="["
593
+ local ofirst=true
594
+ for o in $owners_str; do
595
+ if [[ "$ofirst" == "true" ]]; then
596
+ ofirst=false
597
+ else
598
+ owners_json="${owners_json},"
599
+ fi
600
+ owners_json="${owners_json}$(jq -n --arg v "$o" '$v')"
601
+ done
602
+ owners_json="${owners_json}]"
603
+
604
+ if [[ "$first" == "true" ]]; then
605
+ first=false
606
+ else
607
+ parsed="${parsed},"
608
+ fi
609
+ parsed="${parsed}$(jq -n --arg p "$pattern" --argjson o "$owners_json" '{pattern: $p, owners: $o}')"
610
+ done <<< "$decoded"
611
+ parsed="${parsed}]"
612
+
613
+ _gh_cache_set "$cache_key" "$parsed"
614
+ emit_event "github.cache_miss" "key=$cache_key"
615
+
616
+ echo "$parsed"
617
+ }
618
+
619
+ # ──────────────────────────────────────────────────────────────────────────────
620
+ # Security alerts — CodeQL code scanning alerts
621
+ # ──────────────────────────────────────────────────────────────────────────────
622
+ gh_security_alerts() {
623
+ local owner="$1"
624
+ local repo="$2"
625
+
626
+ if ! _gh_graphql_available; then
627
+ echo "[]"
628
+ return 0
629
+ fi
630
+
631
+ local cache_key="security_${owner}_${repo}"
632
+ local cached
633
+ cached=$(_gh_cache_get "$cache_key" "1800" 2>/dev/null) && {
634
+ emit_event "github.cache_hit" "key=$cache_key"
635
+ echo "$cached"
636
+ return 0
637
+ }
638
+
639
+ local result
640
+ result=$(gh api "repos/${owner}/${repo}/code-scanning/alerts?state=open&per_page=50" 2>/dev/null) || {
641
+ # 403 = feature not enabled, 404 = not found
642
+ _gh_cache_set "$cache_key" "[]"
643
+ echo "[]"
644
+ return 0
645
+ }
646
+
647
+ local parsed
648
+ parsed=$(echo "$result" | jq '[.[] | {
649
+ number: .number,
650
+ severity: .rule.severity,
651
+ rule: .rule.id,
652
+ description: .rule.description,
653
+ path: .most_recent_instance.location.path,
654
+ state: .state
655
+ }]' 2>/dev/null || echo "[]")
656
+
657
+ _gh_cache_set "$cache_key" "$parsed"
658
+ emit_event "github.cache_miss" "key=$cache_key"
659
+
660
+ echo "$parsed"
661
+ }
662
+
663
+ # ──────────────────────────────────────────────────────────────────────────────
664
+ # Dependabot alerts — vulnerability alerts
665
+ # ──────────────────────────────────────────────────────────────────────────────
666
+ gh_dependabot_alerts() {
667
+ local owner="$1"
668
+ local repo="$2"
669
+
670
+ if ! _gh_graphql_available; then
671
+ echo "[]"
672
+ return 0
673
+ fi
674
+
675
+ local cache_key="dependabot_${owner}_${repo}"
676
+ local cached
677
+ cached=$(_gh_cache_get "$cache_key" "1800" 2>/dev/null) && {
678
+ emit_event "github.cache_hit" "key=$cache_key"
679
+ echo "$cached"
680
+ return 0
681
+ }
682
+
683
+ local result
684
+ result=$(gh api "repos/${owner}/${repo}/dependabot/alerts?state=open&per_page=50" 2>/dev/null) || {
685
+ # 403 = feature not enabled
686
+ _gh_cache_set "$cache_key" "[]"
687
+ echo "[]"
688
+ return 0
689
+ }
690
+
691
+ local parsed
692
+ parsed=$(echo "$result" | jq '[.[] | {
693
+ number: .number,
694
+ severity: .security_advisory.severity,
695
+ package: .dependency.package.name,
696
+ ecosystem: .dependency.package.ecosystem,
697
+ vulnerable_range: .security_vulnerability.vulnerable_version_range,
698
+ summary: .security_advisory.summary,
699
+ state: .state
700
+ }]' 2>/dev/null || echo "[]")
701
+
702
+ _gh_cache_set "$cache_key" "$parsed"
703
+ emit_event "github.cache_miss" "key=$cache_key"
704
+
705
+ echo "$parsed"
706
+ }
707
+
708
+ # ──────────────────────────────────────────────────────────────────────────────
709
+ # Actions runs — recent workflow run durations
710
+ # ──────────────────────────────────────────────────────────────────────────────
711
+ gh_actions_runs() {
712
+ local owner="$1"
713
+ local repo="$2"
714
+ local workflow="$3"
715
+ local limit="${4:-10}"
716
+
717
+ if ! _gh_graphql_available; then
718
+ echo "[]"
719
+ return 0
720
+ fi
721
+
722
+ local cache_key="actions_${owner}_${repo}_$(echo "$workflow" | tr '/' '_')_${limit}"
723
+ local cached
724
+ cached=$(_gh_cache_get "$cache_key" "900" 2>/dev/null) && {
725
+ emit_event "github.cache_hit" "key=$cache_key"
726
+ echo "$cached"
727
+ return 0
728
+ }
729
+
730
+ local result
731
+ result=$(gh api "repos/${owner}/${repo}/actions/workflows/${workflow}/runs?per_page=${limit}" 2>/dev/null) || {
732
+ echo "[]"
733
+ return 0
734
+ }
735
+
736
+ local parsed
737
+ parsed=$(echo "$result" | jq '[.workflow_runs[] | {
738
+ id: .id,
739
+ conclusion: .conclusion,
740
+ created_at: .created_at,
741
+ updated_at: .updated_at,
742
+ duration_seconds: (
743
+ ((.updated_at | fromdateiso8601) - (.created_at | fromdateiso8601))
744
+ )
745
+ }]' 2>/dev/null || echo "[]")
746
+
747
+ _gh_cache_set "$cache_key" "$parsed"
748
+ emit_event "github.cache_miss" "key=$cache_key"
749
+
750
+ echo "$parsed"
751
+ }
752
+
753
+ # ═══════════════════════════════════════════════════════════════════════════════
754
+ # AGGREGATED REPO CONTEXT
755
+ # ═══════════════════════════════════════════════════════════════════════════════
756
+
757
+ gh_repo_context() {
758
+ local owner="$1"
759
+ local repo="$2"
760
+
761
+ if ! _gh_graphql_available; then
762
+ echo '{"error": "GitHub API not available", "contributors": [], "security_alerts": [], "dependabot_alerts": []}'
763
+ return 0
764
+ fi
765
+
766
+ local cache_key="context_${owner}_${repo}"
767
+ local cached
768
+ cached=$(_gh_cache_get "$cache_key" "3600" 2>/dev/null) && {
769
+ emit_event "github.cache_hit" "key=$cache_key"
770
+ echo "$cached"
771
+ return 0
772
+ }
773
+
774
+ info "Fetching repo context for ${owner}/${repo}..." >&2
775
+
776
+ # Gather data (each function handles its own caching/errors)
777
+ local contributors security dependabot protection
778
+
779
+ contributors=$(gh_contributors "$owner" "$repo")
780
+ security=$(gh_security_alerts "$owner" "$repo")
781
+ dependabot=$(gh_dependabot_alerts "$owner" "$repo")
782
+ protection=$(gh_branch_protection "$owner" "$repo" "main")
783
+
784
+ # Get basic repo info
785
+ local repo_info
786
+ repo_info=$(gh api "repos/${owner}/${repo}" 2>/dev/null || echo '{}')
787
+
788
+ local primary_language
789
+ primary_language=$(echo "$repo_info" | jq -r '.language // "unknown"' 2>/dev/null || echo "unknown")
790
+
791
+ local contributor_count
792
+ contributor_count=$(echo "$contributors" | jq 'length' 2>/dev/null || echo "0")
793
+
794
+ local top_contributors
795
+ top_contributors=$(echo "$contributors" | jq '.[0:5]' 2>/dev/null || echo "[]")
796
+
797
+ local security_count
798
+ security_count=$(echo "$security" | jq 'length' 2>/dev/null || echo "0")
799
+
800
+ local dependabot_count
801
+ dependabot_count=$(echo "$dependabot" | jq 'length' 2>/dev/null || echo "0")
802
+
803
+ local context
804
+ context=$(jq -n \
805
+ --arg owner "$owner" \
806
+ --arg repo "$repo" \
807
+ --arg language "$primary_language" \
808
+ --argjson contributor_count "$contributor_count" \
809
+ --argjson top_contributors "$top_contributors" \
810
+ --argjson security_count "$security_count" \
811
+ --argjson dependabot_count "$dependabot_count" \
812
+ --argjson protection "$protection" \
813
+ --argjson security_alerts "$security" \
814
+ --argjson dependabot_alerts "$dependabot" \
815
+ '{
816
+ owner: $owner,
817
+ repo: $repo,
818
+ primary_language: $language,
819
+ contributor_count: $contributor_count,
820
+ top_contributors: $top_contributors,
821
+ security_alert_count: $security_count,
822
+ dependabot_alert_count: $dependabot_count,
823
+ branch_protection: $protection,
824
+ security_alerts: $security_alerts,
825
+ dependabot_alerts: $dependabot_alerts,
826
+ fetched_at: now | todate
827
+ }')
828
+
829
+ _gh_cache_set "$cache_key" "$context"
830
+ emit_event "github.repo_context" "owner=$owner" "repo=$repo"
831
+
832
+ echo "$context"
833
+ }
834
+
835
+ # ═══════════════════════════════════════════════════════════════════════════════
836
+ # CLI COMMANDS
837
+ # ═══════════════════════════════════════════════════════════════════════════════
838
+
839
+ gh_repo_context_cli() {
840
+ local owner="${1:-}"
841
+ local repo="${2:-}"
842
+
843
+ if [[ -z "$owner" || -z "$repo" ]]; then
844
+ _gh_detect_repo || { error "Usage: sw github-graphql context <owner> <repo>"; exit 1; }
845
+ owner="$GH_OWNER"
846
+ repo="$GH_REPO"
847
+ fi
848
+
849
+ local result
850
+ result=$(gh_repo_context "$owner" "$repo")
851
+ echo "$result" | jq .
852
+ }
853
+
854
+ gh_security_cli() {
855
+ local owner="${1:-}"
856
+ local repo="${2:-}"
857
+
858
+ if [[ -z "$owner" || -z "$repo" ]]; then
859
+ _gh_detect_repo || { error "Usage: sw github-graphql security <owner> <repo>"; exit 1; }
860
+ owner="$GH_OWNER"
861
+ repo="$GH_REPO"
862
+ fi
863
+
864
+ echo -e "${CYAN}${BOLD}Security Overview: ${owner}/${repo}${RESET}"
865
+ echo ""
866
+
867
+ echo -e "${BOLD}Code Scanning Alerts${RESET}"
868
+ local security
869
+ security=$(gh_security_alerts "$owner" "$repo")
870
+ local sec_count
871
+ sec_count=$(echo "$security" | jq 'length' 2>/dev/null || echo "0")
872
+ if [[ "$sec_count" -gt 0 ]]; then
873
+ echo "$security" | jq -r '.[] | " \(.severity)\t\(.rule)\t\(.path // "n/a")"'
874
+ else
875
+ echo -e " ${GREEN}No open alerts${RESET}"
876
+ fi
877
+ echo ""
878
+
879
+ echo -e "${BOLD}Dependabot Alerts${RESET}"
880
+ local dependabot
881
+ dependabot=$(gh_dependabot_alerts "$owner" "$repo")
882
+ local dep_count
883
+ dep_count=$(echo "$dependabot" | jq 'length' 2>/dev/null || echo "0")
884
+ if [[ "$dep_count" -gt 0 ]]; then
885
+ echo "$dependabot" | jq -r '.[] | " \(.severity)\t\(.package)\t\(.summary)"'
886
+ else
887
+ echo -e " ${GREEN}No open alerts${RESET}"
888
+ fi
889
+ }
890
+
891
+ gh_blame_data_cli() {
892
+ local owner="${1:-}"
893
+ local repo="${2:-}"
894
+ local path="${3:-}"
895
+
896
+ if [[ -z "$path" ]]; then
897
+ error "Usage: sw github-graphql blame <owner> <repo> <path>"
898
+ exit 1
899
+ fi
900
+
901
+ local result
902
+ result=$(gh_blame_data "$owner" "$repo" "$path")
903
+ echo "$result" | jq .
904
+ }
905
+
906
+ gh_commit_history_cli() {
907
+ local owner="${1:-}"
908
+ local repo="${2:-}"
909
+ local path="${3:-}"
910
+ local limit="${4:-10}"
911
+
912
+ if [[ -z "$path" ]]; then
913
+ error "Usage: sw github-graphql history <owner> <repo> <path> [limit]"
914
+ exit 1
915
+ fi
916
+
917
+ local result
918
+ result=$(gh_commit_history "$owner" "$repo" "$path" "$limit")
919
+ echo "$result" | jq .
920
+ }
921
+
922
+ gh_cache_cli() {
923
+ local subcmd="${1:-stats}"
924
+ case "$subcmd" in
925
+ stats) _gh_cache_stats ;;
926
+ clear) _gh_cache_clear ;;
927
+ *) error "Usage: sw github-graphql cache [stats|clear]"; exit 1 ;;
928
+ esac
929
+ }
930
+
931
+ show_help() {
932
+ echo -e "${CYAN}${BOLD}shipwright github-graphql${RESET} — GitHub GraphQL API Client"
933
+ echo ""
934
+ echo -e "${BOLD}Usage:${RESET}"
935
+ echo " sw github-graphql <command> [args]"
936
+ echo ""
937
+ echo -e "${BOLD}Commands:${RESET}"
938
+ echo " context [owner] [repo] Aggregated repo context for intelligence"
939
+ echo " security [owner] [repo] Security alert overview"
940
+ echo " blame <owner> <repo> <path> File blame/contributor data"
941
+ echo " history <owner> <repo> <path> [limit] Commit history for file"
942
+ echo " cache [stats|clear] Manage API cache"
943
+ echo " help Show this help"
944
+ echo ""
945
+ echo -e "${DIM}If owner/repo omitted, auto-detects from git remote.${RESET}"
946
+ }
947
+
948
+ # ═══════════════════════════════════════════════════════════════════════════════
949
+ # MAIN
950
+ # ═══════════════════════════════════════════════════════════════════════════════
951
+
952
+ main() {
953
+ local cmd="${1:-help}"
954
+ case "$cmd" in
955
+ context) shift; gh_repo_context_cli "$@" ;;
956
+ security) shift; gh_security_cli "$@" ;;
957
+ blame) shift; gh_blame_data_cli "$@" ;;
958
+ history) shift; gh_commit_history_cli "$@" ;;
959
+ cache) shift; gh_cache_cli "$@" ;;
960
+ help|--help|-h) show_help ;;
961
+ *)
962
+ error "Unknown command: $cmd"
963
+ show_help
964
+ exit 1
965
+ ;;
966
+ esac
967
+ }
968
+
969
+ # Only run main if executed directly (not sourced)
970
+ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
971
+ main "$@"
972
+ fi