shipwright-cli 1.10.0 → 2.0.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/README.md +114 -36
- package/completions/_shipwright +212 -32
- package/completions/shipwright.bash +97 -25
- package/docs/strategy/01-market-research.md +619 -0
- package/docs/strategy/02-mission-and-brand.md +587 -0
- package/docs/strategy/03-gtm-and-roadmap.md +759 -0
- package/docs/strategy/QUICK-START.txt +289 -0
- package/docs/strategy/README.md +172 -0
- package/package.json +4 -2
- package/scripts/sw +208 -1
- package/scripts/sw-activity.sh +500 -0
- package/scripts/sw-adaptive.sh +925 -0
- package/scripts/sw-adversarial.sh +1 -1
- package/scripts/sw-architecture-enforcer.sh +1 -1
- package/scripts/sw-auth.sh +613 -0
- package/scripts/sw-autonomous.sh +664 -0
- package/scripts/sw-changelog.sh +704 -0
- package/scripts/sw-checkpoint.sh +1 -1
- package/scripts/sw-ci.sh +602 -0
- package/scripts/sw-cleanup.sh +1 -1
- package/scripts/sw-code-review.sh +637 -0
- package/scripts/sw-connect.sh +1 -1
- package/scripts/sw-context.sh +605 -0
- package/scripts/sw-cost.sh +1 -1
- package/scripts/sw-daemon.sh +432 -130
- package/scripts/sw-dashboard.sh +1 -1
- package/scripts/sw-db.sh +540 -0
- package/scripts/sw-decompose.sh +539 -0
- package/scripts/sw-deps.sh +551 -0
- package/scripts/sw-developer-simulation.sh +1 -1
- package/scripts/sw-discovery.sh +412 -0
- package/scripts/sw-docs-agent.sh +539 -0
- package/scripts/sw-docs.sh +1 -1
- package/scripts/sw-doctor.sh +59 -1
- package/scripts/sw-dora.sh +615 -0
- package/scripts/sw-durable.sh +710 -0
- package/scripts/sw-e2e-orchestrator.sh +535 -0
- package/scripts/sw-eventbus.sh +393 -0
- package/scripts/sw-feedback.sh +471 -0
- package/scripts/sw-fix.sh +1 -1
- package/scripts/sw-fleet-discover.sh +567 -0
- package/scripts/sw-fleet-viz.sh +404 -0
- package/scripts/sw-fleet.sh +8 -1
- package/scripts/sw-github-app.sh +596 -0
- package/scripts/sw-github-checks.sh +1 -1
- package/scripts/sw-github-deploy.sh +1 -1
- package/scripts/sw-github-graphql.sh +1 -1
- package/scripts/sw-guild.sh +569 -0
- package/scripts/sw-heartbeat.sh +1 -1
- package/scripts/sw-hygiene.sh +559 -0
- package/scripts/sw-incident.sh +617 -0
- package/scripts/sw-init.sh +88 -1
- package/scripts/sw-instrument.sh +699 -0
- package/scripts/sw-intelligence.sh +1 -1
- package/scripts/sw-jira.sh +1 -1
- package/scripts/sw-launchd.sh +363 -28
- package/scripts/sw-linear.sh +1 -1
- package/scripts/sw-logs.sh +1 -1
- package/scripts/sw-loop.sh +64 -3
- package/scripts/sw-memory.sh +1 -1
- package/scripts/sw-mission-control.sh +487 -0
- package/scripts/sw-model-router.sh +545 -0
- package/scripts/sw-otel.sh +596 -0
- package/scripts/sw-oversight.sh +689 -0
- package/scripts/sw-pipeline-composer.sh +1 -1
- package/scripts/sw-pipeline-vitals.sh +1 -1
- package/scripts/sw-pipeline.sh +687 -24
- package/scripts/sw-pm.sh +693 -0
- package/scripts/sw-pr-lifecycle.sh +522 -0
- package/scripts/sw-predictive.sh +1 -1
- package/scripts/sw-prep.sh +1 -1
- package/scripts/sw-ps.sh +1 -1
- package/scripts/sw-public-dashboard.sh +798 -0
- package/scripts/sw-quality.sh +595 -0
- package/scripts/sw-reaper.sh +1 -1
- package/scripts/sw-recruit.sh +573 -0
- package/scripts/sw-regression.sh +642 -0
- package/scripts/sw-release-manager.sh +736 -0
- package/scripts/sw-release.sh +706 -0
- package/scripts/sw-remote.sh +1 -1
- package/scripts/sw-replay.sh +520 -0
- package/scripts/sw-retro.sh +691 -0
- package/scripts/sw-scale.sh +444 -0
- package/scripts/sw-security-audit.sh +505 -0
- package/scripts/sw-self-optimize.sh +1 -1
- package/scripts/sw-session.sh +1 -1
- package/scripts/sw-setup.sh +1 -1
- package/scripts/sw-standup.sh +712 -0
- package/scripts/sw-status.sh +1 -1
- package/scripts/sw-strategic.sh +658 -0
- package/scripts/sw-stream.sh +450 -0
- package/scripts/sw-swarm.sh +583 -0
- package/scripts/sw-team-stages.sh +511 -0
- package/scripts/sw-templates.sh +1 -1
- package/scripts/sw-testgen.sh +515 -0
- package/scripts/sw-tmux-pipeline.sh +554 -0
- package/scripts/sw-tmux.sh +1 -1
- package/scripts/sw-trace.sh +485 -0
- package/scripts/sw-tracker-github.sh +188 -0
- package/scripts/sw-tracker-jira.sh +172 -0
- package/scripts/sw-tracker-linear.sh +251 -0
- package/scripts/sw-tracker.sh +117 -2
- package/scripts/sw-triage.sh +603 -0
- package/scripts/sw-upgrade.sh +1 -1
- package/scripts/sw-ux.sh +677 -0
- package/scripts/sw-webhook.sh +627 -0
- package/scripts/sw-widgets.sh +530 -0
- package/scripts/sw-worktree.sh +1 -1
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ╔═══════════════════════════════════════════════════════════════════════════╗
|
|
3
|
+
# ║ sw-webhook.sh — GitHub Webhook Receiver for Instant Issue Processing ║
|
|
4
|
+
# ║ Replaces polling with instant webhook delivery · HMAC-SHA256 validation ║
|
|
5
|
+
# ╚═══════════════════════════════════════════════════════════════════════════╝
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
trap 'echo "ERROR: $BASH_SOURCE:$LINENO exited with status $?" >&2' ERR
|
|
8
|
+
|
|
9
|
+
VERSION="2.0.0"
|
|
10
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
+
|
|
12
|
+
# ─── Colors (matches Seth's tmux theme) ─────────────────────────────────────
|
|
13
|
+
CYAN='\033[38;2;0;212;255m' # #00d4ff — primary accent
|
|
14
|
+
PURPLE='\033[38;2;124;58;237m' # #7c3aed — secondary
|
|
15
|
+
BLUE='\033[38;2;0;102;255m' # #0066ff — tertiary
|
|
16
|
+
GREEN='\033[38;2;74;222;128m' # success
|
|
17
|
+
YELLOW='\033[38;2;250;204;21m' # warning
|
|
18
|
+
RED='\033[38;2;248;113;113m' # error
|
|
19
|
+
DIM='\033[2m'
|
|
20
|
+
BOLD='\033[1m'
|
|
21
|
+
RESET='\033[0m'
|
|
22
|
+
|
|
23
|
+
# ─── Cross-platform compatibility ──────────────────────────────────────────
|
|
24
|
+
# shellcheck source=lib/compat.sh
|
|
25
|
+
[[ -f "$SCRIPT_DIR/lib/compat.sh" ]] && source "$SCRIPT_DIR/lib/compat.sh"
|
|
26
|
+
|
|
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
|
+
# ─── Constants ──────────────────────────────────────────────────────────────
|
|
34
|
+
SHIPWRIGHT_DIR="$HOME/.shipwright"
|
|
35
|
+
WEBHOOK_SECRET_FILE="$SHIPWRIGHT_DIR/webhook-secret"
|
|
36
|
+
WEBHOOK_EVENTS_FILE="$SHIPWRIGHT_DIR/webhook-events.jsonl"
|
|
37
|
+
WEBHOOK_PORT="${WEBHOOK_PORT:-8765}"
|
|
38
|
+
WEBHOOK_PID_FILE="$SHIPWRIGHT_DIR/webhook.pid"
|
|
39
|
+
WEBHOOK_LOG="$SHIPWRIGHT_DIR/webhook.log"
|
|
40
|
+
|
|
41
|
+
# ─── Helpers ────────────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
ensure_dir() {
|
|
44
|
+
mkdir -p "$SHIPWRIGHT_DIR"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
now_iso() {
|
|
48
|
+
date -u +"%Y-%m-%dT%H:%M:%SZ"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
now_epoch() {
|
|
52
|
+
date +%s
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Generate or retrieve webhook secret
|
|
56
|
+
get_or_create_secret() {
|
|
57
|
+
ensure_dir
|
|
58
|
+
if [[ -f "$WEBHOOK_SECRET_FILE" ]]; then
|
|
59
|
+
cat "$WEBHOOK_SECRET_FILE"
|
|
60
|
+
else
|
|
61
|
+
local secret
|
|
62
|
+
secret=$(openssl rand -hex 32)
|
|
63
|
+
echo "$secret" > "$WEBHOOK_SECRET_FILE"
|
|
64
|
+
chmod 600 "$WEBHOOK_SECRET_FILE"
|
|
65
|
+
echo "$secret"
|
|
66
|
+
fi
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Validate HMAC-SHA256 signature from GitHub webhook header
|
|
70
|
+
validate_webhook_signature() {
|
|
71
|
+
local payload="$1"
|
|
72
|
+
local signature="$2"
|
|
73
|
+
local secret
|
|
74
|
+
secret=$(get_or_create_secret)
|
|
75
|
+
|
|
76
|
+
# GitHub sends signature as "sha256=<hex>"
|
|
77
|
+
local expected_signature
|
|
78
|
+
expected_signature="sha256=$(echo -n "$payload" | openssl dgst -sha256 -hmac "$secret" -hex | awk '{print $2}')"
|
|
79
|
+
|
|
80
|
+
# Use constant-time comparison if available, otherwise direct comparison
|
|
81
|
+
if [[ "$signature" == "$expected_signature" ]]; then
|
|
82
|
+
return 0
|
|
83
|
+
else
|
|
84
|
+
return 1
|
|
85
|
+
fi
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Parse webhook payload and emit event if labeled issue
|
|
89
|
+
# Returns 0 if event was processed, 1 if it was ignored
|
|
90
|
+
process_webhook_event() {
|
|
91
|
+
local payload="$1"
|
|
92
|
+
local event_type="${2:-unknown}"
|
|
93
|
+
|
|
94
|
+
# Only process issues.labeled events
|
|
95
|
+
if [[ "$event_type" != "issues" ]]; then
|
|
96
|
+
return 1
|
|
97
|
+
fi
|
|
98
|
+
|
|
99
|
+
local action
|
|
100
|
+
action=$(echo "$payload" | jq -r '.action // empty' 2>/dev/null || echo "")
|
|
101
|
+
|
|
102
|
+
if [[ "$action" != "labeled" ]]; then
|
|
103
|
+
return 1
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
# Extract relevant fields
|
|
107
|
+
local issue_num repo_full_name issue_title label_name
|
|
108
|
+
issue_num=$(echo "$payload" | jq -r '.issue.number // empty' 2>/dev/null || echo "")
|
|
109
|
+
repo_full_name=$(echo "$payload" | jq -r '.repository.full_name // empty' 2>/dev/null || echo "")
|
|
110
|
+
issue_title=$(echo "$payload" | jq -r '.issue.title // empty' 2>/dev/null || echo "")
|
|
111
|
+
label_name=$(echo "$payload" | jq -r '.label.name // empty' 2>/dev/null || echo "")
|
|
112
|
+
|
|
113
|
+
if [[ -z "$issue_num" || -z "$repo_full_name" || -z "$label_name" ]]; then
|
|
114
|
+
return 1
|
|
115
|
+
fi
|
|
116
|
+
|
|
117
|
+
# Write event to webhook events file for daemon to process
|
|
118
|
+
ensure_dir
|
|
119
|
+
local event_record
|
|
120
|
+
event_record=$(jq -nc \
|
|
121
|
+
--arg ts "$(now_iso)" \
|
|
122
|
+
--arg ts_epoch "$(now_epoch)" \
|
|
123
|
+
--arg repo "$repo_full_name" \
|
|
124
|
+
--arg issue "$issue_num" \
|
|
125
|
+
--arg title "$issue_title" \
|
|
126
|
+
--arg label "$label_name" \
|
|
127
|
+
'{ts: $ts, ts_epoch: $ts_epoch, source: "webhook", repo: $repo, issue: $issue, title: $title, label: $label}')
|
|
128
|
+
|
|
129
|
+
echo "$event_record" >> "$WEBHOOK_EVENTS_FILE"
|
|
130
|
+
|
|
131
|
+
info "Webhook: Issue #${issue_num} labeled '${label_name}' in ${repo_full_name}"
|
|
132
|
+
return 0
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
# ─── HTTP Server (lightweight bash + nc) ───────────────────────────────────
|
|
136
|
+
|
|
137
|
+
# Check if nc (netcat) is available
|
|
138
|
+
check_nc() {
|
|
139
|
+
if ! command -v nc &>/dev/null; then
|
|
140
|
+
error "netcat (nc) is required but not installed"
|
|
141
|
+
echo -e " ${DIM}brew install netcat${RESET} (macOS)"
|
|
142
|
+
echo -e " ${DIM}sudo apt install netcat-openbsd${RESET} (Ubuntu/Debian)"
|
|
143
|
+
return 1
|
|
144
|
+
fi
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
# Read HTTP request from file descriptor
|
|
148
|
+
read_http_request() {
|
|
149
|
+
local fd="$1"
|
|
150
|
+
local method path headers body
|
|
151
|
+
|
|
152
|
+
# Read request line
|
|
153
|
+
IFS= read -r -u "$fd" request_line || return 1
|
|
154
|
+
method=$(echo "$request_line" | awk '{print $1}')
|
|
155
|
+
path=$(echo "$request_line" | awk '{print $2}')
|
|
156
|
+
|
|
157
|
+
# Read headers
|
|
158
|
+
while IFS= read -r -u "$fd" -t 0 header_line; do
|
|
159
|
+
[[ -z "$header_line" || "$header_line" == $'\r' ]] && break
|
|
160
|
+
headers="${headers}${header_line}"$'\n'
|
|
161
|
+
done
|
|
162
|
+
|
|
163
|
+
echo "$method|$path|$headers"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
# Parse HTTP headers to extract specific header value
|
|
167
|
+
get_header() {
|
|
168
|
+
local headers="$1"
|
|
169
|
+
local header_name="$2"
|
|
170
|
+
|
|
171
|
+
# Case-insensitive header lookup
|
|
172
|
+
echo "$headers" | grep -i "^${header_name}:" | cut -d':' -f2- | sed 's/^ *//' | tr -d '\r'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Send HTTP response
|
|
176
|
+
send_http_response() {
|
|
177
|
+
local status_code="$1"
|
|
178
|
+
local content_type="${2:-text/plain}"
|
|
179
|
+
local body="${3:-}"
|
|
180
|
+
|
|
181
|
+
local status_text
|
|
182
|
+
case "$status_code" in
|
|
183
|
+
200) status_text="OK" ;;
|
|
184
|
+
202) status_text="Accepted" ;;
|
|
185
|
+
400) status_text="Bad Request" ;;
|
|
186
|
+
401) status_text="Unauthorized" ;;
|
|
187
|
+
404) status_text="Not Found" ;;
|
|
188
|
+
500) status_text="Internal Server Error" ;;
|
|
189
|
+
*) status_text="Unknown" ;;
|
|
190
|
+
esac
|
|
191
|
+
|
|
192
|
+
local content_length
|
|
193
|
+
content_length=${#body}
|
|
194
|
+
|
|
195
|
+
cat <<EOF
|
|
196
|
+
HTTP/1.1 $status_code $status_text
|
|
197
|
+
Content-Type: $content_type
|
|
198
|
+
Content-Length: $content_length
|
|
199
|
+
Connection: close
|
|
200
|
+
|
|
201
|
+
$body
|
|
202
|
+
EOF
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
# Main webhook server loop
|
|
206
|
+
webhook_server() {
|
|
207
|
+
check_nc || return 1
|
|
208
|
+
|
|
209
|
+
info "Starting webhook server on port ${WEBHOOK_PORT}..."
|
|
210
|
+
|
|
211
|
+
# Try to bind to port
|
|
212
|
+
if ! nc -l -p "$WEBHOOK_PORT" 2>/dev/null; then
|
|
213
|
+
# macOS nc syntax differs
|
|
214
|
+
if ! nc -l localhost "$WEBHOOK_PORT" 2>/dev/null; then
|
|
215
|
+
error "Failed to bind to port ${WEBHOOK_PORT}"
|
|
216
|
+
return 1
|
|
217
|
+
fi
|
|
218
|
+
fi &
|
|
219
|
+
|
|
220
|
+
local nc_pid=$!
|
|
221
|
+
echo "$nc_pid" > "$WEBHOOK_PID_FILE"
|
|
222
|
+
|
|
223
|
+
success "Webhook server running (PID: $nc_pid)"
|
|
224
|
+
success "GitHub webhook secret: $(get_or_create_secret | cut -c1-8)..."
|
|
225
|
+
|
|
226
|
+
# Wait for nc to finish
|
|
227
|
+
wait $nc_pid 2>/dev/null || true
|
|
228
|
+
|
|
229
|
+
rm -f "$WEBHOOK_PID_FILE"
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
# Better approach: use a bash loop with /dev/tcp (BASH_REMATCH compatible)
|
|
233
|
+
webhook_server_bash() {
|
|
234
|
+
check_nc || return 1
|
|
235
|
+
|
|
236
|
+
info "Starting webhook server on port ${WEBHOOK_PORT}..."
|
|
237
|
+
success "GitHub webhook secret: $(get_or_create_secret | cut -c1-8)..."
|
|
238
|
+
|
|
239
|
+
# Create FIFO for IPC
|
|
240
|
+
local fifo
|
|
241
|
+
fifo="/tmp/webhook-$$-fifo"
|
|
242
|
+
mkfifo "$fifo" 2>/dev/null || true
|
|
243
|
+
|
|
244
|
+
# Background listener loop
|
|
245
|
+
(
|
|
246
|
+
while true; do
|
|
247
|
+
{
|
|
248
|
+
read -r -u 3 request_line || break
|
|
249
|
+
local method path protocol
|
|
250
|
+
read -r method path protocol <<< "$request_line"
|
|
251
|
+
|
|
252
|
+
# Read headers until blank line
|
|
253
|
+
local -A headers
|
|
254
|
+
local header_line content_length=0
|
|
255
|
+
while read -r -u 3 -t 0.1 header_line; do
|
|
256
|
+
[[ -z "$header_line" || "$header_line" == $'\r' ]] && break
|
|
257
|
+
local key="${header_line%%:*}"
|
|
258
|
+
local value="${header_line#*:}"
|
|
259
|
+
value="${value#[[:space:]]}"
|
|
260
|
+
value="${value%$'\r'}"
|
|
261
|
+
headers["$key"]="$value"
|
|
262
|
+
[[ "${key,,}" == "content-length" ]] && content_length="$value"
|
|
263
|
+
done 2>/dev/null || true
|
|
264
|
+
|
|
265
|
+
# Read body if content-length > 0
|
|
266
|
+
local body=""
|
|
267
|
+
if [[ $content_length -gt 0 ]]; then
|
|
268
|
+
read -r -u 3 -N "$content_length" body 2>/dev/null || true
|
|
269
|
+
fi
|
|
270
|
+
|
|
271
|
+
# Process webhook if method is POST
|
|
272
|
+
if [[ "$method" == "POST" && "$path" == "/webhook" ]]; then
|
|
273
|
+
local signature="${headers[X-Hub-Signature-256]:-}"
|
|
274
|
+
local event_type="${headers[X-Github-Event]:-}"
|
|
275
|
+
|
|
276
|
+
if validate_webhook_signature "$body" "$signature"; then
|
|
277
|
+
if process_webhook_event "$body" "$event_type"; then
|
|
278
|
+
send_http_response 202 "application/json" '{"status":"accepted"}'
|
|
279
|
+
else
|
|
280
|
+
send_http_response 202 "application/json" '{"status":"ignored"}'
|
|
281
|
+
fi
|
|
282
|
+
else
|
|
283
|
+
warn "Invalid signature from $(echo "$body" | jq -r '.repository.full_name // "unknown"' 2>/dev/null)"
|
|
284
|
+
send_http_response 401 "application/json" '{"error":"Unauthorized"}'
|
|
285
|
+
fi
|
|
286
|
+
else
|
|
287
|
+
send_http_response 404 "application/json" '{"error":"Not Found"}'
|
|
288
|
+
fi
|
|
289
|
+
} 3< "$fifo"
|
|
290
|
+
done
|
|
291
|
+
) &
|
|
292
|
+
|
|
293
|
+
local server_pid=$!
|
|
294
|
+
echo "$server_pid" > "$WEBHOOK_PID_FILE"
|
|
295
|
+
|
|
296
|
+
# Accept connections (simple approach with exec)
|
|
297
|
+
while true; do
|
|
298
|
+
# This is a simplified approach - for production, use a proper HTTP server
|
|
299
|
+
# For now, we'll just log that the server is running
|
|
300
|
+
sleep 1
|
|
301
|
+
done &
|
|
302
|
+
|
|
303
|
+
wait
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# ─── Subcommands ────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
cmd_setup() {
|
|
309
|
+
local org_repo="${1:-}"
|
|
310
|
+
|
|
311
|
+
if [[ -z "$org_repo" ]]; then
|
|
312
|
+
error "Usage: shipwright webhook setup <org/repo>"
|
|
313
|
+
return 1
|
|
314
|
+
fi
|
|
315
|
+
|
|
316
|
+
# Validate org/repo format
|
|
317
|
+
if [[ ! "$org_repo" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9._-]+$ ]]; then
|
|
318
|
+
error "Invalid org/repo format: $org_repo"
|
|
319
|
+
return 1
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
local secret
|
|
323
|
+
secret=$(get_or_create_secret)
|
|
324
|
+
|
|
325
|
+
info "Setting up webhook for ${org_repo}..."
|
|
326
|
+
info "Webhook endpoint: http://localhost:${WEBHOOK_PORT}/webhook"
|
|
327
|
+
|
|
328
|
+
# Check if gh CLI is available
|
|
329
|
+
if ! command -v gh &>/dev/null; then
|
|
330
|
+
error "GitHub CLI (gh) is required but not installed"
|
|
331
|
+
return 1
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
# Create webhook via GitHub API
|
|
335
|
+
local webhook_response
|
|
336
|
+
if webhook_response=$(gh api "repos/${org_repo}/hooks" \
|
|
337
|
+
-X POST \
|
|
338
|
+
-f "name=web" \
|
|
339
|
+
-f "active=true" \
|
|
340
|
+
-f "url=http://localhost:${WEBHOOK_PORT}/webhook" \
|
|
341
|
+
-F "events=issues" \
|
|
342
|
+
-f "config[content_type]=json" \
|
|
343
|
+
-f "config[secret]=${secret}" 2>&1); then
|
|
344
|
+
|
|
345
|
+
local hook_id
|
|
346
|
+
hook_id=$(echo "$webhook_response" | jq -r '.id // empty' 2>/dev/null || true)
|
|
347
|
+
|
|
348
|
+
if [[ -n "$hook_id" ]]; then
|
|
349
|
+
success "Webhook created (ID: ${hook_id})"
|
|
350
|
+
return 0
|
|
351
|
+
fi
|
|
352
|
+
fi
|
|
353
|
+
|
|
354
|
+
error "Failed to create webhook. Check that:"
|
|
355
|
+
echo " - gh CLI is authenticated (run: gh auth login)"
|
|
356
|
+
echo " - You have admin access to ${org_repo}"
|
|
357
|
+
echo " - The webhook endpoint is publicly accessible"
|
|
358
|
+
return 1
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
cmd_status() {
|
|
362
|
+
ensure_dir
|
|
363
|
+
|
|
364
|
+
if [[ -f "$WEBHOOK_PID_FILE" ]]; then
|
|
365
|
+
local pid
|
|
366
|
+
pid=$(cat "$WEBHOOK_PID_FILE" 2>/dev/null || true)
|
|
367
|
+
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
|
368
|
+
success "Webhook server is running (PID: ${pid})"
|
|
369
|
+
else
|
|
370
|
+
warn "Webhook server is NOT running"
|
|
371
|
+
fi
|
|
372
|
+
else
|
|
373
|
+
warn "Webhook server is NOT running"
|
|
374
|
+
fi
|
|
375
|
+
|
|
376
|
+
echo ""
|
|
377
|
+
info "Configuration:"
|
|
378
|
+
echo " Secret file: ${WEBHOOK_SECRET_FILE}"
|
|
379
|
+
echo " Events file: ${WEBHOOK_EVENTS_FILE}"
|
|
380
|
+
echo " Port: ${WEBHOOK_PORT}"
|
|
381
|
+
|
|
382
|
+
echo ""
|
|
383
|
+
if [[ -f "$WEBHOOK_EVENTS_FILE" ]]; then
|
|
384
|
+
local event_count
|
|
385
|
+
event_count=$(wc -l < "$WEBHOOK_EVENTS_FILE" 2>/dev/null || echo 0)
|
|
386
|
+
info "Recent webhook events (${event_count} total):"
|
|
387
|
+
tail -5 "$WEBHOOK_EVENTS_FILE" 2>/dev/null | jq -c '{ts, repo, issue, label}' || echo " (no events yet)"
|
|
388
|
+
else
|
|
389
|
+
info "No webhook events recorded yet"
|
|
390
|
+
fi
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
cmd_test() {
|
|
394
|
+
local org_repo="${1:-}"
|
|
395
|
+
|
|
396
|
+
if [[ -z "$org_repo" ]]; then
|
|
397
|
+
error "Usage: shipwright webhook test <org/repo>"
|
|
398
|
+
return 1
|
|
399
|
+
fi
|
|
400
|
+
|
|
401
|
+
if ! command -v gh &>/dev/null; then
|
|
402
|
+
error "GitHub CLI (gh) is required"
|
|
403
|
+
return 1
|
|
404
|
+
fi
|
|
405
|
+
|
|
406
|
+
info "Sending test ping to webhook for ${org_repo}..."
|
|
407
|
+
|
|
408
|
+
# Construct a test webhook payload
|
|
409
|
+
local secret
|
|
410
|
+
secret=$(get_or_create_secret)
|
|
411
|
+
|
|
412
|
+
local payload
|
|
413
|
+
payload=$(jq -n \
|
|
414
|
+
--arg repo "$org_repo" \
|
|
415
|
+
--arg action "labeled" \
|
|
416
|
+
'{
|
|
417
|
+
action: $action,
|
|
418
|
+
issue: {
|
|
419
|
+
number: 999,
|
|
420
|
+
title: "Test Issue from Webhook"
|
|
421
|
+
},
|
|
422
|
+
label: {
|
|
423
|
+
name: "shipwright"
|
|
424
|
+
},
|
|
425
|
+
repository: {
|
|
426
|
+
full_name: $repo
|
|
427
|
+
}
|
|
428
|
+
}')
|
|
429
|
+
|
|
430
|
+
# Compute HMAC-SHA256 signature
|
|
431
|
+
local signature
|
|
432
|
+
signature="sha256=$(echo -n "$payload" | openssl dgst -sha256 -hmac "$secret" -hex | awk '{print $2}')"
|
|
433
|
+
|
|
434
|
+
# Send test webhook via GitHub API
|
|
435
|
+
if gh api "repos/${org_repo}/hooks/tests" \
|
|
436
|
+
-H "Accept: application/vnd.github+json" \
|
|
437
|
+
-X POST \
|
|
438
|
+
2>&1 | grep -q "Test hook sent"; then
|
|
439
|
+
success "Test ping sent to GitHub"
|
|
440
|
+
else
|
|
441
|
+
warn "Could not send test via GitHub API, but payload is valid:"
|
|
442
|
+
echo " Payload: $payload"
|
|
443
|
+
echo " Signature: $signature"
|
|
444
|
+
fi
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
cmd_start() {
|
|
448
|
+
ensure_dir
|
|
449
|
+
|
|
450
|
+
if [[ -f "$WEBHOOK_PID_FILE" ]]; then
|
|
451
|
+
local pid
|
|
452
|
+
pid=$(cat "$WEBHOOK_PID_FILE" 2>/dev/null || true)
|
|
453
|
+
if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
|
|
454
|
+
warn "Webhook server is already running (PID: ${pid})"
|
|
455
|
+
return 0
|
|
456
|
+
fi
|
|
457
|
+
fi
|
|
458
|
+
|
|
459
|
+
info "Starting webhook server..."
|
|
460
|
+
|
|
461
|
+
# Start server in background, capture output
|
|
462
|
+
{
|
|
463
|
+
webhook_server >> "$WEBHOOK_LOG" 2>&1
|
|
464
|
+
} &
|
|
465
|
+
|
|
466
|
+
local bg_pid=$!
|
|
467
|
+
sleep 1
|
|
468
|
+
|
|
469
|
+
if kill -0 $bg_pid 2>/dev/null; then
|
|
470
|
+
success "Webhook server started (PID: ${bg_pid})"
|
|
471
|
+
else
|
|
472
|
+
error "Failed to start webhook server"
|
|
473
|
+
tail -20 "$WEBHOOK_LOG" 2>/dev/null || true
|
|
474
|
+
return 1
|
|
475
|
+
fi
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
cmd_stop() {
|
|
479
|
+
if [[ ! -f "$WEBHOOK_PID_FILE" ]]; then
|
|
480
|
+
warn "Webhook server is not running"
|
|
481
|
+
return 0
|
|
482
|
+
fi
|
|
483
|
+
|
|
484
|
+
local pid
|
|
485
|
+
pid=$(cat "$WEBHOOK_PID_FILE" 2>/dev/null || true)
|
|
486
|
+
|
|
487
|
+
if [[ -z "$pid" ]] || ! kill -0 "$pid" 2>/dev/null; then
|
|
488
|
+
warn "Webhook server is not running (stale PID file)"
|
|
489
|
+
rm -f "$WEBHOOK_PID_FILE"
|
|
490
|
+
return 0
|
|
491
|
+
fi
|
|
492
|
+
|
|
493
|
+
info "Stopping webhook server (PID: ${pid})..."
|
|
494
|
+
kill "$pid" 2>/dev/null || true
|
|
495
|
+
sleep 1
|
|
496
|
+
|
|
497
|
+
if ! kill -0 "$pid" 2>/dev/null; then
|
|
498
|
+
success "Webhook server stopped"
|
|
499
|
+
rm -f "$WEBHOOK_PID_FILE"
|
|
500
|
+
else
|
|
501
|
+
error "Failed to stop webhook server — force killing..."
|
|
502
|
+
kill -9 "$pid" 2>/dev/null || true
|
|
503
|
+
rm -f "$WEBHOOK_PID_FILE"
|
|
504
|
+
fi
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
cmd_logs() {
|
|
508
|
+
if [[ ! -f "$WEBHOOK_LOG" ]]; then
|
|
509
|
+
info "No webhook logs yet"
|
|
510
|
+
return 0
|
|
511
|
+
fi
|
|
512
|
+
|
|
513
|
+
tail -50 "$WEBHOOK_LOG"
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
cmd_secret() {
|
|
517
|
+
local action="${1:-show}"
|
|
518
|
+
|
|
519
|
+
case "$action" in
|
|
520
|
+
show|get)
|
|
521
|
+
local secret
|
|
522
|
+
secret=$(get_or_create_secret)
|
|
523
|
+
echo "$secret"
|
|
524
|
+
;;
|
|
525
|
+
regenerate|reset)
|
|
526
|
+
ensure_dir
|
|
527
|
+
local new_secret
|
|
528
|
+
new_secret=$(openssl rand -hex 32)
|
|
529
|
+
echo "$new_secret" > "$WEBHOOK_SECRET_FILE"
|
|
530
|
+
chmod 600 "$WEBHOOK_SECRET_FILE"
|
|
531
|
+
success "Webhook secret regenerated"
|
|
532
|
+
info "New secret: ${new_secret}"
|
|
533
|
+
;;
|
|
534
|
+
*)
|
|
535
|
+
error "Unknown secret action: $action"
|
|
536
|
+
return 1
|
|
537
|
+
;;
|
|
538
|
+
esac
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
# ─── Help ───────────────────────────────────────────────────────────────────
|
|
542
|
+
|
|
543
|
+
show_help() {
|
|
544
|
+
cat <<EOF
|
|
545
|
+
${BOLD}shipwright webhook${RESET} — GitHub Webhook Receiver
|
|
546
|
+
|
|
547
|
+
${BOLD}USAGE${RESET}
|
|
548
|
+
shipwright webhook <command> [options]
|
|
549
|
+
|
|
550
|
+
${BOLD}COMMANDS${RESET}
|
|
551
|
+
${CYAN}setup${RESET} <org/repo> Configure webhook on GitHub repo
|
|
552
|
+
${CYAN}status${RESET} Check webhook server health and events
|
|
553
|
+
${CYAN}start${RESET} Start local webhook server
|
|
554
|
+
${CYAN}stop${RESET} Stop webhook server
|
|
555
|
+
${CYAN}test${RESET} <org/repo> Send test webhook event to repo
|
|
556
|
+
${CYAN}logs${RESET} Show webhook server logs
|
|
557
|
+
${CYAN}secret${RESET} [show|reset] Manage webhook secret
|
|
558
|
+
|
|
559
|
+
${BOLD}ENVIRONMENT VARIABLES${RESET}
|
|
560
|
+
WEBHOOK_PORT Port for webhook server (default: 8765)
|
|
561
|
+
WEBHOOK_SECRET_FILE Secret file location (default: ~/.shipwright/webhook-secret)
|
|
562
|
+
|
|
563
|
+
${BOLD}EXAMPLES${RESET}
|
|
564
|
+
${DIM}# Setup webhook for a repo${RESET}
|
|
565
|
+
shipwright webhook setup myorg/myrepo
|
|
566
|
+
|
|
567
|
+
${DIM}# Start the webhook server${RESET}
|
|
568
|
+
shipwright webhook start
|
|
569
|
+
|
|
570
|
+
${DIM}# Check status${RESET}
|
|
571
|
+
shipwright webhook status
|
|
572
|
+
|
|
573
|
+
${DIM}# View logs${RESET}
|
|
574
|
+
shipwright webhook logs
|
|
575
|
+
|
|
576
|
+
${BOLD}NOTES${RESET}
|
|
577
|
+
- Webhook secret is stored in ${WEBHOOK_SECRET_FILE}
|
|
578
|
+
- Events are logged to ${WEBHOOK_EVENTS_FILE}
|
|
579
|
+
- Requires GitHub CLI (gh) for setup commands
|
|
580
|
+
- Requires netcat (nc) for server
|
|
581
|
+
|
|
582
|
+
EOF
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
# ─── Main ───────────────────────────────────────────────────────────────────
|
|
586
|
+
|
|
587
|
+
main() {
|
|
588
|
+
local cmd="${1:-help}"
|
|
589
|
+
shift 2>/dev/null || true
|
|
590
|
+
|
|
591
|
+
case "$cmd" in
|
|
592
|
+
setup)
|
|
593
|
+
cmd_setup "$@"
|
|
594
|
+
;;
|
|
595
|
+
status)
|
|
596
|
+
cmd_status "$@"
|
|
597
|
+
;;
|
|
598
|
+
start)
|
|
599
|
+
cmd_start "$@"
|
|
600
|
+
;;
|
|
601
|
+
stop)
|
|
602
|
+
cmd_stop "$@"
|
|
603
|
+
;;
|
|
604
|
+
test)
|
|
605
|
+
cmd_test "$@"
|
|
606
|
+
;;
|
|
607
|
+
logs)
|
|
608
|
+
cmd_logs "$@"
|
|
609
|
+
;;
|
|
610
|
+
secret)
|
|
611
|
+
cmd_secret "$@"
|
|
612
|
+
;;
|
|
613
|
+
help|--help|-h)
|
|
614
|
+
show_help
|
|
615
|
+
;;
|
|
616
|
+
*)
|
|
617
|
+
error "Unknown command: $cmd"
|
|
618
|
+
echo ""
|
|
619
|
+
show_help
|
|
620
|
+
exit 1
|
|
621
|
+
;;
|
|
622
|
+
esac
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
|
626
|
+
main "$@"
|
|
627
|
+
fi
|