shipwright-cli 1.7.0 → 1.9.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 (106) 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 +38 -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} +109 -21
  30. package/scripts/sw-adversarial.sh +274 -0
  31. package/scripts/sw-architecture-enforcer.sh +330 -0
  32. package/scripts/sw-checkpoint.sh +390 -0
  33. package/scripts/{cct-cleanup.sh → sw-cleanup.sh} +3 -1
  34. package/scripts/sw-connect.sh +619 -0
  35. package/scripts/{cct-cost.sh → sw-cost.sh} +368 -34
  36. package/scripts/{cct-daemon.sh → sw-daemon.sh} +2217 -204
  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/sw-init.sh +522 -0
  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/{cct-loop.sh → sw-loop.sh} +534 -44
  54. package/scripts/{cct-memory.sh → sw-memory.sh} +321 -38
  55. package/scripts/sw-patrol-meta.sh +417 -0
  56. package/scripts/sw-pipeline-composer.sh +455 -0
  57. package/scripts/{cct-pipeline.sh → sw-pipeline.sh} +2319 -178
  58. package/scripts/sw-predictive.sh +820 -0
  59. package/scripts/{cct-prep.sh → sw-prep.sh} +339 -49
  60. package/scripts/{cct-ps.sh → sw-ps.sh} +6 -4
  61. package/scripts/{cct-reaper.sh → sw-reaper.sh} +6 -4
  62. package/scripts/sw-remote.sh +687 -0
  63. package/scripts/sw-self-optimize.sh +947 -0
  64. package/scripts/sw-session.sh +519 -0
  65. package/scripts/sw-setup.sh +234 -0
  66. package/scripts/sw-status.sh +605 -0
  67. package/scripts/{cct-templates.sh → sw-templates.sh} +9 -4
  68. package/scripts/sw-tmux.sh +591 -0
  69. package/scripts/sw-tracker-jira.sh +277 -0
  70. package/scripts/sw-tracker-linear.sh +292 -0
  71. package/scripts/sw-tracker.sh +409 -0
  72. package/scripts/{cct-upgrade.sh → sw-upgrade.sh} +103 -46
  73. package/scripts/{cct-worktree.sh → sw-worktree.sh} +3 -0
  74. package/templates/pipelines/autonomous.json +27 -5
  75. package/templates/pipelines/full.json +12 -0
  76. package/templates/pipelines/standard.json +12 -0
  77. package/tmux/{claude-teams-overlay.conf → shipwright-overlay.conf} +27 -9
  78. package/tmux/templates/accessibility.json +34 -0
  79. package/tmux/templates/api-design.json +35 -0
  80. package/tmux/templates/architecture.json +1 -0
  81. package/tmux/templates/bug-fix.json +9 -0
  82. package/tmux/templates/code-review.json +1 -0
  83. package/tmux/templates/compliance.json +36 -0
  84. package/tmux/templates/data-pipeline.json +36 -0
  85. package/tmux/templates/debt-paydown.json +34 -0
  86. package/tmux/templates/devops.json +1 -0
  87. package/tmux/templates/documentation.json +1 -0
  88. package/tmux/templates/exploration.json +1 -0
  89. package/tmux/templates/feature-dev.json +1 -0
  90. package/tmux/templates/full-stack.json +8 -0
  91. package/tmux/templates/i18n.json +34 -0
  92. package/tmux/templates/incident-response.json +36 -0
  93. package/tmux/templates/migration.json +1 -0
  94. package/tmux/templates/observability.json +35 -0
  95. package/tmux/templates/onboarding.json +33 -0
  96. package/tmux/templates/performance.json +35 -0
  97. package/tmux/templates/refactor.json +1 -0
  98. package/tmux/templates/release.json +35 -0
  99. package/tmux/templates/security-audit.json +8 -0
  100. package/tmux/templates/spike.json +34 -0
  101. package/tmux/templates/testing.json +1 -0
  102. package/tmux/tmux.conf +98 -9
  103. package/scripts/cct-doctor.sh +0 -328
  104. package/scripts/cct-init.sh +0 -282
  105. package/scripts/cct-session.sh +0 -284
  106. package/scripts/cct-status.sh +0 -169
@@ -0,0 +1,648 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright linear — Linear ↔ GitHub Bidirectional Sync ║
4
+ # ║ Sync issues · Update statuses · Link PRs · Pipeline integration ║
5
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
6
+ set -euo pipefail
7
+ trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
8
+
9
+ VERSION="1.9.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
+ # ─── Output Helpers ─────────────────────────────────────────────────────────
28
+ info() { echo -e "${CYAN}${BOLD}▸${RESET} $*"; }
29
+ success() { echo -e "${GREEN}${BOLD}✓${RESET} $*"; }
30
+ warn() { echo -e "${YELLOW}${BOLD}⚠${RESET} $*"; }
31
+ error() { echo -e "${RED}${BOLD}✗${RESET} $*" >&2; }
32
+
33
+ now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
34
+ now_epoch() { date +%s; }
35
+
36
+ # ─── Structured Event Log ──────────────────────────────────────────────────
37
+ EVENTS_FILE="${HOME}/.shipwright/events.jsonl"
38
+
39
+ emit_event() {
40
+ local event_type="$1"
41
+ shift
42
+ local json_fields=""
43
+ for kv in "$@"; do
44
+ local key="${kv%%=*}"
45
+ local val="${kv#*=}"
46
+ if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
47
+ json_fields="${json_fields},\"${key}\":${val}"
48
+ else
49
+ val="${val//\"/\\\"}"
50
+ json_fields="${json_fields},\"${key}\":\"${val}\""
51
+ fi
52
+ done
53
+ mkdir -p "${HOME}/.shipwright"
54
+ echo "{\"ts\":\"$(now_iso)\",\"ts_epoch\":$(now_epoch),\"type\":\"${event_type}\"${json_fields}}" >> "$EVENTS_FILE"
55
+ }
56
+
57
+ # ─── Configuration ─────────────────────────────────────────────────────────
58
+ CONFIG_DIR="${HOME}/.shipwright"
59
+ LINEAR_CONFIG="${CONFIG_DIR}/linear-config.json"
60
+
61
+ # Linear Status IDs (Sethdford team)
62
+ STATUS_BACKLOG="147eb91d-0428-457b-bdcb-0875b847b061"
63
+ STATUS_TODO="f89d423b-9cad-4e60-aec9-422b64b78a4b"
64
+ STATUS_IN_PROGRESS="7ed39c42-434d-4239-86f3-ffa24dbf1275"
65
+ STATUS_IN_REVIEW="ea38a4f2-f0ee-4e0b-ae45-7e5aad45ef53"
66
+ STATUS_DONE="dc2430cf-0713-40c9-a8c6-889413b626e7"
67
+
68
+ LINEAR_API="https://api.linear.app/graphql"
69
+
70
+ load_config() {
71
+ if [[ -f "$LINEAR_CONFIG" ]]; then
72
+ LINEAR_API_KEY="${LINEAR_API_KEY:-$(jq -r '.api_key // empty' "$LINEAR_CONFIG" 2>/dev/null || true)}"
73
+ LINEAR_TEAM_ID="${LINEAR_TEAM_ID:-$(jq -r '.team_id // empty' "$LINEAR_CONFIG" 2>/dev/null || true)}"
74
+ LINEAR_PROJECT_ID="${LINEAR_PROJECT_ID:-$(jq -r '.project_id // empty' "$LINEAR_CONFIG" 2>/dev/null || true)}"
75
+ fi
76
+
77
+ LINEAR_API_KEY="${LINEAR_API_KEY:-}"
78
+ LINEAR_TEAM_ID="${LINEAR_TEAM_ID:-83deb533-69d2-43ef-bc58-eadb6e72a8f2}"
79
+ LINEAR_PROJECT_ID="${LINEAR_PROJECT_ID:-b262d625-5bbe-47bd-9f89-df27c45eba8b}"
80
+ }
81
+
82
+ check_api_key() {
83
+ if [[ -z "$LINEAR_API_KEY" ]]; then
84
+ error "LINEAR_API_KEY not set"
85
+ echo ""
86
+ echo -e " Set via environment: ${DIM}export LINEAR_API_KEY=lin_api_...${RESET}"
87
+ echo -e " Or run: ${DIM}shipwright linear init${RESET}"
88
+ exit 1
89
+ fi
90
+ }
91
+
92
+ # ─── Linear GraphQL Helper ────────────────────────────────────────────────
93
+ # Executes a GraphQL query/mutation against the Linear API.
94
+ # Uses jq --arg for safe JSON escaping (never string interpolation).
95
+ linear_graphql() {
96
+ local query="$1"
97
+ local variables="${2:-{}}"
98
+
99
+ local payload
100
+ payload=$(jq -n --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')
101
+
102
+ local response
103
+ response=$(curl -sf -X POST "$LINEAR_API" \
104
+ -H "Authorization: $LINEAR_API_KEY" \
105
+ -H "Content-Type: application/json" \
106
+ -d "$payload" 2>&1) || {
107
+ error "Linear API request failed"
108
+ echo "$response" >&2
109
+ return 1
110
+ }
111
+
112
+ # Check for GraphQL errors
113
+ local errors
114
+ errors=$(echo "$response" | jq -r '.errors[0].message // empty' 2>/dev/null || true)
115
+ if [[ -n "$errors" ]]; then
116
+ error "Linear API error: $errors"
117
+ return 1
118
+ fi
119
+
120
+ echo "$response"
121
+ }
122
+
123
+ # ─── Sync: Linear Todo → GitHub Issues ─────────────────────────────────────
124
+
125
+ cmd_sync() {
126
+ check_api_key
127
+ info "Syncing Linear Todo issues → GitHub..."
128
+
129
+ local dry_run=false
130
+ while [[ $# -gt 0 ]]; do
131
+ case "$1" in
132
+ --dry-run) dry_run=true; shift ;;
133
+ *) shift ;;
134
+ esac
135
+ done
136
+
137
+ # Fetch Linear issues in Todo status within the project
138
+ local query='query($teamId: String!, $projectId: String!, $stateId: String!) {
139
+ issues(filter: {
140
+ team: { id: { eq: $teamId } }
141
+ project: { id: { eq: $projectId } }
142
+ state: { id: { eq: $stateId } }
143
+ }) {
144
+ nodes {
145
+ id
146
+ identifier
147
+ title
148
+ description
149
+ url
150
+ priority
151
+ labels { nodes { name } }
152
+ }
153
+ }
154
+ }'
155
+
156
+ local vars
157
+ vars=$(jq -n \
158
+ --arg teamId "$LINEAR_TEAM_ID" \
159
+ --arg projectId "$LINEAR_PROJECT_ID" \
160
+ --arg stateId "$STATUS_TODO" \
161
+ '{teamId: $teamId, projectId: $projectId, stateId: $stateId}')
162
+
163
+ local response
164
+ response=$(linear_graphql "$query" "$vars") || return 1
165
+
166
+ local count
167
+ count=$(echo "$response" | jq '.data.issues.nodes | length')
168
+ if [[ "$count" -eq 0 ]]; then
169
+ info "No Linear issues in Todo status"
170
+ return 0
171
+ fi
172
+
173
+ info "Found ${count} Linear issue(s) in Todo"
174
+
175
+ local synced=0
176
+ local skipped=0
177
+
178
+ # Process each Linear issue
179
+ local i=0
180
+ while [[ $i -lt $count ]]; do
181
+ local issue
182
+ issue=$(echo "$response" | jq ".data.issues.nodes[$i]")
183
+ local linear_id linear_identifier title description url priority
184
+ linear_id=$(echo "$issue" | jq -r '.id')
185
+ linear_identifier=$(echo "$issue" | jq -r '.identifier')
186
+ title=$(echo "$issue" | jq -r '.title')
187
+ description=$(echo "$issue" | jq -r '.description // ""')
188
+ url=$(echo "$issue" | jq -r '.url')
189
+ priority=$(echo "$issue" | jq -r '.priority // 0')
190
+
191
+ # Check if GitHub issue already exists for this Linear issue
192
+ local existing_gh
193
+ existing_gh=$(gh issue list --label "ready-to-build" --search "Linear: ${linear_identifier}" --json number --jq '.[0].number // empty' 2>/dev/null || true)
194
+
195
+ if [[ -n "$existing_gh" ]]; then
196
+ echo -e " ${DIM}Skip${RESET} ${linear_identifier}: ${title} ${DIM}(GitHub #${existing_gh})${RESET}"
197
+ skipped=$((skipped + 1))
198
+ i=$((i + 1))
199
+ continue
200
+ fi
201
+
202
+ # Map priority to label
203
+ local priority_label=""
204
+ case "$priority" in
205
+ 1) priority_label="priority-urgent" ;;
206
+ 2) priority_label="priority-high" ;;
207
+ 3) priority_label="priority-medium" ;;
208
+ 4) priority_label="priority-low" ;;
209
+ esac
210
+
211
+ # Build GitHub issue body with Linear back-link
212
+ local gh_body
213
+ gh_body=$(printf "## %s\n\n%s\n\n---\n**Linear:** [%s](%s)\n**Linear ID:** %s" \
214
+ "$title" "$description" "$linear_identifier" "$url" "$linear_id")
215
+
216
+ if [[ "$dry_run" == "true" ]]; then
217
+ echo -e " ${CYAN}Would create${RESET} GitHub issue: ${title} ${DIM}(${linear_identifier})${RESET}"
218
+ else
219
+ # Create GitHub issue
220
+ local labels="ready-to-build"
221
+ if [[ -n "$priority_label" ]]; then
222
+ labels="${labels},${priority_label}"
223
+ fi
224
+
225
+ local gh_num
226
+ gh_num=$(gh issue create --title "$title" --body "$gh_body" --label "$labels" --json number --jq '.number' 2>&1) || {
227
+ error "Failed to create GitHub issue for ${linear_identifier}: ${gh_num}"
228
+ i=$((i + 1))
229
+ continue
230
+ }
231
+
232
+ # Add comment on Linear issue linking back to GitHub
233
+ local comment_body
234
+ comment_body=$(printf "Synced to GitHub issue #%s\n\nThe daemon will pick this up for autonomous delivery." "$gh_num")
235
+ linear_add_comment "$linear_id" "$comment_body" || true
236
+
237
+ # Move Linear issue to In Progress
238
+ linear_update_status "$linear_id" "$STATUS_IN_PROGRESS" || true
239
+
240
+ success "${linear_identifier} → GitHub #${gh_num}: ${title}"
241
+ emit_event "linear.sync" "linear_id=$linear_identifier" "github_issue=$gh_num" "title=$title"
242
+ synced=$((synced + 1))
243
+ fi
244
+
245
+ i=$((i + 1))
246
+ done
247
+
248
+ echo ""
249
+ if [[ "$dry_run" == "true" ]]; then
250
+ info "Dry run: ${synced} would be created, ${skipped} already synced"
251
+ else
252
+ success "Synced ${synced} issue(s), ${skipped} already linked"
253
+ fi
254
+ }
255
+
256
+ # ─── Update: GitHub → Linear Status ──────────────────────────────────────
257
+
258
+ cmd_update() {
259
+ check_api_key
260
+
261
+ if [[ $# -lt 2 ]]; then
262
+ error "Usage: shipwright linear update <github-issue-num> <status>"
263
+ echo ""
264
+ echo -e " Statuses: ${CYAN}started${RESET} | ${CYAN}review${RESET} | ${CYAN}done${RESET} | ${CYAN}failed${RESET}"
265
+ echo ""
266
+ echo -e " ${DIM}shipwright linear update 42 started${RESET} # → In Progress"
267
+ echo -e " ${DIM}shipwright linear update 42 review${RESET} # → In Review"
268
+ echo -e " ${DIM}shipwright linear update 42 done${RESET} # → Done"
269
+ echo -e " ${DIM}shipwright linear update 42 failed${RESET} # → adds failure comment"
270
+ exit 1
271
+ fi
272
+
273
+ local gh_issue="$1"
274
+ local status="$2"
275
+ local detail="${3:-}"
276
+
277
+ # Find the Linear issue ID from the GitHub issue body
278
+ local linear_id
279
+ linear_id=$(gh issue view "$gh_issue" --json body --jq '.body' 2>/dev/null | \
280
+ grep -o 'Linear ID:.*' | sed 's/.*\*\*Linear ID:\*\* //' | tr -d '[:space:]' || true)
281
+
282
+ if [[ -z "$linear_id" ]]; then
283
+ error "No Linear ID found in GitHub issue #${gh_issue}"
284
+ echo -e " ${DIM}The issue body must contain: **Linear ID:** <uuid>${RESET}"
285
+ return 1
286
+ fi
287
+
288
+ # Map status to Linear state
289
+ local target_state="" status_name=""
290
+ case "$status" in
291
+ started|in-progress|in_progress)
292
+ target_state="$STATUS_IN_PROGRESS"
293
+ status_name="In Progress"
294
+ ;;
295
+ review|in-review|in_review|pr)
296
+ target_state="$STATUS_IN_REVIEW"
297
+ status_name="In Review"
298
+ ;;
299
+ done|completed|merged)
300
+ target_state="$STATUS_DONE"
301
+ status_name="Done"
302
+ ;;
303
+ failed|error)
304
+ # Don't change status, just add a comment
305
+ local comment="Pipeline failed for GitHub issue #${gh_issue}"
306
+ if [[ -n "$detail" ]]; then
307
+ comment="${comment}\n\n${detail}"
308
+ fi
309
+ linear_add_comment "$linear_id" "$comment" || return 1
310
+ warn "Added failure comment to Linear issue"
311
+ emit_event "linear.update" "github_issue=$gh_issue" "status=failed"
312
+ return 0
313
+ ;;
314
+ *)
315
+ error "Unknown status: ${status}"
316
+ echo -e " Valid: ${CYAN}started${RESET} | ${CYAN}review${RESET} | ${CYAN}done${RESET} | ${CYAN}failed${RESET}"
317
+ return 1
318
+ ;;
319
+ esac
320
+
321
+ linear_update_status "$linear_id" "$target_state" || return 1
322
+
323
+ # Add status transition comment
324
+ local comment="Status updated to **${status_name}** (GitHub #${gh_issue})"
325
+ if [[ -n "$detail" ]]; then
326
+ comment="${comment}\n\n${detail}"
327
+ fi
328
+ linear_add_comment "$linear_id" "$comment" || true
329
+
330
+ success "Linear issue updated → ${status_name} (GitHub #${gh_issue})"
331
+ emit_event "linear.update" "github_issue=$gh_issue" "status=$status"
332
+ }
333
+
334
+ # ─── Status Dashboard ────────────────────────────────────────────────────
335
+
336
+ cmd_status() {
337
+ check_api_key
338
+
339
+ echo -e "${PURPLE}${BOLD}━━━ Linear Sync Status ━━━${RESET}"
340
+ echo ""
341
+
342
+ # Count issues by status
343
+ local statuses=("$STATUS_BACKLOG:Backlog" "$STATUS_TODO:Todo" "$STATUS_IN_PROGRESS:In Progress" "$STATUS_IN_REVIEW:In Review" "$STATUS_DONE:Done")
344
+
345
+ for entry in "${statuses[@]}"; do
346
+ local state_id="${entry%%:*}"
347
+ local state_name="${entry#*:}"
348
+
349
+ local query='query($teamId: String!, $projectId: String!, $stateId: String!) {
350
+ issues(filter: {
351
+ team: { id: { eq: $teamId } }
352
+ project: { id: { eq: $projectId } }
353
+ state: { id: { eq: $stateId } }
354
+ }) {
355
+ nodes { id identifier title url }
356
+ }
357
+ }'
358
+
359
+ local vars
360
+ vars=$(jq -n \
361
+ --arg teamId "$LINEAR_TEAM_ID" \
362
+ --arg projectId "$LINEAR_PROJECT_ID" \
363
+ --arg stateId "$state_id" \
364
+ '{teamId: $teamId, projectId: $projectId, stateId: $stateId}')
365
+
366
+ local response
367
+ response=$(linear_graphql "$query" "$vars" 2>/dev/null) || {
368
+ echo -e " ${RED}✗${RESET} ${state_name}: ${DIM}(API error)${RESET}"
369
+ continue
370
+ }
371
+
372
+ local count
373
+ count=$(echo "$response" | jq '.data.issues.nodes | length')
374
+
375
+ local color="$DIM"
376
+ case "$state_name" in
377
+ "In Progress") color="$CYAN" ;;
378
+ "In Review") color="$BLUE" ;;
379
+ "Done") color="$GREEN" ;;
380
+ "Todo") color="$YELLOW" ;;
381
+ esac
382
+
383
+ echo -e " ${color}${BOLD}${state_name}${RESET} ${count}"
384
+
385
+ # Show individual issues for active states
386
+ if [[ "$count" -gt 0 ]] && [[ "$state_name" != "Done" ]] && [[ "$state_name" != "Backlog" ]]; then
387
+ local j=0
388
+ while [[ $j -lt $count ]]; do
389
+ local id title
390
+ id=$(echo "$response" | jq -r ".data.issues.nodes[$j].identifier")
391
+ title=$(echo "$response" | jq -r ".data.issues.nodes[$j].title")
392
+ echo -e " ${DIM}${id}${RESET} ${title}"
393
+ j=$((j + 1))
394
+ done
395
+ fi
396
+ done
397
+
398
+ echo ""
399
+
400
+ # Show recent sync events
401
+ if [[ -f "$EVENTS_FILE" ]]; then
402
+ local recent_syncs
403
+ recent_syncs=$(grep '"type":"linear\.' "$EVENTS_FILE" 2>/dev/null | tail -5 || true)
404
+ if [[ -n "$recent_syncs" ]]; then
405
+ echo -e "${BOLD}Recent Activity${RESET}"
406
+ echo "$recent_syncs" | while IFS= read -r line; do
407
+ local ts type
408
+ ts=$(echo "$line" | jq -r '.ts' 2>/dev/null || true)
409
+ type=$(echo "$line" | jq -r '.type' 2>/dev/null || true)
410
+ local short_ts="${ts:-unknown}"
411
+ echo -e " ${DIM}${short_ts}${RESET} ${type}"
412
+ done
413
+ echo ""
414
+ fi
415
+ fi
416
+ }
417
+
418
+ # ─── Init: Save Configuration ────────────────────────────────────────────
419
+
420
+ cmd_init() {
421
+ echo -e "${PURPLE}${BOLD}━━━ Linear Integration Setup ━━━${RESET}"
422
+ echo ""
423
+
424
+ mkdir -p "$CONFIG_DIR"
425
+
426
+ # API Key
427
+ local api_key="${LINEAR_API_KEY:-}"
428
+ if [[ -z "$api_key" ]]; then
429
+ echo -e " ${CYAN}1.${RESET} Go to ${DIM}https://linear.app/settings/api${RESET}"
430
+ echo -e " ${CYAN}2.${RESET} Create a personal API key"
431
+ echo -e " ${CYAN}3.${RESET} Paste it below"
432
+ echo ""
433
+ read -rp " Linear API Key: " api_key
434
+ if [[ -z "$api_key" ]]; then
435
+ error "API key is required"
436
+ exit 1
437
+ fi
438
+ fi
439
+
440
+ local team_id="${LINEAR_TEAM_ID:-83deb533-69d2-43ef-bc58-eadb6e72a8f2}"
441
+ local project_id="${LINEAR_PROJECT_ID:-b262d625-5bbe-47bd-9f89-df27c45eba8b}"
442
+
443
+ # Write config atomically
444
+ local tmp_config="${LINEAR_CONFIG}.tmp"
445
+ jq -n \
446
+ --arg api_key "$api_key" \
447
+ --arg team_id "$team_id" \
448
+ --arg project_id "$project_id" \
449
+ --arg created_at "$(now_iso)" \
450
+ '{
451
+ api_key: $api_key,
452
+ team_id: $team_id,
453
+ project_id: $project_id,
454
+ created_at: $created_at
455
+ }' > "$tmp_config"
456
+ mv "$tmp_config" "$LINEAR_CONFIG"
457
+ chmod 600 "$LINEAR_CONFIG"
458
+
459
+ success "Configuration saved to ${LINEAR_CONFIG}"
460
+ echo ""
461
+
462
+ # Validate the key works
463
+ info "Validating API key..."
464
+ LINEAR_API_KEY="$api_key"
465
+ local test_query='query { viewer { id name email } }'
466
+ local test_response
467
+ test_response=$(linear_graphql "$test_query") || {
468
+ error "API key validation failed — check your key"
469
+ exit 1
470
+ }
471
+
472
+ local viewer_name
473
+ viewer_name=$(echo "$test_response" | jq -r '.data.viewer.name // "Unknown"')
474
+ success "Authenticated as: ${viewer_name}"
475
+
476
+ emit_event "linear.init" "user=$viewer_name"
477
+ }
478
+
479
+ # ─── Helper: Update Linear Issue Status ──────────────────────────────────
480
+
481
+ linear_update_status() {
482
+ local issue_id="$1"
483
+ local state_id="$2"
484
+
485
+ local query='mutation($issueId: String!, $stateId: String!) {
486
+ issueUpdate(id: $issueId, input: { stateId: $stateId }) {
487
+ issue { id identifier }
488
+ }
489
+ }'
490
+
491
+ local vars
492
+ vars=$(jq -n --arg issueId "$issue_id" --arg stateId "$state_id" \
493
+ '{issueId: $issueId, stateId: $stateId}')
494
+
495
+ linear_graphql "$query" "$vars" >/dev/null
496
+ }
497
+
498
+ # ─── Helper: Add Comment to Linear Issue ─────────────────────────────────
499
+
500
+ linear_add_comment() {
501
+ local issue_id="$1"
502
+ local body="$2"
503
+
504
+ local query='mutation($issueId: String!, $body: String!) {
505
+ commentCreate(input: { issueId: $issueId, body: $body }) {
506
+ comment { id }
507
+ }
508
+ }'
509
+
510
+ local vars
511
+ vars=$(jq -n --arg issueId "$issue_id" --arg body "$body" \
512
+ '{issueId: $issueId, body: $body}')
513
+
514
+ linear_graphql "$query" "$vars" >/dev/null
515
+ }
516
+
517
+ # ─── Helper: Attach PR link to Linear Issue ──────────────────────────────
518
+
519
+ linear_attach_pr() {
520
+ local issue_id="$1"
521
+ local pr_url="$2"
522
+ local pr_title="${3:-Pull Request}"
523
+
524
+ local body
525
+ body=$(printf "PR linked: [%s](%s)" "$pr_title" "$pr_url")
526
+ linear_add_comment "$issue_id" "$body"
527
+ }
528
+
529
+ # ─── Daemon Integration: Notify Linear on Pipeline Events ────────────────
530
+ # Called by sw-daemon.sh at spawn, stage transition, completion, and failure.
531
+ # This function is designed to be sourced or called externally.
532
+
533
+ linear_notify() {
534
+ local event="$1"
535
+ local gh_issue="${2:-}"
536
+ local detail="${3:-}"
537
+
538
+ # Delegate to tracker router if available (preferred path)
539
+ if [[ -f "$SCRIPT_DIR/sw-tracker.sh" ]]; then
540
+ "$SCRIPT_DIR/sw-tracker.sh" notify "$event" "$gh_issue" "$detail" 2>/dev/null || true
541
+ return 0
542
+ fi
543
+
544
+ # Fallback: direct Linear notification (backward compatibility)
545
+ load_config
546
+ if [[ -z "$LINEAR_API_KEY" ]]; then
547
+ return 0 # silently skip if no Linear integration
548
+ fi
549
+
550
+ # Find the Linear issue ID from GitHub issue
551
+ local linear_id=""
552
+ if [[ -n "$gh_issue" ]]; then
553
+ linear_id=$(gh issue view "$gh_issue" --json body --jq '.body' 2>/dev/null | \
554
+ grep -o 'Linear ID:.*' | sed 's/.*\*\*Linear ID:\*\* //' | tr -d '[:space:]' || true)
555
+ fi
556
+
557
+ if [[ -z "$linear_id" ]]; then
558
+ return 0 # no linked Linear issue
559
+ fi
560
+
561
+ case "$event" in
562
+ spawn|started)
563
+ linear_update_status "$linear_id" "$STATUS_IN_PROGRESS" || true
564
+ linear_add_comment "$linear_id" "Pipeline started for GitHub issue #${gh_issue}" || true
565
+ ;;
566
+ review|pr-created)
567
+ linear_update_status "$linear_id" "$STATUS_IN_REVIEW" || true
568
+ if [[ -n "$detail" ]]; then
569
+ linear_attach_pr "$linear_id" "$detail" "PR for #${gh_issue}" || true
570
+ fi
571
+ ;;
572
+ completed|done)
573
+ linear_update_status "$linear_id" "$STATUS_DONE" || true
574
+ linear_add_comment "$linear_id" "Pipeline completed successfully for GitHub issue #${gh_issue}" || true
575
+ ;;
576
+ failed)
577
+ local msg="Pipeline failed for GitHub issue #${gh_issue}"
578
+ if [[ -n "$detail" ]]; then
579
+ msg="${msg}\n\nDetails:\n${detail}"
580
+ fi
581
+ linear_add_comment "$linear_id" "$msg" || true
582
+ ;;
583
+ esac
584
+
585
+ emit_event "linear.notify" "event=$event" "github_issue=$gh_issue"
586
+ }
587
+
588
+ # ─── Help ────────────────────────────────────────────────────────────────
589
+
590
+ show_help() {
591
+ echo -e "${CYAN}${BOLD}shipwright linear${RESET} — Linear ↔ GitHub Bidirectional Sync"
592
+ echo ""
593
+ echo -e "${BOLD}USAGE${RESET}"
594
+ echo -e " ${CYAN}shipwright linear${RESET} <command> [options]"
595
+ echo ""
596
+ echo -e "${BOLD}COMMANDS${RESET}"
597
+ echo -e " ${CYAN}sync${RESET} [--dry-run] Sync Linear Todo issues → GitHub"
598
+ echo -e " ${CYAN}update${RESET} <issue> <status> Update linked Linear ticket status"
599
+ echo -e " ${CYAN}status${RESET} Show sync dashboard"
600
+ echo -e " ${CYAN}init${RESET} Configure Linear API key"
601
+ echo -e " ${CYAN}help${RESET} Show this help"
602
+ echo ""
603
+ echo -e "${BOLD}STATUS VALUES${RESET}"
604
+ echo -e " ${CYAN}started${RESET} Pipeline spawned → Linear: In Progress"
605
+ echo -e " ${CYAN}review${RESET} PR created → Linear: In Review"
606
+ echo -e " ${CYAN}done${RESET} Pipeline complete → Linear: Done"
607
+ echo -e " ${CYAN}failed${RESET} Pipeline failed → Linear: adds failure comment"
608
+ echo ""
609
+ echo -e "${BOLD}EXAMPLES${RESET}"
610
+ echo -e " ${DIM}shipwright linear init${RESET} # Set up API key"
611
+ echo -e " ${DIM}shipwright linear sync${RESET} # Sync Todo → GitHub"
612
+ echo -e " ${DIM}shipwright linear sync --dry-run${RESET} # Preview what would sync"
613
+ echo -e " ${DIM}shipwright linear update 42 started${RESET} # Mark as In Progress"
614
+ echo -e " ${DIM}shipwright linear update 42 review${RESET} # Mark as In Review"
615
+ echo -e " ${DIM}shipwright linear update 42 done${RESET} # Mark as Done"
616
+ echo -e " ${DIM}shipwright linear status${RESET} # Show dashboard"
617
+ echo ""
618
+ echo -e "${BOLD}ENVIRONMENT${RESET}"
619
+ echo -e " ${DIM}LINEAR_API_KEY${RESET} API key (or use 'linear init' to save)"
620
+ echo -e " ${DIM}LINEAR_TEAM_ID${RESET} Override team ID"
621
+ echo -e " ${DIM}LINEAR_PROJECT_ID${RESET} Override project ID"
622
+ }
623
+
624
+ # ─── Command Router ─────────────────────────────────────────────────────
625
+
626
+ main() {
627
+ load_config
628
+
629
+ local cmd="${1:-help}"
630
+ shift 2>/dev/null || true
631
+
632
+ case "$cmd" in
633
+ sync) cmd_sync "$@" ;;
634
+ update) cmd_update "$@" ;;
635
+ status) cmd_status "$@" ;;
636
+ init) cmd_init "$@" ;;
637
+ notify) linear_notify "$@" ;;
638
+ help|--help|-h) show_help ;;
639
+ *)
640
+ error "Unknown command: ${cmd}"
641
+ echo ""
642
+ show_help
643
+ exit 1
644
+ ;;
645
+ esac
646
+ }
647
+
648
+ main "$@"