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.
- package/.claude/agents/code-reviewer.md +90 -0
- package/.claude/agents/devops-engineer.md +142 -0
- package/.claude/agents/pipeline-agent.md +80 -0
- package/.claude/agents/shell-script-specialist.md +150 -0
- package/.claude/agents/test-specialist.md +196 -0
- package/.claude/hooks/post-tool-use.sh +38 -0
- package/.claude/hooks/pre-tool-use.sh +25 -0
- package/.claude/hooks/session-started.sh +37 -0
- package/README.md +212 -814
- package/claude-code/CLAUDE.md.shipwright +54 -0
- package/claude-code/hooks/notify-idle.sh +2 -2
- package/claude-code/hooks/session-start.sh +24 -0
- package/claude-code/hooks/task-completed.sh +6 -2
- package/claude-code/settings.json.template +12 -0
- package/dashboard/public/app.js +4422 -0
- package/dashboard/public/index.html +816 -0
- package/dashboard/public/styles.css +4755 -0
- package/dashboard/server.ts +4315 -0
- package/docs/KNOWN-ISSUES.md +18 -10
- package/docs/TIPS.md +38 -26
- package/docs/patterns/README.md +33 -23
- package/package.json +9 -5
- package/scripts/adapters/iterm2-adapter.sh +1 -1
- package/scripts/adapters/tmux-adapter.sh +52 -23
- package/scripts/adapters/wezterm-adapter.sh +26 -14
- package/scripts/lib/compat.sh +200 -0
- package/scripts/lib/helpers.sh +72 -0
- package/scripts/postinstall.mjs +72 -13
- package/scripts/{cct → sw} +109 -21
- package/scripts/sw-adversarial.sh +274 -0
- package/scripts/sw-architecture-enforcer.sh +330 -0
- package/scripts/sw-checkpoint.sh +390 -0
- package/scripts/{cct-cleanup.sh → sw-cleanup.sh} +3 -1
- package/scripts/sw-connect.sh +619 -0
- package/scripts/{cct-cost.sh → sw-cost.sh} +368 -34
- package/scripts/{cct-daemon.sh → sw-daemon.sh} +2217 -204
- package/scripts/sw-dashboard.sh +477 -0
- package/scripts/sw-developer-simulation.sh +252 -0
- package/scripts/sw-docs.sh +635 -0
- package/scripts/sw-doctor.sh +907 -0
- package/scripts/{cct-fix.sh → sw-fix.sh} +10 -6
- package/scripts/{cct-fleet.sh → sw-fleet.sh} +498 -22
- package/scripts/sw-github-checks.sh +521 -0
- package/scripts/sw-github-deploy.sh +533 -0
- package/scripts/sw-github-graphql.sh +972 -0
- package/scripts/sw-heartbeat.sh +293 -0
- package/scripts/{cct-init.sh → sw-init.sh} +144 -11
- package/scripts/sw-intelligence.sh +1196 -0
- package/scripts/sw-jira.sh +643 -0
- package/scripts/sw-launchd.sh +364 -0
- package/scripts/sw-linear.sh +648 -0
- package/scripts/{cct-logs.sh → sw-logs.sh} +72 -2
- package/scripts/{cct-loop.sh → sw-loop.sh} +534 -44
- package/scripts/{cct-memory.sh → sw-memory.sh} +321 -38
- package/scripts/sw-patrol-meta.sh +417 -0
- package/scripts/sw-pipeline-composer.sh +455 -0
- package/scripts/{cct-pipeline.sh → sw-pipeline.sh} +2319 -178
- package/scripts/sw-predictive.sh +820 -0
- package/scripts/{cct-prep.sh → sw-prep.sh} +339 -49
- package/scripts/{cct-ps.sh → sw-ps.sh} +6 -4
- package/scripts/{cct-reaper.sh → sw-reaper.sh} +6 -4
- package/scripts/sw-remote.sh +687 -0
- package/scripts/sw-self-optimize.sh +947 -0
- package/scripts/sw-session.sh +519 -0
- package/scripts/sw-setup.sh +234 -0
- package/scripts/sw-status.sh +605 -0
- package/scripts/{cct-templates.sh → sw-templates.sh} +9 -4
- package/scripts/sw-tmux.sh +591 -0
- package/scripts/sw-tracker-jira.sh +277 -0
- package/scripts/sw-tracker-linear.sh +292 -0
- package/scripts/sw-tracker.sh +409 -0
- package/scripts/{cct-upgrade.sh → sw-upgrade.sh} +103 -46
- package/scripts/{cct-worktree.sh → sw-worktree.sh} +3 -0
- package/templates/pipelines/autonomous.json +27 -5
- package/templates/pipelines/full.json +12 -0
- package/templates/pipelines/standard.json +12 -0
- package/tmux/{claude-teams-overlay.conf → shipwright-overlay.conf} +27 -9
- package/tmux/templates/accessibility.json +34 -0
- package/tmux/templates/api-design.json +35 -0
- package/tmux/templates/architecture.json +1 -0
- package/tmux/templates/bug-fix.json +9 -0
- package/tmux/templates/code-review.json +1 -0
- package/tmux/templates/compliance.json +36 -0
- package/tmux/templates/data-pipeline.json +36 -0
- package/tmux/templates/debt-paydown.json +34 -0
- package/tmux/templates/devops.json +1 -0
- package/tmux/templates/documentation.json +1 -0
- package/tmux/templates/exploration.json +1 -0
- package/tmux/templates/feature-dev.json +1 -0
- package/tmux/templates/full-stack.json +8 -0
- package/tmux/templates/i18n.json +34 -0
- package/tmux/templates/incident-response.json +36 -0
- package/tmux/templates/migration.json +1 -0
- package/tmux/templates/observability.json +35 -0
- package/tmux/templates/onboarding.json +33 -0
- package/tmux/templates/performance.json +35 -0
- package/tmux/templates/refactor.json +1 -0
- package/tmux/templates/release.json +35 -0
- package/tmux/templates/security-audit.json +8 -0
- package/tmux/templates/spike.json +34 -0
- package/tmux/templates/testing.json +1 -0
- package/tmux/tmux.conf +98 -9
- package/scripts/cct-doctor.sh +0 -414
- package/scripts/cct-session.sh +0 -284
- 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
|
+
}
|