shipwright-cli 1.7.1 → 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 (105) 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/{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/{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 -414
  104. package/scripts/cct-session.sh +0 -284
  105. package/scripts/cct-status.sh +0 -169
@@ -0,0 +1,277 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright tracker: Jira Provider ║
4
+ # ║ Sourced by sw-tracker.sh — do not call directly ║
5
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
6
+ # This file is sourced by sw-tracker.sh.
7
+ # It defines provider_* functions used by the tracker router.
8
+ # Do NOT add set -euo pipefail or a main() function here.
9
+
10
+ # ─── Status Auto-Discovery ────────────────────────────────────────────────
11
+ # Queries Jira API for project statuses and caches the transition name mapping.
12
+ # Only fills in JIRA_TRANSITION_* values that are empty (config/env takes priority).
13
+ # Falls back silently if API call fails.
14
+
15
+ provider_discover_statuses() {
16
+ # Require base URL, API token, and project key for discovery
17
+ [[ -z "${JIRA_BASE_URL:-}" || -z "${JIRA_API_TOKEN:-}" || -z "${JIRA_PROJECT_KEY:-}" ]] && return 0
18
+
19
+ local cache_dir="${HOME}/.shipwright/tracker-cache"
20
+ local cache_file="${cache_dir}/jira-statuses.json"
21
+
22
+ # Check cache freshness (24h TTL)
23
+ if [[ -f "$cache_file" ]]; then
24
+ local now cached_at cache_age
25
+ now=$(date +%s)
26
+ cached_at=$(jq -r '.cached_at // 0' "$cache_file" 2>/dev/null || echo "0")
27
+ cache_age=$((now - cached_at))
28
+ if [[ $cache_age -lt 86400 ]]; then
29
+ # Use cached transition names (only fill empty slots)
30
+ local cached_val
31
+ cached_val=$(jq -r '.transitions.in_progress // empty' "$cache_file" 2>/dev/null || true)
32
+ [[ -z "$JIRA_TRANSITION_IN_PROGRESS" && -n "$cached_val" ]] && JIRA_TRANSITION_IN_PROGRESS="$cached_val"
33
+ cached_val=$(jq -r '.transitions.in_review // empty' "$cache_file" 2>/dev/null || true)
34
+ [[ -z "$JIRA_TRANSITION_IN_REVIEW" && -n "$cached_val" ]] && JIRA_TRANSITION_IN_REVIEW="$cached_val"
35
+ cached_val=$(jq -r '.transitions.done // empty' "$cache_file" 2>/dev/null || true)
36
+ [[ -z "$JIRA_TRANSITION_DONE" && -n "$cached_val" ]] && JIRA_TRANSITION_DONE="$cached_val"
37
+ return 0
38
+ fi
39
+ fi
40
+
41
+ # Query Jira API for project statuses
42
+ local response
43
+ response=$(jira_api "GET" "project/${JIRA_PROJECT_KEY}/statuses" 2>/dev/null) || return 0
44
+
45
+ # Parse all unique statuses across issue types
46
+ local all_statuses
47
+ all_statuses=$(echo "$response" | jq '[.[].statuses[]] | unique_by(.name)' 2>/dev/null || echo "[]")
48
+
49
+ local status_count
50
+ status_count=$(echo "$all_statuses" | jq 'length' 2>/dev/null || echo "0")
51
+ [[ "$status_count" -eq 0 ]] && return 0
52
+
53
+ # Map by Jira status category: new, indeterminate, done
54
+ local discovered_in_progress discovered_in_review discovered_done
55
+
56
+ # "indeterminate" category = in progress states
57
+ discovered_in_progress=$(echo "$all_statuses" | jq -r \
58
+ '[.[] | select(.statusCategory.key == "indeterminate")] | .[0] | .name // empty' 2>/dev/null || true)
59
+
60
+ # Look for a review state (name contains "review")
61
+ discovered_in_review=$(echo "$all_statuses" | jq -r \
62
+ '[.[] | select(.name | test("review"; "i"))] | .[0] | .name // empty' 2>/dev/null || true)
63
+
64
+ # "done" category
65
+ discovered_done=$(echo "$all_statuses" | jq -r \
66
+ '[.[] | select(.statusCategory.key == "done")] | .[0] | .name // empty' 2>/dev/null || true)
67
+
68
+ # Apply discovered values (only fill gaps — config/env takes priority)
69
+ [[ -z "$JIRA_TRANSITION_IN_PROGRESS" && -n "$discovered_in_progress" ]] && JIRA_TRANSITION_IN_PROGRESS="$discovered_in_progress"
70
+ [[ -z "$JIRA_TRANSITION_IN_REVIEW" && -n "$discovered_in_review" ]] && JIRA_TRANSITION_IN_REVIEW="$discovered_in_review"
71
+ [[ -z "$JIRA_TRANSITION_DONE" && -n "$discovered_done" ]] && JIRA_TRANSITION_DONE="$discovered_done"
72
+
73
+ # Cache results atomically
74
+ mkdir -p "$cache_dir"
75
+ local tmp_cache
76
+ tmp_cache=$(mktemp)
77
+ jq -n \
78
+ --arg ts "$(date +%s)" \
79
+ --arg in_progress "${discovered_in_progress:-}" \
80
+ --arg in_review "${discovered_in_review:-}" \
81
+ --arg done "${discovered_done:-}" \
82
+ '{
83
+ cached_at: ($ts | tonumber),
84
+ transitions: {
85
+ in_progress: $in_progress,
86
+ in_review: $in_review,
87
+ done: $done
88
+ }
89
+ }' > "$tmp_cache" 2>/dev/null && mv "$tmp_cache" "$cache_file" || rm -f "$tmp_cache"
90
+ }
91
+
92
+ # ─── Load Jira-specific Config ─────────────────────────────────────────────
93
+
94
+ provider_load_config() {
95
+ local config="${HOME}/.shipwright/tracker-config.json"
96
+
97
+ JIRA_BASE_URL="${JIRA_BASE_URL:-$(jq -r '.jira.base_url // empty' "$config" 2>/dev/null || true)}"
98
+ JIRA_EMAIL="${JIRA_EMAIL:-$(jq -r '.jira.email // empty' "$config" 2>/dev/null || true)}"
99
+ JIRA_API_TOKEN="${JIRA_API_TOKEN:-$(jq -r '.jira.api_token // empty' "$config" 2>/dev/null || true)}"
100
+ JIRA_PROJECT_KEY="${JIRA_PROJECT_KEY:-$(jq -r '.jira.project_key // empty' "$config" 2>/dev/null || true)}"
101
+
102
+ # Transition names from config (empty if not explicitly configured)
103
+ JIRA_TRANSITION_IN_PROGRESS="${JIRA_TRANSITION_IN_PROGRESS:-$(jq -r '.jira.transitions.in_progress // empty' "$config" 2>/dev/null || true)}"
104
+ JIRA_TRANSITION_IN_REVIEW="${JIRA_TRANSITION_IN_REVIEW:-$(jq -r '.jira.transitions.in_review // empty' "$config" 2>/dev/null || true)}"
105
+ JIRA_TRANSITION_DONE="${JIRA_TRANSITION_DONE:-$(jq -r '.jira.transitions.done // empty' "$config" 2>/dev/null || true)}"
106
+
107
+ # Strip trailing slash from base URL
108
+ JIRA_BASE_URL="${JIRA_BASE_URL%/}"
109
+
110
+ # Auto-discover statuses from API if not explicitly configured
111
+ provider_discover_statuses
112
+
113
+ # Final fallback defaults
114
+ JIRA_TRANSITION_IN_PROGRESS="${JIRA_TRANSITION_IN_PROGRESS:-In Progress}"
115
+ JIRA_TRANSITION_IN_REVIEW="${JIRA_TRANSITION_IN_REVIEW:-In Review}"
116
+ JIRA_TRANSITION_DONE="${JIRA_TRANSITION_DONE:-Done}"
117
+ }
118
+
119
+ # ─── Jira REST API Helper ─────────────────────────────────────────────────
120
+
121
+ jira_api() {
122
+ local method="$1"
123
+ local endpoint="$2"
124
+ local data="${3:-}"
125
+
126
+ local auth
127
+ auth=$(printf '%s:%s' "$JIRA_EMAIL" "$JIRA_API_TOKEN" | base64)
128
+
129
+ local args=(-sf -X "$method" \
130
+ -H "Authorization: Basic $auth" \
131
+ -H "Content-Type: application/json")
132
+
133
+ if [[ -n "$data" ]]; then
134
+ args+=(-d "$data")
135
+ fi
136
+
137
+ curl "${args[@]}" "${JIRA_BASE_URL}/rest/api/3/${endpoint}" 2>&1
138
+ }
139
+
140
+ # ─── Find Jira Issue Key from GitHub Issue Body ───────────────────────────
141
+
142
+ find_jira_key() {
143
+ local gh_issue="$1"
144
+
145
+ if [[ -z "$gh_issue" ]]; then
146
+ return 0
147
+ fi
148
+
149
+ gh issue view "$gh_issue" --json body --jq '.body' 2>/dev/null | \
150
+ grep -oE 'Jira:.*[A-Z]+-[0-9]+' | grep -oE '[A-Z]+-[0-9]+' | head -1 || true
151
+ }
152
+
153
+ # ─── Add Comment to Jira Issue ─────────────────────────────────────────────
154
+ # Uses Atlassian Document Format (ADF) for the comment body.
155
+
156
+ jira_add_comment() {
157
+ local issue_key="$1"
158
+ local body="$2"
159
+
160
+ local payload
161
+ payload=$(jq -n --arg text "$body" '{
162
+ body: {
163
+ type: "doc",
164
+ version: 1,
165
+ content: [{
166
+ type: "paragraph",
167
+ content: [{type: "text", text: $text}]
168
+ }]
169
+ }
170
+ }')
171
+
172
+ jira_api "POST" "issue/${issue_key}/comment" "$payload"
173
+ }
174
+
175
+ # ─── Transition Jira Issue ─────────────────────────────────────────────────
176
+ # Finds the transition ID by name and applies it.
177
+
178
+ jira_transition() {
179
+ local issue_key="$1"
180
+ local transition_name="$2"
181
+
182
+ # Get available transitions
183
+ local transitions
184
+ transitions=$(jira_api "GET" "issue/${issue_key}/transitions") || return 0
185
+
186
+ # Find transition ID by name
187
+ local transition_id
188
+ transition_id=$(echo "$transitions" | jq -r --arg name "$transition_name" \
189
+ '.transitions[] | select(.name == $name) | .id' 2>/dev/null || true)
190
+
191
+ if [[ -z "$transition_id" ]]; then
192
+ # Transition not available — silently skip
193
+ return 0
194
+ fi
195
+
196
+ local payload
197
+ payload=$(jq -n --arg id "$transition_id" '{transition: {id: $id}}')
198
+
199
+ jira_api "POST" "issue/${issue_key}/transitions" "$payload"
200
+ }
201
+
202
+ # ─── Add Remote Link (PR) to Jira Issue ───────────────────────────────────
203
+
204
+ jira_attach_pr() {
205
+ local issue_key="$1"
206
+ local pr_url="$2"
207
+ local pr_title="${3:-Pull Request}"
208
+
209
+ local payload
210
+ payload=$(jq -n --arg url "$pr_url" --arg title "$pr_title" '{
211
+ object: {url: $url, title: $title}
212
+ }')
213
+
214
+ jira_api "POST" "issue/${issue_key}/remotelink" "$payload"
215
+ }
216
+
217
+ # ─── Main Provider Entry Point ─────────────────────────────────────────────
218
+ # Called by tracker_notify() in sw-tracker.sh
219
+
220
+ provider_notify() {
221
+ local event="$1"
222
+ local gh_issue="${2:-}"
223
+ local detail="${3:-}"
224
+
225
+ provider_load_config
226
+
227
+ # Silently skip if not configured
228
+ [[ -z "$JIRA_BASE_URL" || -z "$JIRA_API_TOKEN" ]] && return 0
229
+
230
+ # Find the linked Jira issue
231
+ local jira_key=""
232
+ if [[ -n "$gh_issue" ]]; then
233
+ jira_key=$(find_jira_key "$gh_issue")
234
+ fi
235
+ [[ -z "$jira_key" ]] && return 0
236
+
237
+ case "$event" in
238
+ spawn|started)
239
+ jira_transition "$jira_key" "$JIRA_TRANSITION_IN_PROGRESS" || true
240
+ jira_add_comment "$jira_key" "Pipeline started for GitHub issue #${gh_issue}" || true
241
+ ;;
242
+ stage_complete)
243
+ # detail format: "stage_id|duration|description"
244
+ local stage_id duration stage_desc
245
+ stage_id=$(echo "$detail" | cut -d'|' -f1)
246
+ duration=$(echo "$detail" | cut -d'|' -f2)
247
+ stage_desc=$(echo "$detail" | cut -d'|' -f3)
248
+ jira_add_comment "$jira_key" "Stage ${stage_id} complete (${duration}) — ${stage_desc}" || true
249
+ ;;
250
+ stage_failed)
251
+ # detail format: "stage_id|error_context"
252
+ local stage_id error_ctx
253
+ stage_id=$(echo "$detail" | cut -d'|' -f1)
254
+ error_ctx=$(echo "$detail" | cut -d'|' -f2-)
255
+ jira_add_comment "$jira_key" "Stage ${stage_id} failed: ${error_ctx}" || true
256
+ ;;
257
+ review|pr-created)
258
+ jira_transition "$jira_key" "$JIRA_TRANSITION_IN_REVIEW" || true
259
+ if [[ -n "$detail" ]]; then
260
+ jira_attach_pr "$jira_key" "$detail" "PR for #${gh_issue}" || true
261
+ fi
262
+ ;;
263
+ completed|done)
264
+ jira_transition "$jira_key" "$JIRA_TRANSITION_DONE" || true
265
+ jira_add_comment "$jira_key" "Pipeline completed for GitHub issue #${gh_issue}" || true
266
+ ;;
267
+ failed)
268
+ local msg="Pipeline failed for GitHub issue #${gh_issue}"
269
+ if [[ -n "$detail" ]]; then
270
+ msg="${msg}. ${detail}"
271
+ fi
272
+ jira_add_comment "$jira_key" "$msg" || true
273
+ ;;
274
+ esac
275
+
276
+ emit_event "tracker.notify" "provider=jira" "event=$event" "github_issue=$gh_issue"
277
+ }
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env bash
2
+ # ╔═══════════════════════════════════════════════════════════════════════════╗
3
+ # ║ shipwright tracker: Linear Provider ║
4
+ # ║ Sourced by sw-tracker.sh — do not call directly ║
5
+ # ╚═══════════════════════════════════════════════════════════════════════════╝
6
+ # This file is sourced by sw-tracker.sh.
7
+ # It defines provider_* functions used by the tracker router.
8
+ # Do NOT add set -euo pipefail or a main() function here.
9
+
10
+ # ─── Status Auto-Discovery ────────────────────────────────────────────────
11
+ # Queries Linear API for workflow states and caches the mapping.
12
+ # Only fills in STATUS_* values that are empty (config/env takes priority).
13
+ # Falls back silently if API call fails.
14
+
15
+ provider_discover_statuses() {
16
+ # Require team ID and API key for discovery
17
+ [[ -z "${LINEAR_TEAM_ID:-}" ]] && return 0
18
+ [[ -z "${LINEAR_API_KEY:-}" ]] && return 0
19
+
20
+ local cache_dir="${HOME}/.shipwright/tracker-cache"
21
+ local cache_file="${cache_dir}/linear-statuses.json"
22
+
23
+ # Check cache freshness (24h TTL)
24
+ if [[ -f "$cache_file" ]]; then
25
+ local now cached_at cache_age
26
+ now=$(date +%s)
27
+ cached_at=$(jq -r '.cached_at // 0' "$cache_file" 2>/dev/null || echo "0")
28
+ cache_age=$((now - cached_at))
29
+ if [[ $cache_age -lt 86400 ]]; then
30
+ # Use cached values (only fill empty slots)
31
+ [[ -z "$STATUS_BACKLOG" ]] && STATUS_BACKLOG=$(jq -r '.statuses.backlog // empty' "$cache_file" 2>/dev/null || true)
32
+ [[ -z "$STATUS_TODO" ]] && STATUS_TODO=$(jq -r '.statuses.todo // empty' "$cache_file" 2>/dev/null || true)
33
+ [[ -z "$STATUS_IN_PROGRESS" ]] && STATUS_IN_PROGRESS=$(jq -r '.statuses.in_progress // empty' "$cache_file" 2>/dev/null || true)
34
+ [[ -z "$STATUS_IN_REVIEW" ]] && STATUS_IN_REVIEW=$(jq -r '.statuses.in_review // empty' "$cache_file" 2>/dev/null || true)
35
+ [[ -z "$STATUS_DONE" ]] && STATUS_DONE=$(jq -r '.statuses.done // empty' "$cache_file" 2>/dev/null || true)
36
+ return 0
37
+ fi
38
+ fi
39
+
40
+ # Query Linear API for workflow states
41
+ local query='query($teamId: String!) {
42
+ team(id: $teamId) {
43
+ states {
44
+ nodes { id name type }
45
+ }
46
+ }
47
+ }'
48
+ local vars
49
+ vars=$(jq -n --arg teamId "$LINEAR_TEAM_ID" '{teamId: $teamId}')
50
+
51
+ local response
52
+ response=$(linear_graphql "$query" "$vars" 2>/dev/null) || {
53
+ # API call failed — keep existing config/hardcoded values
54
+ return 0
55
+ }
56
+
57
+ # Parse states
58
+ local states_json
59
+ states_json=$(echo "$response" | jq '.data.team.states.nodes // []' 2>/dev/null || echo "[]")
60
+
61
+ local state_count
62
+ state_count=$(echo "$states_json" | jq 'length' 2>/dev/null || echo "0")
63
+ [[ "$state_count" -eq 0 ]] && return 0
64
+
65
+ # Map by Linear state type: backlog, unstarted, started, completed, canceled
66
+ local discovered_backlog discovered_todo discovered_in_progress discovered_in_review discovered_done
67
+
68
+ discovered_backlog=$(echo "$states_json" | jq -r '[.[] | select(.type == "backlog")] | .[0] | .id // empty' 2>/dev/null || true)
69
+ discovered_todo=$(echo "$states_json" | jq -r '[.[] | select(.type == "unstarted")] | .[0] | .id // empty' 2>/dev/null || true)
70
+ discovered_in_progress=$(echo "$states_json" | jq -r '[.[] | select(.type == "started")] | .[0] | .id // empty' 2>/dev/null || true)
71
+ discovered_done=$(echo "$states_json" | jq -r '[.[] | select(.type == "completed")] | .[0] | .id // empty' 2>/dev/null || true)
72
+
73
+ # "In Review" is typically a custom state — match by name
74
+ discovered_in_review=$(echo "$states_json" | jq -r '[.[] | select(.name | test("review"; "i"))] | .[0] | .id // empty' 2>/dev/null || true)
75
+
76
+ # Apply discovered values (only fill gaps — config/env takes priority)
77
+ [[ -z "$STATUS_BACKLOG" && -n "$discovered_backlog" ]] && STATUS_BACKLOG="$discovered_backlog"
78
+ [[ -z "$STATUS_TODO" && -n "$discovered_todo" ]] && STATUS_TODO="$discovered_todo"
79
+ [[ -z "$STATUS_IN_PROGRESS" && -n "$discovered_in_progress" ]] && STATUS_IN_PROGRESS="$discovered_in_progress"
80
+ [[ -z "$STATUS_IN_REVIEW" && -n "$discovered_in_review" ]] && STATUS_IN_REVIEW="$discovered_in_review"
81
+ [[ -z "$STATUS_DONE" && -n "$discovered_done" ]] && STATUS_DONE="$discovered_done"
82
+
83
+ # Cache results atomically
84
+ mkdir -p "$cache_dir"
85
+ local tmp_cache
86
+ tmp_cache=$(mktemp)
87
+ jq -n \
88
+ --arg ts "$(date +%s)" \
89
+ --arg backlog "${discovered_backlog:-}" \
90
+ --arg todo "${discovered_todo:-}" \
91
+ --arg in_progress "${discovered_in_progress:-}" \
92
+ --arg in_review "${discovered_in_review:-}" \
93
+ --arg done "${discovered_done:-}" \
94
+ '{
95
+ cached_at: ($ts | tonumber),
96
+ statuses: {
97
+ backlog: $backlog,
98
+ todo: $todo,
99
+ in_progress: $in_progress,
100
+ in_review: $in_review,
101
+ done: $done
102
+ }
103
+ }' > "$tmp_cache" 2>/dev/null && mv "$tmp_cache" "$cache_file" || rm -f "$tmp_cache"
104
+ }
105
+
106
+ # ─── Load Linear-specific Config ───────────────────────────────────────────
107
+
108
+ provider_load_config() {
109
+ local config="${HOME}/.shipwright/tracker-config.json"
110
+
111
+ # API key: env var → tracker-config.json → linear-config.json (legacy)
112
+ LINEAR_API_KEY="${LINEAR_API_KEY:-$(jq -r '.linear.api_key // empty' "$config" 2>/dev/null || true)}"
113
+ if [[ -z "$LINEAR_API_KEY" ]]; then
114
+ local legacy_config="${HOME}/.shipwright/linear-config.json"
115
+ if [[ -f "$legacy_config" ]]; then
116
+ LINEAR_API_KEY="${LINEAR_API_KEY:-$(jq -r '.api_key // empty' "$legacy_config" 2>/dev/null || true)}"
117
+ fi
118
+ fi
119
+
120
+ LINEAR_TEAM_ID="${LINEAR_TEAM_ID:-$(jq -r '.linear.team_id // empty' "$config" 2>/dev/null || true)}"
121
+ LINEAR_PROJECT_ID="${LINEAR_PROJECT_ID:-$(jq -r '.linear.project_id // empty' "$config" 2>/dev/null || true)}"
122
+
123
+ # Status IDs from config (empty if not configured)
124
+ STATUS_BACKLOG="${LINEAR_STATUS_BACKLOG:-$(jq -r '.linear.statuses.backlog // empty' "$config" 2>/dev/null || true)}"
125
+ STATUS_TODO="${LINEAR_STATUS_TODO:-$(jq -r '.linear.statuses.todo // empty' "$config" 2>/dev/null || true)}"
126
+ STATUS_IN_PROGRESS="${LINEAR_STATUS_IN_PROGRESS:-$(jq -r '.linear.statuses.in_progress // empty' "$config" 2>/dev/null || true)}"
127
+ STATUS_IN_REVIEW="${LINEAR_STATUS_IN_REVIEW:-$(jq -r '.linear.statuses.in_review // empty' "$config" 2>/dev/null || true)}"
128
+ STATUS_DONE="${LINEAR_STATUS_DONE:-$(jq -r '.linear.statuses.done // empty' "$config" 2>/dev/null || true)}"
129
+
130
+ LINEAR_API="https://api.linear.app/graphql"
131
+
132
+ # Auto-discover statuses from API if not explicitly configured
133
+ provider_discover_statuses
134
+ }
135
+
136
+ # ─── Linear GraphQL Helper ────────────────────────────────────────────────
137
+
138
+ linear_graphql() {
139
+ local query="$1"
140
+ local variables="${2:-{}}"
141
+
142
+ local payload
143
+ payload=$(jq -n --arg q "$query" --argjson v "$variables" '{query: $q, variables: $v}')
144
+
145
+ local response
146
+ response=$(curl -sf -X POST "$LINEAR_API" \
147
+ -H "Authorization: $LINEAR_API_KEY" \
148
+ -H "Content-Type: application/json" \
149
+ -d "$payload" 2>&1) || {
150
+ error "Linear API request failed"
151
+ echo "$response" >&2
152
+ return 1
153
+ }
154
+
155
+ # Check for GraphQL errors
156
+ local errors
157
+ errors=$(echo "$response" | jq -r '.errors[0].message // empty' 2>/dev/null || true)
158
+ if [[ -n "$errors" ]]; then
159
+ error "Linear API error: $errors"
160
+ return 1
161
+ fi
162
+
163
+ echo "$response"
164
+ }
165
+
166
+ # ─── Update Linear Issue Status ────────────────────────────────────────────
167
+
168
+ linear_update_status() {
169
+ local issue_id="$1"
170
+ local state_id="$2"
171
+
172
+ # Skip if no state ID provided
173
+ [[ -z "$state_id" ]] && return 0
174
+
175
+ local query='mutation($issueId: String!, $stateId: String!) {
176
+ issueUpdate(id: $issueId, input: { stateId: $stateId }) {
177
+ issue { id identifier }
178
+ }
179
+ }'
180
+
181
+ local vars
182
+ vars=$(jq -n --arg issueId "$issue_id" --arg stateId "$state_id" \
183
+ '{issueId: $issueId, stateId: $stateId}')
184
+
185
+ linear_graphql "$query" "$vars" >/dev/null
186
+ }
187
+
188
+ # ─── Add Comment to Linear Issue ───────────────────────────────────────────
189
+
190
+ linear_add_comment() {
191
+ local issue_id="$1"
192
+ local body="$2"
193
+
194
+ local query='mutation($issueId: String!, $body: String!) {
195
+ commentCreate(input: { issueId: $issueId, body: $body }) {
196
+ comment { id }
197
+ }
198
+ }'
199
+
200
+ local vars
201
+ vars=$(jq -n --arg issueId "$issue_id" --arg body "$body" \
202
+ '{issueId: $issueId, body: $body}')
203
+
204
+ linear_graphql "$query" "$vars" >/dev/null
205
+ }
206
+
207
+ # ─── Attach PR Link to Linear Issue ───────────────────────────────────────
208
+
209
+ linear_attach_pr() {
210
+ local issue_id="$1"
211
+ local pr_url="$2"
212
+ local pr_title="${3:-Pull Request}"
213
+
214
+ local body
215
+ body=$(printf "PR linked: [%s](%s)" "$pr_title" "$pr_url")
216
+ linear_add_comment "$issue_id" "$body"
217
+ }
218
+
219
+ # ─── Find Linear Issue ID from GitHub Issue Body ──────────────────────────
220
+
221
+ find_linear_id() {
222
+ local gh_issue="$1"
223
+
224
+ if [[ -z "$gh_issue" ]]; then
225
+ return 0
226
+ fi
227
+
228
+ gh issue view "$gh_issue" --json body --jq '.body' 2>/dev/null | \
229
+ grep -o 'Linear ID:.*' | sed 's/.*\*\*Linear ID:\*\* //' | tr -d '[:space:]' || true
230
+ }
231
+
232
+ # ─── Main Provider Entry Point ─────────────────────────────────────────────
233
+ # Called by tracker_notify() in sw-tracker.sh
234
+
235
+ provider_notify() {
236
+ local event="$1"
237
+ local gh_issue="${2:-}"
238
+ local detail="${3:-}"
239
+
240
+ provider_load_config
241
+
242
+ # Silently skip if no API key
243
+ [[ -z "$LINEAR_API_KEY" ]] && return 0
244
+
245
+ # Find the linked Linear issue
246
+ local linear_id=""
247
+ if [[ -n "$gh_issue" ]]; then
248
+ linear_id=$(find_linear_id "$gh_issue")
249
+ fi
250
+ [[ -z "$linear_id" ]] && return 0
251
+
252
+ case "$event" in
253
+ spawn|started)
254
+ linear_update_status "$linear_id" "$STATUS_IN_PROGRESS" || true
255
+ linear_add_comment "$linear_id" "Pipeline started for GitHub issue #${gh_issue}" || true
256
+ ;;
257
+ stage_complete)
258
+ # detail format: "stage_id|duration|description"
259
+ local stage_id duration stage_desc
260
+ stage_id=$(echo "$detail" | cut -d'|' -f1)
261
+ duration=$(echo "$detail" | cut -d'|' -f2)
262
+ stage_desc=$(echo "$detail" | cut -d'|' -f3)
263
+ linear_add_comment "$linear_id" "Stage **${stage_id}** complete (${duration}) — ${stage_desc}" || true
264
+ ;;
265
+ stage_failed)
266
+ # detail format: "stage_id|error_context"
267
+ local stage_id error_ctx
268
+ stage_id=$(echo "$detail" | cut -d'|' -f1)
269
+ error_ctx=$(echo "$detail" | cut -d'|' -f2-)
270
+ linear_add_comment "$linear_id" "Stage **${stage_id}** failed\n\n\`\`\`\n${error_ctx}\n\`\`\`" || true
271
+ ;;
272
+ review|pr-created)
273
+ linear_update_status "$linear_id" "$STATUS_IN_REVIEW" || true
274
+ if [[ -n "$detail" ]]; then
275
+ linear_attach_pr "$linear_id" "$detail" "PR for #${gh_issue}" || true
276
+ fi
277
+ ;;
278
+ completed|done)
279
+ linear_update_status "$linear_id" "$STATUS_DONE" || true
280
+ linear_add_comment "$linear_id" "Pipeline completed for GitHub issue #${gh_issue}" || true
281
+ ;;
282
+ failed)
283
+ local msg="Pipeline failed for GitHub issue #${gh_issue}"
284
+ if [[ -n "$detail" ]]; then
285
+ msg="${msg}\n\nDetails:\n${detail}"
286
+ fi
287
+ linear_add_comment "$linear_id" "$msg" || true
288
+ ;;
289
+ esac
290
+
291
+ emit_event "tracker.notify" "provider=linear" "event=$event" "github_issue=$gh_issue"
292
+ }