|
#!/bin/bash |
|
# bd-vibekanban-lib.sh - Shared functions for Beads ↔ VibeKanban sync |
|
# Source this file from bd-vibekanban |
|
|
|
set -euo pipefail |
|
|
|
# Configuration defaults |
|
VK_URL="${VK_URL:-http://localhost:8090}" |
|
VK_PROJECT="${VK_PROJECT:-}" |
|
MAPPING_FILE="${MAPPING_FILE:-.beads/vk-mapping.json}" |
|
DRY_RUN="${DRY_RUN:-false}" |
|
|
|
# Colors for output |
|
RED='\033[0;31m' |
|
GREEN='\033[0;32m' |
|
YELLOW='\033[0;33m' |
|
BLUE='\033[0;34m' |
|
NC='\033[0m' # No Color |
|
|
|
# Logging helpers |
|
log_info() { echo -e "${BLUE}ℹ${NC} $*"; } |
|
log_success() { echo -e "${GREEN}✓${NC} $*"; } |
|
log_warn() { echo -e "${YELLOW}⚠${NC} $*"; } |
|
log_error() { echo -e "${RED}✗${NC} $*" >&2; } |
|
|
|
# ============================================================================ |
|
# Status Mapping Functions |
|
# ============================================================================ |
|
|
|
# Map Beads status to VibeKanban status |
|
map_beads_to_vk() { |
|
local beads_status="$1" |
|
case "$beads_status" in |
|
open) echo "todo" ;; |
|
in_progress) echo "inprogress" ;; |
|
blocked) echo "todo" ;; |
|
deferred) echo "cancelled" ;; |
|
closed) echo "done" ;; |
|
*) echo "todo" ;; |
|
esac |
|
} |
|
|
|
# Map VibeKanban status to Beads status |
|
map_vk_to_beads() { |
|
local vk_status="$1" |
|
case "$vk_status" in |
|
todo) echo "open" ;; |
|
inprogress) echo "in_progress" ;; |
|
inreview) echo "in_progress" ;; |
|
done) echo "closed" ;; |
|
cancelled) echo "deferred" ;; |
|
*) echo "open" ;; |
|
esac |
|
} |
|
|
|
# Map Beads priority (0-4) to VibeKanban priority (string or keep numeric) |
|
# VK seems to not have priority in API, so we skip for now |
|
map_beads_priority_to_vk() { |
|
local priority="$1" |
|
case "$priority" in |
|
0) echo "critical" ;; |
|
1) echo "high" ;; |
|
2) echo "medium" ;; |
|
3) echo "low" ;; |
|
4) echo "backlog" ;; |
|
*) echo "medium" ;; |
|
esac |
|
} |
|
|
|
# ============================================================================ |
|
# ID Mapping Functions |
|
# ============================================================================ |
|
|
|
# Initialize mapping file if it doesn't exist |
|
init_mapping_file() { |
|
if [[ ! -f "$MAPPING_FILE" ]]; then |
|
echo "{}" > "$MAPPING_FILE" |
|
log_info "Created mapping file: $MAPPING_FILE" |
|
fi |
|
} |
|
|
|
# Get VibeKanban ID for a Beads issue |
|
get_vk_id() { |
|
local beads_id="$1" |
|
if [[ ! -f "$MAPPING_FILE" ]]; then |
|
return 1 |
|
fi |
|
jq -r --arg id "$beads_id" '.beads_to_vk[$id] // empty' "$MAPPING_FILE" |
|
} |
|
|
|
# Get Beads ID for a VibeKanban task |
|
get_beads_id() { |
|
local vk_id="$1" |
|
if [[ ! -f "$MAPPING_FILE" ]]; then |
|
return 1 |
|
fi |
|
jq -r --arg id "$vk_id" '.vk_to_beads[$id] // empty' "$MAPPING_FILE" |
|
} |
|
|
|
# Set bidirectional mapping between Beads ID and VK ID |
|
set_mapping() { |
|
local beads_id="$1" |
|
local vk_id="$2" |
|
local tmp |
|
tmp=$(mktemp) |
|
|
|
jq --arg bid "$beads_id" --arg vid "$vk_id" \ |
|
'.beads_to_vk[$bid] = $vid | .vk_to_beads[$vid] = $bid' \ |
|
"$MAPPING_FILE" > "$tmp" |
|
mv "$tmp" "$MAPPING_FILE" |
|
} |
|
|
|
# Remove mapping for a Beads ID |
|
remove_mapping_by_beads() { |
|
local beads_id="$1" |
|
local vk_id |
|
vk_id=$(get_vk_id "$beads_id") |
|
if [[ -n "$vk_id" ]]; then |
|
local tmp |
|
tmp=$(mktemp) |
|
jq --arg bid "$beads_id" --arg vid "$vk_id" \ |
|
'del(.beads_to_vk[$bid]) | del(.vk_to_beads[$vid])' \ |
|
"$MAPPING_FILE" > "$tmp" |
|
mv "$tmp" "$MAPPING_FILE" |
|
fi |
|
} |
|
|
|
# ============================================================================ |
|
# VibeKanban API Functions |
|
# ============================================================================ |
|
|
|
# Check VK API availability |
|
check_vk_api() { |
|
if ! curl -sf "$VK_URL/api/projects" > /dev/null 2>&1; then |
|
log_error "VibeKanban API not available at $VK_URL" |
|
return 1 |
|
fi |
|
} |
|
|
|
# Get all tasks from VibeKanban for the configured project |
|
vk_get_tasks() { |
|
curl -sf "$VK_URL/api/tasks?project_id=$VK_PROJECT" | jq -c '.data // []' |
|
} |
|
|
|
# Get a single task by ID |
|
vk_get_task() { |
|
local task_id="$1" |
|
curl -sf "$VK_URL/api/tasks/$task_id" | jq -c '.data // empty' |
|
} |
|
|
|
# Create a task in VibeKanban |
|
# Returns the created task's ID |
|
# In dry-run mode, returns empty string (caller should check DRY_RUN) |
|
vk_create_task() { |
|
local title="$1" |
|
local description="${2:-}" |
|
local status="${3:-todo}" |
|
|
|
local payload |
|
payload=$(jq -n \ |
|
--arg pid "$VK_PROJECT" \ |
|
--arg title "$title" \ |
|
--arg desc "$description" \ |
|
--arg status "$status" \ |
|
'{project_id: $pid, title: $title, description: $desc, status: $status}') |
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then |
|
# Don't echo anything - caller handles dry-run logging |
|
return 0 |
|
fi |
|
|
|
local response |
|
response=$(curl -sf -X POST "$VK_URL/api/tasks" \ |
|
-H "Content-Type: application/json" \ |
|
-d "$payload") |
|
|
|
echo "$response" | jq -r '.data.id // empty' |
|
} |
|
|
|
# Update a task in VibeKanban |
|
vk_update_task() { |
|
local task_id="$1" |
|
local title="${2:-}" |
|
local description="${3:-}" |
|
local status="${4:-}" |
|
|
|
# Build payload with only non-empty fields |
|
local payload="{}" |
|
if [[ -n "$title" ]]; then |
|
payload=$(echo "$payload" | jq --arg v "$title" '. + {title: $v}') |
|
fi |
|
if [[ -n "$description" ]]; then |
|
payload=$(echo "$payload" | jq --arg v "$description" '. + {description: $v}') |
|
fi |
|
if [[ -n "$status" ]]; then |
|
payload=$(echo "$payload" | jq --arg v "$status" '. + {status: $v}') |
|
fi |
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then |
|
log_info "[DRY-RUN] Would update task $task_id: $payload" |
|
return 0 |
|
fi |
|
|
|
curl -sf -X PUT "$VK_URL/api/tasks/$task_id" \ |
|
-H "Content-Type: application/json" \ |
|
-d "$payload" > /dev/null |
|
} |
|
|
|
# Delete a task in VibeKanban |
|
vk_delete_task() { |
|
local task_id="$1" |
|
|
|
if [[ "$DRY_RUN" == "true" ]]; then |
|
log_info "[DRY-RUN] Would delete task $task_id" |
|
return 0 |
|
fi |
|
|
|
curl -sf -X DELETE "$VK_URL/api/tasks/$task_id" > /dev/null |
|
} |
|
|
|
# ============================================================================ |
|
# Dependency Awareness Functions |
|
# ============================================================================ |
|
|
|
# Get newline-separated list of blocked issue IDs |
|
get_blocked_ids() { |
|
bd blocked --json 2>/dev/null | jq -r '.[].id' 2>/dev/null || echo "" |
|
} |
|
|
|
# Get newline-separated list of ready-to-work issue IDs |
|
get_ready_ids() { |
|
bd ready --json --limit 0 2>/dev/null | jq -r '.[].id' 2>/dev/null || echo "" |
|
} |
|
|
|
# Check if an ID appears in a newline-separated list |
|
id_in_list() { |
|
local id="$1" |
|
local list="$2" |
|
[[ -n "$list" ]] && echo "$list" | grep -qx "$id" || return 1 |
|
} |
|
|
|
# Get the dependency tag prefix for a task |
|
# [BLOCKED] = has unresolved dependencies |
|
# [READY] = open, no blockers |
|
# (empty) = in progress, closed, or deferred |
|
get_dep_tag() { |
|
local beads_id="$1" |
|
local blocked_ids="$2" |
|
local ready_ids="$3" |
|
|
|
if id_in_list "$beads_id" "$blocked_ids"; then |
|
echo "[BLOCKED] " |
|
elif id_in_list "$beads_id" "$ready_ids"; then |
|
echo "[READY] " |
|
else |
|
echo "" |
|
fi |
|
} |
|
|
|
# Build enriched description with dependency info appended |
|
# Appends a "Blocked by" section if the issue has unresolved blockers |
|
enrich_description() { |
|
local beads_id="$1" |
|
local desc="$2" |
|
local blocked_json="$3" |
|
|
|
local blockers |
|
blockers=$(echo "$blocked_json" | jq -r --arg id "$beads_id" \ |
|
'.[] | select(.id == $id) | .blocked_by[]' 2>/dev/null || echo "") |
|
|
|
if [[ -n "$blockers" ]]; then |
|
local dep_lines="" |
|
while IFS= read -r blocker_id; do |
|
if [[ -n "$blocker_id" ]]; then |
|
dep_lines="${dep_lines}\n- ${blocker_id}" |
|
fi |
|
done <<< "$blockers" |
|
printf '%s\n\n---\nBLOCKED by:%b' "$desc" "$dep_lines" |
|
else |
|
echo "$desc" |
|
fi |
|
} |
|
|
|
# ============================================================================ |
|
# Sync Functions |
|
# ============================================================================ |
|
|
|
# Push Beads issues to VibeKanban |
|
# Args: [--all] to include closed/deferred issues |
|
do_push() { |
|
local include_all=false |
|
if [[ "${1:-}" == "--all" ]]; then |
|
include_all=true |
|
shift |
|
fi |
|
|
|
local created=0 |
|
local updated=0 |
|
local skipped=0 |
|
|
|
if [[ "$include_all" == "true" ]]; then |
|
log_info "Pushing ALL Beads issues → VibeKanban (including closed)..." |
|
else |
|
log_info "Pushing Beads → VibeKanban..." |
|
fi |
|
|
|
# Pre-fetch dependency data for emoji and description enrichment |
|
log_info "Fetching dependency data..." |
|
local blocked_ids ready_ids blocked_json |
|
blocked_ids=$(get_blocked_ids) |
|
ready_ids=$(get_ready_ids) |
|
blocked_json=$(bd blocked --json 2>/dev/null || echo "[]") |
|
|
|
# Get all issues (bd list without --all returns open only by default) |
|
local issues |
|
if [[ "$include_all" == "true" ]]; then |
|
# Use bd list --all to include closed/deferred issues, --limit 0 for unlimited |
|
issues=$(bd list --json --all --limit 0 2>/dev/null || echo "[]") |
|
else |
|
issues=$(bd list --json --limit 0 2>/dev/null || echo "[]") |
|
fi |
|
|
|
echo "$issues" | jq -c '.[]' | while read -r issue; do |
|
local beads_id title desc status priority issue_type |
|
beads_id=$(echo "$issue" | jq -r '.id') |
|
title=$(echo "$issue" | jq -r '.title') |
|
desc=$(echo "$issue" | jq -r '.description // ""') |
|
status=$(echo "$issue" | jq -r '.status') |
|
priority=$(echo "$issue" | jq -r '.priority // 2') |
|
issue_type=$(echo "$issue" | jq -r '.issue_type // "task"') |
|
|
|
# Skip closed and deferred unless --all is specified |
|
if [[ "$include_all" != "true" ]] && [[ "$status" == "closed" || "$status" == "deferred" ]]; then |
|
continue |
|
fi |
|
|
|
local vk_status |
|
vk_status=$(map_beads_to_vk "$status") |
|
|
|
# Determine dependency tag prefix |
|
local dep_tag |
|
dep_tag=$(get_dep_tag "$beads_id" "$blocked_ids" "$ready_ids") |
|
|
|
# Build title with dep tag + beads ID prefix + issue type |
|
local full_title |
|
if [[ "$issue_type" != "task" ]]; then |
|
full_title="${dep_tag}[$beads_id] [$issue_type] $title" |
|
else |
|
full_title="${dep_tag}[$beads_id] $title" |
|
fi |
|
|
|
# Enrich description with dependency info |
|
local enriched_desc |
|
enriched_desc=$(enrich_description "$beads_id" "$desc" "$blocked_json") |
|
|
|
# Check if already mapped |
|
local vk_id |
|
vk_id=$(get_vk_id "$beads_id" || echo "") |
|
|
|
if [[ -z "$vk_id" ]]; then |
|
# Create new task in VK |
|
if [[ "$DRY_RUN" == "true" ]]; then |
|
log_info "[DRY-RUN] Would create: $beads_id → VK task '$full_title' (status: $vk_status)" |
|
((created++)) || true |
|
else |
|
vk_id=$(vk_create_task "$full_title" "$enriched_desc" "$vk_status") |
|
if [[ -n "$vk_id" ]]; then |
|
set_mapping "$beads_id" "$vk_id" |
|
log_success "Created: $beads_id → $vk_id" |
|
((created++)) || true |
|
else |
|
log_error "Failed to create task for $beads_id" |
|
fi |
|
fi |
|
else |
|
# Update existing task |
|
if [[ "$DRY_RUN" == "true" ]]; then |
|
log_info "[DRY-RUN] Would update: $beads_id → $vk_id (status: $vk_status)" |
|
((updated++)) || true |
|
elif vk_update_task "$vk_id" "$full_title" "$enriched_desc" "$vk_status"; then |
|
log_success "Updated: $beads_id → $vk_id" |
|
((updated++)) || true |
|
else |
|
log_error "Failed to update task $vk_id for $beads_id" |
|
fi |
|
fi |
|
done |
|
|
|
echo "" |
|
log_info "Push complete: $created created, $updated updated, $skipped skipped" |
|
} |
|
|
|
# Pull VibeKanban tasks to Beads (status sync only) |
|
do_pull() { |
|
local updated=0 |
|
local skipped=0 |
|
|
|
log_info "Pulling VibeKanban → Beads (status sync only)..." |
|
|
|
local tasks |
|
tasks=$(vk_get_tasks) |
|
|
|
echo "$tasks" | jq -c '.[]' | while read -r task; do |
|
local vk_id vk_status vk_title |
|
vk_id=$(echo "$task" | jq -r '.id') |
|
vk_status=$(echo "$task" | jq -r '.status') |
|
vk_title=$(echo "$task" | jq -r '.title') |
|
|
|
# Get corresponding Beads ID |
|
local beads_id |
|
beads_id=$(get_beads_id "$vk_id" || echo "") |
|
|
|
if [[ -z "$beads_id" ]]; then |
|
log_warn "Skipped: VK task '$vk_title' ($vk_id) - no Beads mapping" |
|
((skipped++)) || true |
|
continue |
|
fi |
|
|
|
# Get current Beads status (bd show returns array) |
|
local current_status beads_status |
|
current_status=$(bd show "$beads_id" --json 2>/dev/null | jq -r '.[0].status // empty' || echo "") |
|
beads_status=$(map_vk_to_beads "$vk_status") |
|
|
|
if [[ -z "$current_status" ]]; then |
|
log_warn "Skipped: Beads issue $beads_id not found" |
|
((skipped++)) || true |
|
continue |
|
fi |
|
|
|
# Update if status differs |
|
if [[ "$current_status" != "$beads_status" ]]; then |
|
if [[ "$DRY_RUN" == "true" ]]; then |
|
log_info "[DRY-RUN] Would update $beads_id: $current_status → $beads_status" |
|
else |
|
# Handle special case: VK "done" should close the issue using bd close |
|
if [[ "$beads_status" == "closed" ]]; then |
|
bd close "$beads_id" 2>/dev/null || true |
|
log_success "Closed: $beads_id (was $current_status, VK is done)" |
|
else |
|
bd update "$beads_id" --status "$beads_status" 2>/dev/null || true |
|
log_success "Updated: $beads_id status $current_status → $beads_status" |
|
fi |
|
fi |
|
((updated++)) || true |
|
fi |
|
done |
|
|
|
echo "" |
|
log_info "Pull complete: $updated updated, $skipped skipped" |
|
} |
|
|
|
# Bidirectional sync |
|
do_sync() { |
|
local mode="bidirectional" |
|
local prefer_local=false |
|
local include_all=false |
|
|
|
while [[ $# -gt 0 ]]; do |
|
case "$1" in |
|
--pull) |
|
mode="pull" |
|
;; |
|
--push) |
|
mode="push" |
|
;; |
|
--dry-run) |
|
DRY_RUN="true" |
|
log_warn "DRY-RUN mode enabled - no changes will be made" |
|
;; |
|
--prefer-local) |
|
prefer_local=true |
|
;; |
|
--all) |
|
include_all=true |
|
;; |
|
*) |
|
log_error "Unknown option: $1" |
|
return 1 |
|
;; |
|
esac |
|
shift |
|
done |
|
|
|
local push_args="" |
|
if [[ "$include_all" == "true" ]]; then |
|
push_args="--all" |
|
fi |
|
|
|
case "$mode" in |
|
pull) |
|
do_pull |
|
;; |
|
push) |
|
do_push $push_args |
|
;; |
|
bidirectional) |
|
if [[ "$prefer_local" == "true" ]]; then |
|
log_info "Prefer-local mode: pushing first, then pulling" |
|
do_push $push_args |
|
echo "" |
|
do_pull |
|
else |
|
log_info "Bidirectional sync: pulling first, then pushing" |
|
do_pull |
|
echo "" |
|
do_push $push_args |
|
fi |
|
;; |
|
esac |
|
} |
|
|
|
# ============================================================================ |
|
# Status and Config Functions |
|
# ============================================================================ |
|
|
|
show_status() { |
|
echo "=== Beads ↔ VibeKanban Sync Status ===" |
|
echo "" |
|
echo "Configuration:" |
|
echo " VK URL: $VK_URL" |
|
echo " VK Project: ${VK_PROJECT:-<not set>}" |
|
echo " Mapping: $MAPPING_FILE" |
|
echo "" |
|
|
|
# Check API |
|
if check_vk_api 2>/dev/null; then |
|
log_success "VibeKanban API is reachable" |
|
else |
|
log_error "VibeKanban API is not reachable" |
|
fi |
|
|
|
# Count mappings |
|
if [[ -f "$MAPPING_FILE" ]]; then |
|
local count |
|
count=$(jq '.beads_to_vk | length' "$MAPPING_FILE" 2>/dev/null || echo "0") |
|
echo " Mapped issues: $count" |
|
else |
|
echo " Mapped issues: 0 (no mapping file)" |
|
fi |
|
|
|
echo "" |
|
echo "Beads open issues: $(bd list --status open --json 2>/dev/null | jq 'length' || echo "?")" |
|
|
|
if [[ -n "$VK_PROJECT" ]]; then |
|
echo "VK tasks: $(vk_get_tasks 2>/dev/null | jq 'length' || echo "?")" |
|
fi |
|
} |
|
|
|
show_config() { |
|
echo "=== Beads ↔ VibeKanban Configuration ===" |
|
echo "" |
|
echo "Current settings:" |
|
echo " vibekanban.url: $(bd config get vibekanban.url 2>/dev/null || echo '<not set>')" |
|
echo " vibekanban.project_id: $(bd config get vibekanban.project_id 2>/dev/null || echo '<not set>')" |
|
echo "" |
|
echo "To configure:" |
|
echo " bd config set vibekanban.url http://localhost:8090" |
|
echo " bd config set vibekanban.project_id <uuid>" |
|
echo "" |
|
echo "Or use environment variables:" |
|
echo " export VK_URL=http://localhost:8090" |
|
echo " export VK_PROJECT=<uuid>" |
|
} |
|
|
|
show_help() { |
|
cat << 'EOF' |
|
bd-vibekanban - Bidirectional sync between Beads and VibeKanban |
|
|
|
USAGE: |
|
bd-vibekanban <command> [options] |
|
|
|
COMMANDS: |
|
sync [options] Synchronize issues between Beads and VibeKanban |
|
status Show sync status and configuration |
|
config Show configuration help |
|
help Show this help message |
|
|
|
SYNC OPTIONS: |
|
--push Push Beads → VibeKanban only |
|
--pull Pull VibeKanban → Beads only (status sync) |
|
--all Include closed/deferred issues in push |
|
--dry-run Preview changes without applying them |
|
--prefer-local On bidirectional sync, push before pull |
|
|
|
CONFIGURATION: |
|
Set via bd config: |
|
bd config set vibekanban.url http://localhost:8090 |
|
bd config set vibekanban.project_id <project-uuid> |
|
|
|
Or via environment variables: |
|
VK_URL=http://localhost:8090 |
|
VK_PROJECT=<project-uuid> |
|
|
|
EXAMPLES: |
|
# Initial push of all open issues |
|
bd-vibekanban sync --push |
|
|
|
# Pull status changes from VibeKanban |
|
bd-vibekanban sync --pull |
|
|
|
# Bidirectional sync |
|
bd-vibekanban sync |
|
|
|
# Preview what would change |
|
bd-vibekanban sync --dry-run |
|
|
|
STATUS MAPPING: |
|
Beads VibeKanban |
|
───────────────────────────── |
|
open todo |
|
in_progress inprogress |
|
blocked todo (preserved in beads) |
|
deferred cancelled |
|
closed done |
|
|
|
DEPENDENCY INDICATORS: |
|
Push adds visual dependency indicators to VibeKanban tasks: |
|
|
|
Title tag: |
|
[BLOCKED] Task has unresolved dependencies |
|
[READY] Task is ready to work (open, no blockers) |
|
(none) Task is in progress, closed, or deferred |
|
|
|
Description: |
|
Blocked tasks get a "BLOCKED by" section appended |
|
with the IDs of blocking issues. |
|
|
|
NOTES: |
|
- Beads is the source of truth for content (title, description, type) |
|
- VibeKanban status changes are synced back to Beads |
|
- ID mappings are stored in .beads/vk-mapping.json |
|
- Blocked issues appear as 'todo' in VK but retain 'blocked' in Beads |
|
- Dependency indicators refresh on each push |
|
EOF |
|
} |