shipwright-cli 2.3.1 → 3.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 +95 -28
- package/completions/_shipwright +1 -1
- package/completions/shipwright.bash +3 -8
- package/completions/shipwright.fish +1 -1
- package/config/defaults.json +111 -0
- package/config/event-schema.json +81 -0
- package/config/policy.json +155 -2
- package/config/policy.schema.json +162 -1
- package/dashboard/coverage/coverage-summary.json +14 -0
- package/dashboard/public/index.html +1 -1
- package/dashboard/server.ts +306 -17
- package/dashboard/src/components/charts/bar.test.ts +79 -0
- package/dashboard/src/components/charts/donut.test.ts +68 -0
- package/dashboard/src/components/charts/pipeline-rail.test.ts +117 -0
- package/dashboard/src/components/charts/sparkline.test.ts +125 -0
- package/dashboard/src/core/api.test.ts +309 -0
- package/dashboard/src/core/helpers.test.ts +301 -0
- package/dashboard/src/core/router.test.ts +307 -0
- package/dashboard/src/core/router.ts +7 -0
- package/dashboard/src/core/sse.test.ts +144 -0
- package/dashboard/src/views/metrics.test.ts +186 -0
- package/dashboard/src/views/overview.test.ts +173 -0
- package/dashboard/src/views/pipelines.test.ts +183 -0
- package/dashboard/src/views/team.test.ts +253 -0
- package/dashboard/vitest.config.ts +14 -5
- package/docs/TIPS.md +1 -1
- package/docs/patterns/README.md +1 -1
- package/package.json +15 -5
- package/scripts/adapters/docker-deploy.sh +1 -1
- package/scripts/adapters/tmux-adapter.sh +11 -1
- package/scripts/adapters/wezterm-adapter.sh +1 -1
- package/scripts/check-version-consistency.sh +1 -1
- package/scripts/lib/architecture.sh +126 -0
- package/scripts/lib/bootstrap.sh +75 -0
- package/scripts/lib/compat.sh +89 -6
- package/scripts/lib/config.sh +91 -0
- package/scripts/lib/daemon-adaptive.sh +3 -3
- package/scripts/lib/daemon-dispatch.sh +39 -16
- package/scripts/lib/daemon-health.sh +1 -1
- package/scripts/lib/daemon-patrol.sh +24 -12
- package/scripts/lib/daemon-poll.sh +37 -25
- package/scripts/lib/daemon-state.sh +115 -23
- package/scripts/lib/daemon-triage.sh +30 -8
- package/scripts/lib/fleet-failover.sh +63 -0
- package/scripts/lib/helpers.sh +30 -6
- package/scripts/lib/pipeline-detection.sh +2 -2
- package/scripts/lib/pipeline-github.sh +9 -9
- package/scripts/lib/pipeline-intelligence.sh +85 -35
- package/scripts/lib/pipeline-quality-checks.sh +16 -16
- package/scripts/lib/pipeline-quality.sh +1 -1
- package/scripts/lib/pipeline-stages.sh +242 -28
- package/scripts/lib/pipeline-state.sh +40 -4
- package/scripts/lib/test-helpers.sh +247 -0
- package/scripts/postinstall.mjs +3 -11
- package/scripts/sw +10 -4
- package/scripts/sw-activity.sh +1 -11
- package/scripts/sw-adaptive.sh +109 -85
- package/scripts/sw-adversarial.sh +4 -14
- package/scripts/sw-architecture-enforcer.sh +1 -11
- package/scripts/sw-auth.sh +8 -17
- package/scripts/sw-autonomous.sh +111 -49
- package/scripts/sw-changelog.sh +1 -11
- package/scripts/sw-checkpoint.sh +144 -20
- package/scripts/sw-ci.sh +2 -12
- package/scripts/sw-cleanup.sh +13 -17
- package/scripts/sw-code-review.sh +16 -36
- package/scripts/sw-connect.sh +5 -12
- package/scripts/sw-context.sh +9 -26
- package/scripts/sw-cost.sh +6 -16
- package/scripts/sw-daemon.sh +75 -70
- package/scripts/sw-dashboard.sh +57 -17
- package/scripts/sw-db.sh +506 -15
- package/scripts/sw-decompose.sh +1 -11
- package/scripts/sw-deps.sh +15 -25
- package/scripts/sw-developer-simulation.sh +1 -11
- package/scripts/sw-discovery.sh +112 -30
- package/scripts/sw-doc-fleet.sh +7 -17
- package/scripts/sw-docs-agent.sh +6 -16
- package/scripts/sw-docs.sh +4 -12
- package/scripts/sw-doctor.sh +134 -43
- package/scripts/sw-dora.sh +11 -19
- package/scripts/sw-durable.sh +35 -52
- package/scripts/sw-e2e-orchestrator.sh +11 -27
- package/scripts/sw-eventbus.sh +115 -115
- package/scripts/sw-evidence.sh +748 -0
- package/scripts/sw-feedback.sh +3 -13
- package/scripts/sw-fix.sh +2 -20
- package/scripts/sw-fleet-discover.sh +1 -11
- package/scripts/sw-fleet-viz.sh +10 -18
- package/scripts/sw-fleet.sh +13 -17
- package/scripts/sw-github-app.sh +6 -16
- package/scripts/sw-github-checks.sh +1 -11
- package/scripts/sw-github-deploy.sh +1 -11
- package/scripts/sw-github-graphql.sh +2 -12
- package/scripts/sw-guild.sh +1 -11
- package/scripts/sw-heartbeat.sh +49 -12
- package/scripts/sw-hygiene.sh +45 -43
- package/scripts/sw-incident.sh +284 -67
- package/scripts/sw-init.sh +35 -37
- package/scripts/sw-instrument.sh +1 -11
- package/scripts/sw-intelligence.sh +362 -51
- package/scripts/sw-jira.sh +5 -14
- package/scripts/sw-launchd.sh +2 -12
- package/scripts/sw-linear.sh +8 -17
- package/scripts/sw-logs.sh +4 -12
- package/scripts/sw-loop.sh +641 -90
- package/scripts/sw-memory.sh +243 -17
- package/scripts/sw-mission-control.sh +2 -12
- package/scripts/sw-model-router.sh +73 -34
- package/scripts/sw-otel.sh +11 -21
- package/scripts/sw-oversight.sh +1 -11
- package/scripts/sw-patrol-meta.sh +5 -11
- package/scripts/sw-pipeline-composer.sh +7 -17
- package/scripts/sw-pipeline-vitals.sh +1 -11
- package/scripts/sw-pipeline.sh +478 -122
- package/scripts/sw-pm.sh +2 -12
- package/scripts/sw-pr-lifecycle.sh +203 -29
- package/scripts/sw-predictive.sh +16 -22
- package/scripts/sw-prep.sh +6 -16
- package/scripts/sw-ps.sh +1 -11
- package/scripts/sw-public-dashboard.sh +2 -12
- package/scripts/sw-quality.sh +77 -10
- package/scripts/sw-reaper.sh +1 -11
- package/scripts/sw-recruit.sh +15 -25
- package/scripts/sw-regression.sh +11 -21
- package/scripts/sw-release-manager.sh +19 -28
- package/scripts/sw-release.sh +8 -16
- package/scripts/sw-remote.sh +1 -11
- package/scripts/sw-replay.sh +48 -44
- package/scripts/sw-retro.sh +70 -92
- package/scripts/sw-review-rerun.sh +220 -0
- package/scripts/sw-scale.sh +109 -32
- package/scripts/sw-security-audit.sh +12 -22
- package/scripts/sw-self-optimize.sh +239 -23
- package/scripts/sw-session.sh +3 -13
- package/scripts/sw-setup.sh +8 -18
- package/scripts/sw-standup.sh +5 -15
- package/scripts/sw-status.sh +32 -23
- package/scripts/sw-strategic.sh +129 -13
- package/scripts/sw-stream.sh +1 -11
- package/scripts/sw-swarm.sh +76 -36
- package/scripts/sw-team-stages.sh +10 -20
- package/scripts/sw-templates.sh +4 -14
- package/scripts/sw-testgen.sh +3 -13
- package/scripts/sw-tmux-pipeline.sh +1 -19
- package/scripts/sw-tmux-role-color.sh +0 -10
- package/scripts/sw-tmux-status.sh +3 -11
- package/scripts/sw-tmux.sh +2 -20
- package/scripts/sw-trace.sh +1 -19
- package/scripts/sw-tracker-github.sh +0 -10
- package/scripts/sw-tracker-jira.sh +1 -11
- package/scripts/sw-tracker-linear.sh +1 -11
- package/scripts/sw-tracker.sh +7 -24
- package/scripts/sw-triage.sh +24 -34
- package/scripts/sw-upgrade.sh +5 -23
- package/scripts/sw-ux.sh +1 -19
- package/scripts/sw-webhook.sh +18 -32
- package/scripts/sw-widgets.sh +3 -21
- package/scripts/sw-worktree.sh +11 -27
- package/scripts/update-homebrew-sha.sh +67 -0
- package/templates/pipelines/tdd.json +72 -0
- package/scripts/sw-pipeline.sh.mock +0 -7
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
[[ -n "${_DAEMON_STATE_LOADED:-}" ]] && return 0
|
|
4
4
|
_DAEMON_STATE_LOADED=1
|
|
5
5
|
|
|
6
|
+
# SQLite persistence (DB as primary read path)
|
|
7
|
+
_DAEMON_STATE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
+
[[ -f "${_DAEMON_STATE_DIR}/../sw-db.sh" ]] && source "${_DAEMON_STATE_DIR}/../sw-db.sh"
|
|
9
|
+
|
|
6
10
|
daemon_log() {
|
|
7
11
|
local level="$1"
|
|
8
12
|
shift
|
|
@@ -58,8 +62,8 @@ notify() {
|
|
|
58
62
|
-d "$payload" "$SLACK_WEBHOOK" >/dev/null 2>&1 || true
|
|
59
63
|
fi
|
|
60
64
|
|
|
61
|
-
# Custom webhook (env var SHIPWRIGHT_WEBHOOK_URL
|
|
62
|
-
local _webhook_url="${SHIPWRIGHT_WEBHOOK_URL
|
|
65
|
+
# Custom webhook (env var SHIPWRIGHT_WEBHOOK_URL)
|
|
66
|
+
local _webhook_url="${SHIPWRIGHT_WEBHOOK_URL:-}"
|
|
63
67
|
if [[ -n "$_webhook_url" ]]; then
|
|
64
68
|
local payload
|
|
65
69
|
payload=$(jq -n \
|
|
@@ -123,7 +127,7 @@ daemon_preflight_auth_check() {
|
|
|
123
127
|
|
|
124
128
|
# gh auth check
|
|
125
129
|
if [[ "${NO_GITHUB:-false}" != "true" ]]; then
|
|
126
|
-
if ! gh auth status
|
|
130
|
+
if ! gh auth status >/dev/null 2>&1; then
|
|
127
131
|
daemon_log ERROR "GitHub auth check failed — auto-pausing daemon"
|
|
128
132
|
local pause_json
|
|
129
133
|
pause_json=$(jq -n --arg reason "gh_auth_failure" --arg ts "$(now_iso)" \
|
|
@@ -189,7 +193,7 @@ preflight_checks() {
|
|
|
189
193
|
local optional_tools=("tmux" "curl")
|
|
190
194
|
|
|
191
195
|
for tool in "${required_tools[@]}"; do
|
|
192
|
-
if command -v "$tool"
|
|
196
|
+
if command -v "$tool" >/dev/null 2>&1; then
|
|
193
197
|
echo -e " ${GREEN}✓${RESET} $tool"
|
|
194
198
|
else
|
|
195
199
|
echo -e " ${RED}✗${RESET} $tool ${RED}(required)${RESET}"
|
|
@@ -198,7 +202,7 @@ preflight_checks() {
|
|
|
198
202
|
done
|
|
199
203
|
|
|
200
204
|
for tool in "${optional_tools[@]}"; do
|
|
201
|
-
if command -v "$tool"
|
|
205
|
+
if command -v "$tool" >/dev/null 2>&1; then
|
|
202
206
|
echo -e " ${GREEN}✓${RESET} $tool"
|
|
203
207
|
else
|
|
204
208
|
echo -e " ${DIM}○${RESET} $tool ${DIM}(optional — some features disabled)${RESET}"
|
|
@@ -207,7 +211,7 @@ preflight_checks() {
|
|
|
207
211
|
|
|
208
212
|
# 2. Git state
|
|
209
213
|
echo ""
|
|
210
|
-
if git rev-parse --is-inside-work-tree
|
|
214
|
+
if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
211
215
|
echo -e " ${GREEN}✓${RESET} Inside git repo"
|
|
212
216
|
else
|
|
213
217
|
echo -e " ${RED}✗${RESET} Not inside a git repository"
|
|
@@ -215,7 +219,7 @@ preflight_checks() {
|
|
|
215
219
|
fi
|
|
216
220
|
|
|
217
221
|
# Check base branch exists
|
|
218
|
-
if git rev-parse --verify "$BASE_BRANCH"
|
|
222
|
+
if git rev-parse --verify "$BASE_BRANCH" >/dev/null 2>&1; then
|
|
219
223
|
echo -e " ${GREEN}✓${RESET} Base branch: $BASE_BRANCH"
|
|
220
224
|
else
|
|
221
225
|
echo -e " ${RED}✗${RESET} Base branch not found: $BASE_BRANCH"
|
|
@@ -224,7 +228,7 @@ preflight_checks() {
|
|
|
224
228
|
|
|
225
229
|
# 3. GitHub auth (required for daemon — it needs to poll issues)
|
|
226
230
|
if [[ "$NO_GITHUB" != "true" ]]; then
|
|
227
|
-
if gh auth status
|
|
231
|
+
if gh auth status >/dev/null 2>&1; then
|
|
228
232
|
echo -e " ${GREEN}✓${RESET} GitHub authenticated"
|
|
229
233
|
else
|
|
230
234
|
echo -e " ${RED}✗${RESET} GitHub not authenticated (required for daemon)"
|
|
@@ -286,15 +290,48 @@ atomic_write_state() {
|
|
|
286
290
|
}
|
|
287
291
|
}
|
|
288
292
|
|
|
293
|
+
# Sync active_jobs from state JSON to DB (dual-write, best-effort)
|
|
294
|
+
_sync_state_to_db() {
|
|
295
|
+
local state_json="$1"
|
|
296
|
+
[[ -z "$state_json" ]] && return 0
|
|
297
|
+
if ! type db_save_job >/dev/null 2>&1 || ! db_available 2>/dev/null; then
|
|
298
|
+
return 0
|
|
299
|
+
fi
|
|
300
|
+
local start_epoch job_id
|
|
301
|
+
while IFS= read -r job; do
|
|
302
|
+
[[ -z "$job" || "$job" == "null" ]] && continue
|
|
303
|
+
local issue title pid worktree branch template goal started_at
|
|
304
|
+
issue=$(echo "$job" | jq -r '.issue // 0' 2>/dev/null)
|
|
305
|
+
title=$(echo "$job" | jq -r '.title // ""' 2>/dev/null)
|
|
306
|
+
pid=$(echo "$job" | jq -r '.pid // 0' 2>/dev/null)
|
|
307
|
+
worktree=$(echo "$job" | jq -r '.worktree // ""' 2>/dev/null)
|
|
308
|
+
branch=$(echo "$job" | jq -r '.branch // ""' 2>/dev/null)
|
|
309
|
+
template=$(echo "$job" | jq -r '.template // "autonomous"' 2>/dev/null)
|
|
310
|
+
goal=$(echo "$job" | jq -r '.goal // ""' 2>/dev/null)
|
|
311
|
+
started_at=$(echo "$job" | jq -r '.started_at // ""' 2>/dev/null)
|
|
312
|
+
if [[ -z "$issue" || "$issue" == "0" ]] || [[ ! "$issue" =~ ^[0-9]+$ ]]; then
|
|
313
|
+
continue
|
|
314
|
+
fi
|
|
315
|
+
start_epoch=0
|
|
316
|
+
if [[ -n "$started_at" ]]; then
|
|
317
|
+
start_epoch=$(TZ=UTC date -j -f "%Y-%m-%dT%H:%M:%SZ" "$started_at" +%s 2>/dev/null || date -d "$started_at" +%s 2>/dev/null || echo "0")
|
|
318
|
+
fi
|
|
319
|
+
[[ -z "$start_epoch" ]] && start_epoch=0
|
|
320
|
+
job_id="daemon-${issue}-${start_epoch}"
|
|
321
|
+
db_save_job "$job_id" "$issue" "$title" "$pid" "$worktree" "$branch" "$template" "$goal" 2>/dev/null || true
|
|
322
|
+
done < <(echo "$state_json" | jq -c '.active_jobs[]? // empty' 2>/dev/null || true)
|
|
323
|
+
}
|
|
324
|
+
|
|
289
325
|
# Locked read-modify-write: prevents TOCTOU race on state file.
|
|
290
326
|
# Usage: locked_state_update '.queued += [42]'
|
|
291
327
|
# The jq expression is applied to the current state file atomically.
|
|
328
|
+
# Dual-write: also syncs active_jobs to DB when available.
|
|
292
329
|
locked_state_update() {
|
|
293
330
|
local jq_expr="$1"
|
|
294
331
|
shift
|
|
295
332
|
local lock_file="${STATE_FILE}.lock"
|
|
296
333
|
(
|
|
297
|
-
if command -v flock
|
|
334
|
+
if command -v flock >/dev/null 2>&1; then
|
|
298
335
|
flock -w 5 200 2>/dev/null || {
|
|
299
336
|
daemon_log ERROR "locked_state_update: lock acquisition timed out — aborting"
|
|
300
337
|
return 1
|
|
@@ -309,6 +346,7 @@ locked_state_update() {
|
|
|
309
346
|
daemon_log ERROR "locked_state_update: atomic_write_state failed"
|
|
310
347
|
return 1
|
|
311
348
|
}
|
|
349
|
+
_sync_state_to_db "$tmp" 2>/dev/null || true
|
|
312
350
|
) 200>"$lock_file"
|
|
313
351
|
}
|
|
314
352
|
|
|
@@ -343,7 +381,7 @@ init_state() {
|
|
|
343
381
|
}')
|
|
344
382
|
local lock_file="${STATE_FILE}.lock"
|
|
345
383
|
(
|
|
346
|
-
if command -v flock
|
|
384
|
+
if command -v flock >/dev/null 2>&1; then
|
|
347
385
|
flock -w 5 200 2>/dev/null || {
|
|
348
386
|
daemon_log ERROR "init_state: lock acquisition timed out"
|
|
349
387
|
return 1
|
|
@@ -358,6 +396,11 @@ init_state() {
|
|
|
358
396
|
--arg started "$(now_iso)" \
|
|
359
397
|
'.pid = ($pid | tonumber) | .started_at = $started'
|
|
360
398
|
fi
|
|
399
|
+
|
|
400
|
+
# Ensure DB schema is initialized when available
|
|
401
|
+
if type migrate_schema >/dev/null 2>&1 && db_available 2>/dev/null; then
|
|
402
|
+
migrate_schema 2>/dev/null || true
|
|
403
|
+
fi
|
|
361
404
|
}
|
|
362
405
|
|
|
363
406
|
update_state_field() {
|
|
@@ -369,13 +412,15 @@ update_state_field() {
|
|
|
369
412
|
# ─── Inflight Check ─────────────────────────────────────────────────────────
|
|
370
413
|
|
|
371
414
|
daemon_is_inflight() {
|
|
372
|
-
local
|
|
415
|
+
local issue_key="$1"
|
|
416
|
+
local issue_num="$issue_key"
|
|
417
|
+
[[ "$issue_key" == *:* ]] && issue_num="${issue_key##*:}"
|
|
373
418
|
|
|
374
419
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
375
420
|
return 1
|
|
376
421
|
fi
|
|
377
422
|
|
|
378
|
-
# Check active_jobs
|
|
423
|
+
# Check active_jobs (stored with numeric .issue)
|
|
379
424
|
local active_match
|
|
380
425
|
active_match=$(jq -r --argjson num "$issue_num" \
|
|
381
426
|
'.active_jobs[] | select(.issue == $num) | .issue' \
|
|
@@ -384,10 +429,10 @@ daemon_is_inflight() {
|
|
|
384
429
|
return 0
|
|
385
430
|
fi
|
|
386
431
|
|
|
387
|
-
# Check queued
|
|
432
|
+
# Check queued (stores full key e.g. "owner/repo:42" or "42")
|
|
388
433
|
local queued_match
|
|
389
|
-
queued_match=$(jq -r --
|
|
390
|
-
'.queued[] | select(. == $
|
|
434
|
+
queued_match=$(jq -r --arg key "$issue_key" \
|
|
435
|
+
'.queued[] | select(. == $key)' \
|
|
391
436
|
"$STATE_FILE" 2>/dev/null || true)
|
|
392
437
|
if [[ -n "$queued_match" ]]; then
|
|
393
438
|
return 0
|
|
@@ -417,7 +462,7 @@ locked_get_active_count() {
|
|
|
417
462
|
local count
|
|
418
463
|
count=$(
|
|
419
464
|
(
|
|
420
|
-
if command -v flock
|
|
465
|
+
if command -v flock >/dev/null 2>&1; then
|
|
421
466
|
flock -w 5 200 2>/dev/null || {
|
|
422
467
|
daemon_log WARN "locked_get_active_count: lock timeout — returning MAX_PARALLEL as safe default" >&2
|
|
423
468
|
echo "$MAX_PARALLEL"
|
|
@@ -433,13 +478,30 @@ locked_get_active_count() {
|
|
|
433
478
|
# ─── Queue Management ───────────────────────────────────────────────────────
|
|
434
479
|
|
|
435
480
|
enqueue_issue() {
|
|
436
|
-
local
|
|
437
|
-
locked_state_update --
|
|
438
|
-
'.queued += [$
|
|
439
|
-
|
|
481
|
+
local issue_key="$1"
|
|
482
|
+
locked_state_update --arg key "$issue_key" \
|
|
483
|
+
'.queued += [$key] | .queued |= unique'
|
|
484
|
+
if type db_enqueue_issue >/dev/null 2>&1; then
|
|
485
|
+
db_enqueue_issue "$issue_key" 2>/dev/null || true
|
|
486
|
+
fi
|
|
487
|
+
daemon_log INFO "Queued issue ${issue_key} (at capacity)"
|
|
440
488
|
}
|
|
441
489
|
|
|
442
490
|
dequeue_next() {
|
|
491
|
+
# Try DB first when available
|
|
492
|
+
if type db_dequeue_next >/dev/null 2>&1 && db_available 2>/dev/null; then
|
|
493
|
+
local next
|
|
494
|
+
next=$(db_dequeue_next 2>/dev/null || true)
|
|
495
|
+
if [[ -n "$next" ]]; then
|
|
496
|
+
# Also update JSON file for backward compat
|
|
497
|
+
if [[ -f "$STATE_FILE" ]]; then
|
|
498
|
+
locked_state_update --arg key "$next" '.queued = [.queued[] | select(. != $key)]'
|
|
499
|
+
fi
|
|
500
|
+
echo "$next"
|
|
501
|
+
return
|
|
502
|
+
fi
|
|
503
|
+
fi
|
|
504
|
+
|
|
443
505
|
if [[ ! -f "$STATE_FILE" ]]; then
|
|
444
506
|
return
|
|
445
507
|
fi
|
|
@@ -447,8 +509,10 @@ dequeue_next() {
|
|
|
447
509
|
local next
|
|
448
510
|
next=$(jq -r '.queued[0] // empty' "$STATE_FILE" 2>/dev/null || true)
|
|
449
511
|
if [[ -n "$next" ]]; then
|
|
450
|
-
# Remove from queue (locked to prevent race with enqueue)
|
|
451
512
|
locked_state_update '.queued = .queued[1:]'
|
|
513
|
+
if type db_remove_from_queue >/dev/null 2>&1; then
|
|
514
|
+
db_remove_from_queue "$next" 2>/dev/null || true
|
|
515
|
+
fi
|
|
452
516
|
echo "$next"
|
|
453
517
|
fi
|
|
454
518
|
}
|
|
@@ -496,6 +560,22 @@ untrack_priority_job() {
|
|
|
496
560
|
|
|
497
561
|
# ─── Distributed Issue Claiming ───────────────────────────────────────────
|
|
498
562
|
|
|
563
|
+
# Verify we have exclusive claim: exactly one claimed:* label matching our machine
|
|
564
|
+
_verify_claim_exclusive() {
|
|
565
|
+
local issue_num="$1" machine_name="$2"
|
|
566
|
+
local claimed_labels
|
|
567
|
+
claimed_labels=$(gh issue view "$issue_num" --json labels --jq \
|
|
568
|
+
'[.labels[].name | select(startswith("claimed:"))]' 2>/dev/null || echo "[]")
|
|
569
|
+
local count
|
|
570
|
+
count=$(echo "$claimed_labels" | jq 'length' 2>/dev/null || echo "0")
|
|
571
|
+
if [[ "$count" != "1" ]]; then
|
|
572
|
+
return 1 # Competing claims (multiple or none)
|
|
573
|
+
fi
|
|
574
|
+
local sole_claim
|
|
575
|
+
sole_claim=$(echo "$claimed_labels" | jq -r '.[0]' 2>/dev/null || echo "")
|
|
576
|
+
[[ "$sole_claim" == "claimed:${machine_name}" ]]
|
|
577
|
+
}
|
|
578
|
+
|
|
499
579
|
claim_issue() {
|
|
500
580
|
local issue_num="$1"
|
|
501
581
|
local machine_name="$2"
|
|
@@ -509,9 +589,15 @@ claim_issue() {
|
|
|
509
589
|
-d "$(jq -n --argjson issue "$issue_num" --arg machine "$machine_name" \
|
|
510
590
|
'{issue: $issue, machine: $machine}')" 2>/dev/null || echo "")
|
|
511
591
|
|
|
512
|
-
if [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == true'
|
|
592
|
+
if [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == true' >/dev/null 2>&1; then
|
|
593
|
+
# VERIFY: re-read labels, ensure only our claim exists
|
|
594
|
+
if ! _verify_claim_exclusive "$issue_num" "$machine_name"; then
|
|
595
|
+
daemon_log INFO "Issue #${issue_num} claim race lost (competing claim) — removing our label"
|
|
596
|
+
gh issue edit "$issue_num" --remove-label "claimed:${machine_name}" 2>/dev/null || true
|
|
597
|
+
return 1
|
|
598
|
+
fi
|
|
513
599
|
return 0
|
|
514
|
-
elif [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == false'
|
|
600
|
+
elif [[ -n "$resp" ]] && echo "$resp" | jq -e '.approved == false' >/dev/null 2>&1; then
|
|
515
601
|
local claimed_by
|
|
516
602
|
claimed_by=$(echo "$resp" | jq -r '.claimed_by // "another machine"')
|
|
517
603
|
daemon_log INFO "Issue #${issue_num} claimed by ${claimed_by} (via dashboard)"
|
|
@@ -530,6 +616,12 @@ claim_issue() {
|
|
|
530
616
|
fi
|
|
531
617
|
|
|
532
618
|
gh issue edit "$issue_num" --add-label "claimed:${machine_name}" 2>/dev/null || return 1
|
|
619
|
+
# VERIFY: re-read labels, ensure only our claim exists
|
|
620
|
+
if ! _verify_claim_exclusive "$issue_num" "$machine_name"; then
|
|
621
|
+
daemon_log INFO "Issue #${issue_num} claim race lost (competing claim) — removing our label"
|
|
622
|
+
gh issue edit "$issue_num" --remove-label "claimed:${machine_name}" 2>/dev/null || true
|
|
623
|
+
return 1
|
|
624
|
+
fi
|
|
533
625
|
return 0
|
|
534
626
|
}
|
|
535
627
|
|
|
@@ -21,7 +21,7 @@ triage_score_issue() {
|
|
|
21
21
|
issue_body=$(echo "$issue_json" | jq -r '.body // ""')
|
|
22
22
|
|
|
23
23
|
# ── Intelligence-powered triage (if enabled) ──
|
|
24
|
-
if [[ "${INTELLIGENCE_ENABLED:-false}" == "true" ]] && type intelligence_analyze_issue
|
|
24
|
+
if [[ "${INTELLIGENCE_ENABLED:-false}" == "true" ]] && type intelligence_analyze_issue >/dev/null 2>&1; then
|
|
25
25
|
daemon_log INFO "Intelligence: using AI triage (intelligence enabled)" >&2
|
|
26
26
|
local analysis
|
|
27
27
|
analysis=$(intelligence_analyze_issue "$issue_json" 2>/dev/null || echo "")
|
|
@@ -143,10 +143,14 @@ triage_score_issue() {
|
|
|
143
143
|
# Check if this issue blocks others (search issue references)
|
|
144
144
|
if [[ "$NO_GITHUB" != "true" ]]; then
|
|
145
145
|
local mentions
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
146
|
+
local repo_nwo
|
|
147
|
+
repo_nwo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null || echo "")
|
|
148
|
+
if [[ -n "$repo_nwo" ]]; then
|
|
149
|
+
mentions=$(gh api "repos/${repo_nwo}/issues/${issue_num}/timeline" --paginate -q '
|
|
150
|
+
[.[] | select(.event == "cross-referenced") | .source.issue.body // ""] |
|
|
151
|
+
map(select(test("blocked by #'"${issue_num}"'|depends on #'"${issue_num}"'"; "i"))) | length
|
|
152
|
+
' 2>/dev/null || echo "0")
|
|
153
|
+
fi
|
|
150
154
|
mentions=${mentions:-0}
|
|
151
155
|
if [[ "$mentions" -gt 0 ]]; then
|
|
152
156
|
dep_score=15
|
|
@@ -212,7 +216,7 @@ select_pipeline_template() {
|
|
|
212
216
|
fi
|
|
213
217
|
|
|
214
218
|
# ── Intelligence-composed pipeline (if enabled) ──
|
|
215
|
-
if [[ "${COMPOSER_ENABLED:-false}" == "true" ]] && type composer_create_pipeline
|
|
219
|
+
if [[ "${COMPOSER_ENABLED:-false}" == "true" ]] && type composer_create_pipeline >/dev/null 2>&1; then
|
|
216
220
|
daemon_log INFO "Intelligence: using AI pipeline composition (composer enabled)" >&2
|
|
217
221
|
local analysis="${INTELLIGENCE_ANALYSIS:-{}}"
|
|
218
222
|
local repo_context=""
|
|
@@ -301,8 +305,8 @@ select_pipeline_template() {
|
|
|
301
305
|
fi
|
|
302
306
|
|
|
303
307
|
# ── Branch protection escalation (highest priority) ──
|
|
304
|
-
if type gh_branch_protection
|
|
305
|
-
if type _gh_detect_repo
|
|
308
|
+
if type gh_branch_protection >/dev/null 2>&1 && [[ "${NO_GITHUB:-false}" != "true" ]]; then
|
|
309
|
+
if type _gh_detect_repo >/dev/null 2>&1; then
|
|
306
310
|
_gh_detect_repo 2>/dev/null || true
|
|
307
311
|
fi
|
|
308
312
|
local gh_owner="${GH_OWNER:-}" gh_repo="${GH_REPO:-}"
|
|
@@ -386,6 +390,24 @@ select_pipeline_template() {
|
|
|
386
390
|
fi
|
|
387
391
|
fi
|
|
388
392
|
|
|
393
|
+
# ── Thompson sampling (outcome-based learning, when DB available) ──
|
|
394
|
+
if type thompson_select_template >/dev/null 2>&1; then
|
|
395
|
+
local _complexity="medium"
|
|
396
|
+
[[ "$score" -ge 70 ]] && _complexity="low"
|
|
397
|
+
[[ "$score" -lt 40 ]] && _complexity="high"
|
|
398
|
+
local _thompson_result
|
|
399
|
+
_thompson_result=$(thompson_select_template "$_complexity" 2>/dev/null || echo "")
|
|
400
|
+
if [[ -n "${_thompson_result:-}" && "${_thompson_result:-}" != "standard" ]]; then
|
|
401
|
+
daemon_log INFO "Thompson sampling: $_thompson_result (complexity=$_complexity)" >&2
|
|
402
|
+
echo "$_thompson_result"
|
|
403
|
+
return
|
|
404
|
+
fi
|
|
405
|
+
if [[ -n "${_thompson_result:-}" ]]; then
|
|
406
|
+
echo "$_thompson_result"
|
|
407
|
+
return
|
|
408
|
+
fi
|
|
409
|
+
fi
|
|
410
|
+
|
|
389
411
|
# ── Learned template weights ──
|
|
390
412
|
local _tw_file="${HOME}/.shipwright/optimization/template-weights.json"
|
|
391
413
|
if [[ -f "$_tw_file" ]]; then
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# fleet-failover.sh — Re-queue work from offline fleet machines
|
|
2
|
+
# When a machine goes offline, release its claimed issues so they can be picked up again.
|
|
3
|
+
# Source from daemon-poll or sw-fleet. Works standalone with gh + jq.
|
|
4
|
+
[[ -n "${_FLEET_FAILOVER_LOADED:-}" ]] && return 0
|
|
5
|
+
_FLEET_FAILOVER_LOADED=1
|
|
6
|
+
|
|
7
|
+
fleet_failover_check() {
|
|
8
|
+
local health_file="$HOME/.shipwright/machine-health.json"
|
|
9
|
+
[[ ! -f "$health_file" ]] && return 0
|
|
10
|
+
|
|
11
|
+
[[ "${NO_GITHUB:-false}" == "true" ]] && return 0
|
|
12
|
+
command -v gh >/dev/null 2>&1 || return 0
|
|
13
|
+
command -v jq >/dev/null 2>&1 || return 0
|
|
14
|
+
|
|
15
|
+
# Find offline machines (health file: .[machine_name] = {status, checked_at})
|
|
16
|
+
local offline_machines
|
|
17
|
+
offline_machines=$(jq -r 'to_entries[] | select(.value.status == "offline") | .key' "$health_file" 2>/dev/null)
|
|
18
|
+
[[ -z "$offline_machines" ]] && return 0
|
|
19
|
+
|
|
20
|
+
while IFS= read -r machine; do
|
|
21
|
+
[[ -z "$machine" ]] && continue
|
|
22
|
+
|
|
23
|
+
# Find issues claimed by this offline machine via GitHub label
|
|
24
|
+
local orphaned_issues
|
|
25
|
+
orphaned_issues=$(gh search issues \
|
|
26
|
+
"label:claimed:${machine}" \
|
|
27
|
+
is:open \
|
|
28
|
+
--json number,repository \
|
|
29
|
+
--limit 100 2>/dev/null | jq -r '.[] | "\(.repository.nameWithOwner):\(.number)"' 2>/dev/null)
|
|
30
|
+
[[ -z "$orphaned_issues" ]] && continue
|
|
31
|
+
|
|
32
|
+
while IFS= read -r issue_key; do
|
|
33
|
+
[[ -z "$issue_key" ]] && continue
|
|
34
|
+
|
|
35
|
+
local issue_num="${issue_key##*:}"
|
|
36
|
+
local repo="${issue_key%:*}"
|
|
37
|
+
[[ "$repo" == "$issue_key" ]] && repo=""
|
|
38
|
+
|
|
39
|
+
# Log and emit
|
|
40
|
+
if [[ "$(type -t info 2>/dev/null)" == "function" ]]; then
|
|
41
|
+
info "Failover: re-queuing issue #${issue_num} from offline machine ${machine}"
|
|
42
|
+
fi
|
|
43
|
+
if [[ "$(type -t emit_event 2>/dev/null)" == "function" ]]; then
|
|
44
|
+
emit_event "fleet.failover" "{\"issue\":\"$issue_num\",\"from_machine\":\"$machine\"}"
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# Release the claim (remove label) — idempotent
|
|
48
|
+
if [[ -n "$repo" ]]; then
|
|
49
|
+
gh issue edit "$issue_num" --repo "$repo" --remove-label "claimed:${machine}" 2>/dev/null || true
|
|
50
|
+
else
|
|
51
|
+
gh issue edit "$issue_num" --remove-label "claimed:${machine}" 2>/dev/null || true
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# When running in daemon context: enqueue so we pick it up if we watch this repo
|
|
55
|
+
# In org mode WATCH_MODE=org, enqueue uses owner/repo:num; in repo mode just num
|
|
56
|
+
if [[ -f "${STATE_FILE:-$HOME/.shipwright/daemon-state.json}" ]] && type enqueue_issue >/dev/null 2>&1; then
|
|
57
|
+
local queue_key="$issue_num"
|
|
58
|
+
[[ -n "$repo" ]] && queue_key="${repo}:${issue_num}"
|
|
59
|
+
enqueue_issue "$queue_key" 2>/dev/null || true
|
|
60
|
+
fi
|
|
61
|
+
done <<< "$orphaned_issues"
|
|
62
|
+
done <<< "$offline_machines"
|
|
63
|
+
}
|
package/scripts/lib/helpers.sh
CHANGED
|
@@ -64,7 +64,7 @@ emit_event() {
|
|
|
64
64
|
shift
|
|
65
65
|
|
|
66
66
|
# Try SQLite first (via sw-db.sh's db_add_event)
|
|
67
|
-
if type db_add_event
|
|
67
|
+
if type db_add_event >/dev/null 2>&1; then
|
|
68
68
|
db_add_event "$event_type" "$@" 2>/dev/null || true
|
|
69
69
|
fi
|
|
70
70
|
|
|
@@ -76,7 +76,10 @@ emit_event() {
|
|
|
76
76
|
if [[ "$val" =~ ^-?[0-9]+\.?[0-9]*$ ]]; then
|
|
77
77
|
json_fields="${json_fields},\"${key}\":${val}"
|
|
78
78
|
else
|
|
79
|
-
val="${val
|
|
79
|
+
val="${val//\\/\\\\}" # escape backslashes first
|
|
80
|
+
val="${val//\"/\\\"}" # then quotes
|
|
81
|
+
val="${val//$'\n'/\\n}" # then newlines
|
|
82
|
+
val="${val//$'\t'/\\t}" # then tabs
|
|
80
83
|
json_fields="${json_fields},\"${key}\":\"${val}\""
|
|
81
84
|
fi
|
|
82
85
|
done
|
|
@@ -85,11 +88,20 @@ emit_event() {
|
|
|
85
88
|
# Use flock to prevent concurrent write corruption
|
|
86
89
|
local _lock_file="${EVENTS_FILE}.lock"
|
|
87
90
|
(
|
|
88
|
-
if command -v flock
|
|
91
|
+
if command -v flock >/dev/null 2>&1; then
|
|
89
92
|
flock -w 2 200 2>/dev/null || true
|
|
90
93
|
fi
|
|
91
94
|
echo "$_event_line" >> "$EVENTS_FILE"
|
|
92
95
|
) 200>"$_lock_file"
|
|
96
|
+
|
|
97
|
+
# Optional schema validation (dev mode only)
|
|
98
|
+
if [[ -n "${SHIPWRIGHT_DEV:-}" && -n "${_CONFIG_REPO_DIR:-}" && -f "${_CONFIG_REPO_DIR}/config/event-schema.json" ]]; then
|
|
99
|
+
local known_types
|
|
100
|
+
known_types=$(jq -r '.event_types | keys[]' "${_CONFIG_REPO_DIR}/config/event-schema.json" 2>/dev/null || true)
|
|
101
|
+
if [[ -n "$known_types" ]] && ! echo "$known_types" | grep -qx "$event_type"; then
|
|
102
|
+
echo "WARN: Unknown event type '$event_type'" >&2
|
|
103
|
+
fi
|
|
104
|
+
fi
|
|
93
105
|
}
|
|
94
106
|
|
|
95
107
|
# Rotate a JSONL file to keep it within max_lines.
|
|
@@ -103,9 +115,7 @@ with_retry() {
|
|
|
103
115
|
local attempt=1
|
|
104
116
|
local delay=1
|
|
105
117
|
while [[ "$attempt" -le "$max_attempts" ]]; do
|
|
106
|
-
|
|
107
|
-
return 0
|
|
108
|
-
fi
|
|
118
|
+
"$@" && return 0
|
|
109
119
|
local exit_code=$?
|
|
110
120
|
if [[ "$attempt" -lt "$max_attempts" ]]; then
|
|
111
121
|
warn "Attempt $attempt/$max_attempts failed (exit $exit_code), retrying in ${delay}s..."
|
|
@@ -187,3 +197,17 @@ _sw_github_url() {
|
|
|
187
197
|
repo="$(_sw_github_repo)"
|
|
188
198
|
echo "https://github.com/${repo}"
|
|
189
199
|
}
|
|
200
|
+
|
|
201
|
+
# ─── Network Safe Wrappers (config-aware timeouts) ─────────────────────────────
|
|
202
|
+
# Use SHIPWRIGHT_* env vars if set; otherwise _config_get_int when config.sh is loaded
|
|
203
|
+
# Usage: _curl_safe [curl args...] | _gh_safe [gh args...]
|
|
204
|
+
_curl_safe() {
|
|
205
|
+
local ct="${SHIPWRIGHT_CONNECT_TIMEOUT:-$(_config_get_int "network.connect_timeout" 10 2>/dev/null || echo 10)}"
|
|
206
|
+
local mt="${SHIPWRIGHT_MAX_TIME:-$(_config_get_int "network.max_time" 60 2>/dev/null || echo 60)}"
|
|
207
|
+
curl --connect-timeout "$ct" --max-time "$mt" "$@"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_gh_safe() {
|
|
211
|
+
local gh_timeout="${SHIPWRIGHT_GH_TIMEOUT:-$(_config_get_int "network.gh_timeout" 30 2>/dev/null || echo 30)}"
|
|
212
|
+
GH_HTTP_TIMEOUT="$gh_timeout" _timeout "$gh_timeout" gh "$@"
|
|
213
|
+
}
|
|
@@ -101,7 +101,7 @@ detect_project_lang() {
|
|
|
101
101
|
fi
|
|
102
102
|
|
|
103
103
|
# Intelligence: holistic analysis for polyglot/monorepo detection
|
|
104
|
-
if [[ "$detected" == "unknown" ]] && type intelligence_search_memory
|
|
104
|
+
if [[ "$detected" == "unknown" ]] && type intelligence_search_memory >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then
|
|
105
105
|
local config_files
|
|
106
106
|
config_files=$(ls "$root" 2>/dev/null | grep -E '\.(json|toml|yaml|yml|xml|gradle|lock|mod)$' | head -15)
|
|
107
107
|
if [[ -n "$config_files" ]]; then
|
|
@@ -221,7 +221,7 @@ detect_task_type() {
|
|
|
221
221
|
local goal="$1"
|
|
222
222
|
|
|
223
223
|
# Intelligence: Claude classification with confidence score
|
|
224
|
-
if type intelligence_search_memory
|
|
224
|
+
if type intelligence_search_memory >/dev/null 2>&1 && command -v claude >/dev/null 2>&1; then
|
|
225
225
|
local ai_result
|
|
226
226
|
ai_result=$(claude --print --output-format text -p "Classify this task into exactly ONE category. Reply in format: CATEGORY|CONFIDENCE (0-100)
|
|
227
227
|
|
|
@@ -9,14 +9,14 @@ gh_init() {
|
|
|
9
9
|
return
|
|
10
10
|
fi
|
|
11
11
|
|
|
12
|
-
if ! command -v gh
|
|
12
|
+
if ! command -v gh >/dev/null 2>&1; then
|
|
13
13
|
GH_AVAILABLE=false
|
|
14
14
|
warn "gh CLI not found — GitHub integration disabled"
|
|
15
15
|
return
|
|
16
16
|
fi
|
|
17
17
|
|
|
18
18
|
# Check if authenticated
|
|
19
|
-
if ! gh auth status
|
|
19
|
+
if ! gh auth status >/dev/null 2>&1; then
|
|
20
20
|
GH_AVAILABLE=false
|
|
21
21
|
warn "gh not authenticated — GitHub integration disabled"
|
|
22
22
|
return
|
|
@@ -46,7 +46,7 @@ gh_init() {
|
|
|
46
46
|
gh_comment_issue() {
|
|
47
47
|
[[ "$GH_AVAILABLE" != "true" ]] && return 0
|
|
48
48
|
local issue_num="$1" body="$2"
|
|
49
|
-
gh issue comment "$issue_num" --body "$body" 2>/dev/null || true
|
|
49
|
+
_timeout 30 gh issue comment "$issue_num" --body "$body" 2>/dev/null || true
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
# Post a progress-tracking comment and save its ID for later updates
|
|
@@ -56,7 +56,7 @@ gh_post_progress() {
|
|
|
56
56
|
local issue_num="$1" body="$2"
|
|
57
57
|
local result
|
|
58
58
|
result=$(gh api "repos/${REPO_OWNER}/${REPO_NAME}/issues/${issue_num}/comments" \
|
|
59
|
-
-f body="$body" --jq '.id' 2>/dev/null) || true
|
|
59
|
+
-f body="$body" --jq '.id' --timeout 30 2>/dev/null) || true
|
|
60
60
|
if [[ -n "$result" && "$result" != "null" ]]; then
|
|
61
61
|
PROGRESS_COMMENT_ID="$result"
|
|
62
62
|
fi
|
|
@@ -68,7 +68,7 @@ gh_update_progress() {
|
|
|
68
68
|
[[ "$GH_AVAILABLE" != "true" || -z "$PROGRESS_COMMENT_ID" ]] && return 0
|
|
69
69
|
local body="$1"
|
|
70
70
|
gh api "repos/${REPO_OWNER}/${REPO_NAME}/issues/comments/${PROGRESS_COMMENT_ID}" \
|
|
71
|
-
-X PATCH -f body="$body" 2>/dev/null || true
|
|
71
|
+
-X PATCH -f body="$body" --timeout 30 2>/dev/null || true
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
# Add labels to an issue or PR
|
|
@@ -77,7 +77,7 @@ gh_add_labels() {
|
|
|
77
77
|
[[ "$GH_AVAILABLE" != "true" ]] && return 0
|
|
78
78
|
local issue_num="$1" labels="$2"
|
|
79
79
|
[[ -z "$labels" ]] && return 0
|
|
80
|
-
gh issue edit "$issue_num" --add-label "$labels" 2>/dev/null || true
|
|
80
|
+
_timeout 30 gh issue edit "$issue_num" --add-label "$labels" 2>/dev/null || true
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
# Remove a label from an issue
|
|
@@ -85,7 +85,7 @@ gh_add_labels() {
|
|
|
85
85
|
gh_remove_label() {
|
|
86
86
|
[[ "$GH_AVAILABLE" != "true" ]] && return 0
|
|
87
87
|
local issue_num="$1" label="$2"
|
|
88
|
-
gh issue edit "$issue_num" --remove-label "$label" 2>/dev/null || true
|
|
88
|
+
_timeout 30 gh issue edit "$issue_num" --remove-label "$label" 2>/dev/null || true
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
# Self-assign an issue
|
|
@@ -93,7 +93,7 @@ gh_remove_label() {
|
|
|
93
93
|
gh_assign_self() {
|
|
94
94
|
[[ "$GH_AVAILABLE" != "true" ]] && return 0
|
|
95
95
|
local issue_num="$1"
|
|
96
|
-
gh issue edit "$issue_num" --add-assignee "@me" 2>/dev/null || true
|
|
96
|
+
_timeout 30 gh issue edit "$issue_num" --add-assignee "@me" 2>/dev/null || true
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
# Get full issue metadata as JSON
|
|
@@ -101,7 +101,7 @@ gh_assign_self() {
|
|
|
101
101
|
gh_get_issue_meta() {
|
|
102
102
|
[[ "$GH_AVAILABLE" != "true" ]] && return 0
|
|
103
103
|
local issue_num="$1"
|
|
104
|
-
gh issue view "$issue_num" --json title,body,labels,milestone,assignees,comments,number,state 2>/dev/null || true
|
|
104
|
+
_timeout 30 gh issue view "$issue_num" --json title,body,labels,milestone,assignees,comments,number,state 2>/dev/null || true
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
# Build a progress table for GitHub comment
|