claude-plugin-viban 1.0.37 → 1.1.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viban",
3
- "version": "1.0.37",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal Kanban TUI for AI-human collaborative issue tracking",
5
5
  "author": {
6
6
  "name": "happy-nut"
package/bin/viban CHANGED
@@ -35,7 +35,7 @@ check_dependencies() {
35
35
  }
36
36
 
37
37
  # Only check dependencies for interactive commands (not help)
38
- [[ "$1" != "help" && "$1" != "--help" && "$1" != "-h" ]] && check_dependencies
38
+ [[ "$1" != "help" && "$1" != "--help" && "$1" != "-h" && "$1" != "sync" ]] && check_dependencies
39
39
 
40
40
  # ============================================================
41
41
  # Auto-update Check (once per day, cached)
@@ -113,7 +113,7 @@ auto_update_check() {
113
113
 
114
114
  # Run auto-update for all commands except help/version/update itself
115
115
  case "$1" in
116
- help|--help|-h|--version|-v|update) ;;
116
+ help|--help|-h|--version|-v|update|sync) ;;
117
117
  *) auto_update_check "$@" ;;
118
118
  esac
119
119
 
@@ -246,7 +246,7 @@ EOF
246
246
 
247
247
  # Auto-initialize for commands that need data (not help/init)
248
248
  case "$1" in
249
- help|--help|-h|--version|-v|update|init) ;;
249
+ help|--help|-h|--version|-v|update|init|sync) ;;
250
250
  *) init_viban_json ;;
251
251
  esac
252
252
 
@@ -1571,6 +1571,26 @@ cmd_migrate() {
1571
1571
  ' "$VIBAN_JSON"
1572
1572
  }
1573
1573
 
1574
+ cmd_sync() {
1575
+ local provider="${VIBAN_SYNC_PROVIDER:-}"
1576
+ # Auto-detect provider from existing sync.json or default to github
1577
+ if [[ -z "$provider" && -f "$VIBAN_DATA_DIR/sync.json" ]]; then
1578
+ provider=$(jq -r '.provider // "github"' "$VIBAN_DATA_DIR/sync.json")
1579
+ fi
1580
+ provider="${provider:-github}"
1581
+
1582
+ local provider_script="$VIBAN_SCRIPT_DIR/scripts/providers/${provider}.sh"
1583
+ if [[ ! -f "$provider_script" ]]; then
1584
+ echo "Error: Unknown sync provider '$provider'"
1585
+ echo "Available: $(ls "$VIBAN_SCRIPT_DIR/scripts/providers/" 2>/dev/null | sed 's/\.sh$//' | tr '\n' ' ')"
1586
+ exit 1
1587
+ fi
1588
+
1589
+ VIBAN_JSON="$VIBAN_JSON" VIBAN_DATA_DIR="$VIBAN_DATA_DIR" \
1590
+ VIBAN_PROVIDER="$provider" VIBAN_SCRIPT_DIR="$VIBAN_SCRIPT_DIR" \
1591
+ bash "$VIBAN_SCRIPT_DIR/scripts/sync.sh" "$@"
1592
+ }
1593
+
1574
1594
  main() {
1575
1595
  check_deps
1576
1596
  init_json
@@ -1585,6 +1605,7 @@ main() {
1585
1605
  edit) [[ -z "$2" ]] && { echo "Usage: viban edit <id>"; exit 1; }; edit_issue "$2";;
1586
1606
  priority) cmd_priority "$2" "$3";;
1587
1607
  migrate) cmd_migrate;;
1608
+ sync) shift; cmd_sync "$@";;
1588
1609
  --version|-v)
1589
1610
  # Get version from package.json
1590
1611
  if [[ -f "$VIBAN_SCRIPT_DIR/package.json" ]]; then
@@ -1616,6 +1637,7 @@ main() {
1616
1637
  echo " viban edit <id> Edit task in editor"
1617
1638
  echo " viban get <id> Get task details (JSON)"
1618
1639
  echo " viban migrate Migrate: extract type from title"
1640
+ echo " viban sync Sync with external issue tracker (GitHub, etc.)"
1619
1641
  echo " viban update Update to latest version (if available)"
1620
1642
  echo ""
1621
1643
  echo " Priority: P0=CRITICAL, P1=HIGH, P2=MEDIUM, P3=LOW"
@@ -0,0 +1,99 @@
1
+ ---
2
+ description: "Sync viban board with external issue tracker (GitHub, Jira, etc.)"
3
+ ---
4
+
5
+ # /sync - External Issue Tracker Sync
6
+
7
+ Sync the viban board with an external issue tracker. Currently supports GitHub Issues via `gh` CLI.
8
+
9
+ > **Principle**: Show what will happen before doing it. Never sync without user confirmation.
10
+
11
+ ## Input
12
+
13
+ **User Input**: `$ARGUMENTS`
14
+
15
+ ## Step 1: Check Sync Configuration
16
+
17
+ ```bash
18
+ # Check if sync is already configured
19
+ if [ -f "$(git rev-parse --git-common-dir 2>/dev/null || echo .viban)/sync.json" ]; then
20
+ echo "Sync configured"
21
+ viban sync --status
22
+ else
23
+ echo "Sync not configured"
24
+ fi
25
+ ```
26
+
27
+ - If **not configured**, proceed to Step 2
28
+ - If **configured**, skip to Step 3
29
+
30
+ ## Step 2: Initialize Sync
31
+
32
+ ```bash
33
+ viban sync --init
34
+ ```
35
+
36
+ This will:
37
+ - Auto-detect the provider from git remote (defaults to GitHub)
38
+ - Check that `gh` CLI is installed and authenticated
39
+ - Create required labels on the remote repo
40
+ - Initialize `sync.json` metadata
41
+
42
+ If initialization fails, report the error and suggest fixes (install `gh`, run `gh auth login`, etc.).
43
+
44
+ ## Step 3: Preview Changes (Dry Run)
45
+
46
+ ```bash
47
+ viban sync --dry-run
48
+ ```
49
+
50
+ Show the user what will happen:
51
+ - `<-` Issues to pull from remote
52
+ - `->` Cards to push to remote
53
+ - `==` Unchanged items
54
+ - `!!` Conflicts (and resolution strategy)
55
+
56
+ ## Step 4: Confirm and Sync
57
+
58
+ Ask the user for confirmation using AskUserQuestion:
59
+
60
+ - header: "Sync"
61
+ - question: "Apply these sync changes?"
62
+ - options:
63
+ - "Yes, sync now"
64
+ - "Sync and push new local cards too (--push-new)"
65
+ - "Pull only (remote -> local)"
66
+ - "Cancel"
67
+ - multiSelect: false
68
+
69
+ Based on the answer:
70
+
71
+ ```bash
72
+ # Yes, sync now
73
+ viban sync
74
+
75
+ # With push-new
76
+ viban sync --push-new
77
+
78
+ # Pull only
79
+ viban sync --pull-only
80
+ ```
81
+
82
+ ## Step 5: Report Results
83
+
84
+ Show the sync summary:
85
+ ```
86
+ Sync complete:
87
+ Pulled: N new/updated cards from remote
88
+ Pushed: N cards to remote
89
+ Conflicts: N (resolved by: remote wins)
90
+ Unchanged: N
91
+ ```
92
+
93
+ ## Notes
94
+
95
+ - **First sync imports all open issues** as backlog cards with `github:N` external IDs
96
+ - **Conflicts**: when both sides changed, remote wins by default (with warning)
97
+ - **Closed issues**: remote closed issues move viban card to `review` status
98
+ - **Done cards**: `viban done` then sync closes the remote issue
99
+ - **New local cards** are NOT pushed unless `--push-new` is specified (local-first default)
package/install.sh CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  set -e
6
6
 
7
+ main() {
8
+
7
9
  RED='\033[0;31m'
8
10
  GREEN='\033[0;32m'
9
11
  YELLOW='\033[0;33m'
@@ -172,39 +174,14 @@ echo ""
172
174
  echo -e "${BOLD}Registering Claude Code plugin...${NC}"
173
175
  echo ""
174
176
 
175
- CLAUDE_CONFIG_DIR="${HOME}/.claude"
176
- CLAUDE_PLUGINS_FILE="${CLAUDE_CONFIG_DIR}/plugins.json"
177
-
178
- mkdir -p "$CLAUDE_CONFIG_DIR"
179
-
180
- # Get npm global prefix to find viban
181
- NPM_PREFIX=$(npm prefix -g)
182
- VIBAN_PLUGIN_DIR="${NPM_PREFIX}/lib/node_modules/claude-plugin-viban"
183
-
184
- # Check if plugins.json exists
185
- if [[ -f "$CLAUDE_PLUGINS_FILE" ]]; then
186
- # Check if viban is already registered
187
- if jq -e '.plugins[] | select(.name == "viban")' "$CLAUDE_PLUGINS_FILE" > /dev/null 2>&1; then
188
- echo -e "${GREEN}✓${NC} Plugin already registered"
189
- else
190
- # Add viban to existing plugins
191
- jq --arg path "$VIBAN_PLUGIN_DIR" '.plugins += [{"name": "viban", "path": $path}]' "$CLAUDE_PLUGINS_FILE" > "${CLAUDE_PLUGINS_FILE}.tmp"
192
- mv "${CLAUDE_PLUGINS_FILE}.tmp" "$CLAUDE_PLUGINS_FILE"
193
- echo -e "${GREEN}✓${NC} Plugin registered"
194
- fi
177
+ if command -v claude &> /dev/null; then
178
+ claude plugin marketplace add https://github.com/happy-nut/claude-plugin-viban 2>/dev/null || true
179
+ claude plugin install viban 2>/dev/null || true
180
+ echo -e "${GREEN}✓${NC} Plugin registered in Claude Code"
195
181
  else
196
- # Create new plugins.json
197
- cat > "$CLAUDE_PLUGINS_FILE" << EOF
198
- {
199
- "plugins": [
200
- {
201
- "name": "viban",
202
- "path": "$VIBAN_PLUGIN_DIR"
203
- }
204
- ]
205
- }
206
- EOF
207
- echo -e "${GREEN}✓${NC} Plugin registered"
182
+ echo -e "${YELLOW}!${NC} Claude Code CLI not found. To register the plugin manually, run:"
183
+ echo -e " ${YELLOW}claude plugin marketplace add https://github.com/happy-nut/claude-plugin-viban${NC}"
184
+ echo -e " ${YELLOW}claude plugin install viban${NC}"
208
185
  fi
209
186
 
210
187
  echo ""
@@ -220,3 +197,7 @@ echo -e " ${YELLOW}viban help${NC} Show all commands"
220
197
  echo ""
221
198
  echo -e "In Claude Code, the plugin is now available."
222
199
  echo ""
200
+
201
+ }
202
+
203
+ main "$@"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin-viban",
3
- "version": "1.0.37",
3
+ "version": "1.1.0",
4
4
  "description": "Terminal Kanban TUI for AI-human collaborative issue tracking",
5
5
  "main": "bin/viban",
6
6
  "bin": {
@@ -0,0 +1,268 @@
1
+ #!/bin/bash
2
+ # GitHub provider for viban sync
3
+ # Implements the provider interface using the `gh` CLI
4
+
5
+ # ============================================================
6
+ # Provider Interface
7
+ # ============================================================
8
+
9
+ provider_name() {
10
+ echo "github"
11
+ }
12
+
13
+ provider_check_deps() {
14
+ if ! command -v gh &>/dev/null; then
15
+ echo "Error: gh CLI not found"
16
+ echo " Install: brew install gh"
17
+ echo " Or visit: https://cli.github.com/"
18
+ return 1
19
+ fi
20
+ }
21
+
22
+ provider_check_auth() {
23
+ if ! gh auth status &>/dev/null; then
24
+ echo "Error: Not authenticated with GitHub"
25
+ echo " Run: gh auth login"
26
+ return 1
27
+ fi
28
+ }
29
+
30
+ provider_detect_config() {
31
+ local remote_url
32
+ remote_url=$(git remote get-url origin 2>/dev/null) || {
33
+ echo "Error: No git remote 'origin' found"
34
+ return 1
35
+ }
36
+
37
+ local repo=""
38
+ # SSH format: git@github.com:owner/repo.git
39
+ if [[ "$remote_url" =~ git@github\.com:([^/]+/[^/.]+)(\.git)?$ ]]; then
40
+ repo="${BASH_REMATCH[1]}"
41
+ # HTTPS format: https://github.com/owner/repo.git
42
+ elif [[ "$remote_url" =~ github\.com/([^/]+/[^/.]+)(\.git)?$ ]]; then
43
+ repo="${BASH_REMATCH[1]}"
44
+ fi
45
+
46
+ if [[ -z "$repo" ]]; then
47
+ echo "Error: Could not detect GitHub repo from remote URL: $remote_url"
48
+ return 1
49
+ fi
50
+
51
+ echo "{\"repo\":\"$repo\"}"
52
+ }
53
+
54
+ # ============================================================
55
+ # Label Mapping
56
+ # ============================================================
57
+
58
+ # Status labels
59
+ _gh_status_to_viban() {
60
+ local gh_state="$1" labels="$2"
61
+ if [[ "$gh_state" == "closed" ]]; then
62
+ echo "done"
63
+ elif echo "$labels" | grep -q "review"; then
64
+ echo "review"
65
+ elif echo "$labels" | grep -q "in-progress"; then
66
+ echo "in_progress"
67
+ else
68
+ echo "backlog"
69
+ fi
70
+ }
71
+
72
+ _viban_status_labels() {
73
+ local st="$1"
74
+ case "$st" in
75
+ in_progress) echo "in-progress" ;;
76
+ review) echo "review" ;;
77
+ *) echo "" ;;
78
+ esac
79
+ }
80
+
81
+ # Priority mapping
82
+ _gh_priority_to_viban() {
83
+ local labels="$1"
84
+ if echo "$labels" | grep -q "P0-critical"; then echo "P0"
85
+ elif echo "$labels" | grep -q "P1-high"; then echo "P1"
86
+ elif echo "$labels" | grep -q "P2-medium"; then echo "P2"
87
+ elif echo "$labels" | grep -q "P3-low"; then echo "P3"
88
+ else echo "P3"
89
+ fi
90
+ }
91
+
92
+ _viban_priority_label() {
93
+ local priority="$1"
94
+ case "$priority" in
95
+ P0) echo "P0-critical" ;;
96
+ P1) echo "P1-high" ;;
97
+ P2) echo "P2-medium" ;;
98
+ P3) echo "P3-low" ;;
99
+ *) echo "" ;;
100
+ esac
101
+ }
102
+
103
+ # Type mapping
104
+ _gh_type_to_viban() {
105
+ local labels="$1"
106
+ if echo "$labels" | grep -q "bug"; then echo "bug"
107
+ elif echo "$labels" | grep -q "enhancement"; then echo "feat"
108
+ elif echo "$labels" | grep -q "chore"; then echo "chore"
109
+ elif echo "$labels" | grep -q "refactor"; then echo "refactor"
110
+ else echo ""
111
+ fi
112
+ }
113
+
114
+ _viban_type_label() {
115
+ local type="$1"
116
+ case "$type" in
117
+ bug) echo "bug" ;;
118
+ feat) echo "enhancement" ;;
119
+ chore) echo "chore" ;;
120
+ refactor) echo "refactor" ;;
121
+ *) echo "" ;;
122
+ esac
123
+ }
124
+
125
+ # ============================================================
126
+ # Core Provider Functions
127
+ # ============================================================
128
+
129
+ provider_fetch_issues() {
130
+ local repo="$1"
131
+
132
+ local issues
133
+ issues=$(gh issue list --repo "$repo" --state open --json number,title,body,labels,updatedAt --limit 200 2>/dev/null) || {
134
+ echo "Error: Failed to fetch issues from $repo"
135
+ return 1
136
+ }
137
+
138
+ # Transform to normalized format
139
+ echo "$issues" | jq '[.[] | {
140
+ remote_id: (.number | tostring),
141
+ title: .title,
142
+ description: (.body // ""),
143
+ status: (
144
+ if ([.labels[].name] | any(. == "review")) then "review"
145
+ elif ([.labels[].name] | any(. == "in-progress")) then "in_progress"
146
+ else "backlog"
147
+ end
148
+ ),
149
+ priority: (
150
+ if ([.labels[].name] | any(. == "P0-critical")) then "P0"
151
+ elif ([.labels[].name] | any(. == "P1-high")) then "P1"
152
+ elif ([.labels[].name] | any(. == "P2-medium")) then "P2"
153
+ elif ([.labels[].name] | any(. == "P3-low")) then "P3"
154
+ else "P3"
155
+ end
156
+ ),
157
+ type: (
158
+ if ([.labels[].name] | any(. == "bug")) then "bug"
159
+ elif ([.labels[].name] | any(. == "enhancement")) then "feat"
160
+ elif ([.labels[].name] | any(. == "chore")) then "chore"
161
+ elif ([.labels[].name] | any(. == "refactor")) then "refactor"
162
+ else null
163
+ end
164
+ ),
165
+ updated_at: .updatedAt
166
+ }]'
167
+ }
168
+
169
+ provider_create_issue() {
170
+ local repo="$1"
171
+ # Read normalized JSON from stdin
172
+ local issue_json
173
+ issue_json=$(cat)
174
+
175
+ local title body labels_args
176
+ title=$(echo "$issue_json" | jq -r '.title')
177
+ body=$(echo "$issue_json" | jq -r '.description // ""')
178
+
179
+ # Build labels
180
+ local labels=()
181
+ local status_label priority_label type_label
182
+
183
+ status_label=$(_viban_status_labels "$(echo "$issue_json" | jq -r '.status // "backlog"')")
184
+ [[ -n "$status_label" ]] && labels+=("$status_label")
185
+
186
+ priority_label=$(_viban_priority_label "$(echo "$issue_json" | jq -r '.priority // "P3"')")
187
+ [[ -n "$priority_label" ]] && labels+=("$priority_label")
188
+
189
+ type_label=$(_viban_type_label "$(echo "$issue_json" | jq -r '.type // ""')")
190
+ [[ -n "$type_label" ]] && labels+=("$type_label")
191
+
192
+ local label_args=()
193
+ for l in "${labels[@]}"; do
194
+ label_args+=(--label "$l")
195
+ done
196
+
197
+ local result
198
+ result=$(gh issue create --repo "$repo" --title "$title" --body "$body" "${label_args[@]}" 2>/dev/null) || {
199
+ echo "Error: Failed to create issue '$title'"
200
+ return 1
201
+ }
202
+
203
+ # Extract issue number from URL (gh returns URL like https://github.com/owner/repo/issues/42)
204
+ echo "$result" | grep -o '[0-9]*$'
205
+ }
206
+
207
+ provider_update_issue() {
208
+ local repo="$1" remote_id="$2"
209
+ # Read normalized JSON from stdin
210
+ local issue_json
211
+ issue_json=$(cat)
212
+
213
+ local title body
214
+ title=$(echo "$issue_json" | jq -r '.title')
215
+ body=$(echo "$issue_json" | jq -r '.description // ""')
216
+
217
+ # Collect all desired labels
218
+ local labels=()
219
+ local status_label priority_label type_label
220
+
221
+ status_label=$(_viban_status_labels "$(echo "$issue_json" | jq -r '.status // "backlog"')")
222
+ [[ -n "$status_label" ]] && labels+=("$status_label")
223
+
224
+ priority_label=$(_viban_priority_label "$(echo "$issue_json" | jq -r '.priority // "P3"')")
225
+ [[ -n "$priority_label" ]] && labels+=("$priority_label")
226
+
227
+ type_label=$(_viban_type_label "$(echo "$issue_json" | jq -r '.type // ""')")
228
+ [[ -n "$type_label" ]] && labels+=("$type_label")
229
+
230
+ # Remove old status/priority/type labels, then add new ones
231
+ local remove_labels="in-progress,review,P0-critical,P1-high,P2-medium,P3-low,bug,enhancement,chore,refactor"
232
+ gh issue edit "$remote_id" --repo "$repo" --title "$title" --body "$body" \
233
+ --remove-label "$remove_labels" 2>/dev/null
234
+
235
+ if [[ ${#labels[@]} -gt 0 ]]; then
236
+ local add_labels
237
+ add_labels=$(IFS=,; echo "${labels[*]}")
238
+ gh issue edit "$remote_id" --repo "$repo" --add-label "$add_labels" 2>/dev/null
239
+ fi
240
+ }
241
+
242
+ provider_close_issue() {
243
+ local repo="$1" remote_id="$2"
244
+ gh issue close "$remote_id" --repo "$repo" 2>/dev/null || {
245
+ echo "Error: Failed to close issue #$remote_id"
246
+ return 1
247
+ }
248
+ }
249
+
250
+ provider_ensure_labels() {
251
+ local repo="$1"
252
+
253
+ local required_labels=(
254
+ "in-progress:Status: In Progress:0E8A16"
255
+ "review:Status: In Review:FBCA04"
256
+ "P0-critical:Priority: Critical:B60205"
257
+ "P1-high:Priority: High:D93F0B"
258
+ "P2-medium:Priority: Medium:FBCA04"
259
+ "P3-low:Priority: Low:0E8A16"
260
+ "chore:Type: Chore:EEEEEE"
261
+ "refactor:Type: Refactor:C5DEF5"
262
+ )
263
+
264
+ for label_def in "${required_labels[@]}"; do
265
+ IFS=: read -r name desc color <<< "$label_def"
266
+ gh label create "$name" --repo "$repo" --description "$desc" --color "$color" 2>/dev/null || true
267
+ done
268
+ }
@@ -0,0 +1,530 @@
1
+ #!/bin/bash
2
+ # viban sync - Core sync engine (provider-agnostic)
3
+ # Orchestrates two-way sync between viban board and external issue trackers.
4
+ #
5
+ # Environment variables (set by bin/viban cmd_sync):
6
+ # VIBAN_JSON - Path to viban.json
7
+ # VIBAN_DATA_DIR - Path to viban data directory
8
+ # VIBAN_PROVIDER - Provider name (e.g., "github")
9
+ # VIBAN_SCRIPT_DIR - Path to viban install directory
10
+
11
+ set -euo pipefail
12
+
13
+ SYNC_JSON="${VIBAN_DATA_DIR}/sync.json"
14
+ PROVIDER_SCRIPT="${VIBAN_SCRIPT_DIR}/scripts/providers/${VIBAN_PROVIDER}.sh"
15
+
16
+ # ============================================================
17
+ # Provider Loading
18
+ # ============================================================
19
+
20
+ load_provider() {
21
+ if [[ ! -f "$PROVIDER_SCRIPT" ]]; then
22
+ echo "Error: Provider script not found: $PROVIDER_SCRIPT"
23
+ return 1
24
+ fi
25
+ source "$PROVIDER_SCRIPT"
26
+
27
+ # Validate provider interface
28
+ local required_funcs=(
29
+ provider_name provider_check_deps provider_check_auth
30
+ provider_detect_config provider_fetch_issues provider_create_issue
31
+ provider_update_issue provider_close_issue provider_ensure_labels
32
+ )
33
+ for func in "${required_funcs[@]}"; do
34
+ if ! declare -f "$func" &>/dev/null; then
35
+ echo "Error: Provider '${VIBAN_PROVIDER}' missing required function: $func"
36
+ return 1
37
+ fi
38
+ done
39
+ }
40
+
41
+ # ============================================================
42
+ # Sync Metadata (sync.json)
43
+ # ============================================================
44
+
45
+ read_sync_meta() {
46
+ if [[ -f "$SYNC_JSON" ]]; then
47
+ cat "$SYNC_JSON"
48
+ else
49
+ echo '{}'
50
+ fi
51
+ }
52
+
53
+ write_sync_meta() {
54
+ local data="$1"
55
+ echo "$data" > "${SYNC_JSON}.tmp" && mv "${SYNC_JSON}.tmp" "$SYNC_JSON"
56
+ }
57
+
58
+ get_issue_meta() {
59
+ local viban_id="$1"
60
+ read_sync_meta | jq -r --arg id "$viban_id" '.issues[$id] // empty'
61
+ }
62
+
63
+ set_issue_meta() {
64
+ local viban_id="$1" remote_id="$2" remote_updated="$3" viban_updated="$4"
65
+ local meta
66
+ meta=$(read_sync_meta)
67
+ meta=$(echo "$meta" | jq --arg vid "$viban_id" --arg rid "$remote_id" \
68
+ --arg ru "$remote_updated" --arg vu "$viban_updated" \
69
+ '.issues[$vid] = {remote_id: $rid, remote_updated_at: $ru, viban_updated_at: $vu}')
70
+ write_sync_meta "$meta"
71
+ }
72
+
73
+ # ============================================================
74
+ # Sync Init
75
+ # ============================================================
76
+
77
+ sync_init() {
78
+ local repo_override="$1"
79
+
80
+ echo "Initializing sync..."
81
+
82
+ # Check provider dependencies and auth
83
+ provider_check_deps || exit 1
84
+ provider_check_auth || exit 1
85
+
86
+ # Detect or use provided config
87
+ local config
88
+ if [[ -n "$repo_override" ]]; then
89
+ config="{\"repo\":\"$repo_override\"}"
90
+ else
91
+ config=$(provider_detect_config) || exit 1
92
+ fi
93
+
94
+ local repo
95
+ repo=$(echo "$config" | jq -r '.repo')
96
+ echo "Detected $(provider_name) repo: $repo"
97
+
98
+ # Ensure required labels exist
99
+ echo "Ensuring labels..."
100
+ provider_ensure_labels "$repo"
101
+
102
+ # Initialize sync.json
103
+ local now
104
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
105
+ write_sync_meta "$(jq -n \
106
+ --arg provider "$(provider_name)" \
107
+ --argjson config "$config" \
108
+ --arg now "$now" \
109
+ '{provider: $provider, provider_config: $config, last_sync_at: $now, issues: {}}')"
110
+
111
+ echo "Sync initialized for $(provider_name) ($repo)"
112
+ }
113
+
114
+ # ============================================================
115
+ # Sync Pull (remote -> viban)
116
+ # ============================================================
117
+
118
+ sync_pull() {
119
+ local repo="$1" dry_run="${2:-false}"
120
+ local pulled=0 updated=0 unchanged=0
121
+
122
+ local remote_issues
123
+ remote_issues=$(provider_fetch_issues "$repo") || exit 1
124
+
125
+ local provider_prefix
126
+ provider_prefix="$(provider_name):"
127
+
128
+ local count
129
+ count=$(echo "$remote_issues" | jq 'length')
130
+
131
+ for i in $(seq 0 $((count - 1))); do
132
+ local issue
133
+ issue=$(echo "$remote_issues" | jq ".[$i]")
134
+
135
+ local remote_id title description status priority type remote_updated
136
+ remote_id=$(echo "$issue" | jq -r '.remote_id')
137
+ title=$(echo "$issue" | jq -r '.title')
138
+ description=$(echo "$issue" | jq -r '.description // ""')
139
+ status=$(echo "$issue" | jq -r '.status')
140
+ priority=$(echo "$issue" | jq -r '.priority // "P3"')
141
+ type=$(echo "$issue" | jq -r '.type // ""')
142
+ remote_updated=$(echo "$issue" | jq -r '.updated_at')
143
+
144
+ local ext_id="${provider_prefix}${remote_id}"
145
+
146
+ # Find existing viban card with this external_id
147
+ local viban_card
148
+ viban_card=$(jq -r --arg eid "$ext_id" \
149
+ '.issues[] | select(.external_id == $eid)' "$VIBAN_JSON" 2>/dev/null)
150
+
151
+ if [[ -z "$viban_card" || "$viban_card" == "null" ]]; then
152
+ # New remote issue -> import
153
+ if [[ "$dry_run" == "true" ]]; then
154
+ echo " <- ${ext_id} \"${title}\" (new card to create)"
155
+ else
156
+ # Use jq to add card directly
157
+ local now viban_id
158
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
159
+ viban_id=$(jq -r '.next_id // (([.issues[].id] | max // 0) + 1)' "$VIBAN_JSON")
160
+
161
+ local type_val="null"
162
+ [[ -n "$type" && "$type" != "null" ]] && type_val="\"$type\""
163
+
164
+ jq --arg id "$viban_id" --arg title "$title" --arg desc "$description" \
165
+ --arg status "$status" --arg priority "$priority" \
166
+ --arg ext_id "$ext_id" --arg now "$now" --argjson type_val "$type_val" \
167
+ '.next_id = ((.next_id // 0) + 1) |
168
+ .issues += [{
169
+ id: ($id | tonumber),
170
+ title: $title,
171
+ description: $desc,
172
+ status: $status,
173
+ priority: $priority,
174
+ type: $type_val,
175
+ external_id: $ext_id,
176
+ attachments: [],
177
+ assigned_to: null,
178
+ created_at: $now,
179
+ updated_at: $now
180
+ }]' "$VIBAN_JSON" > "${VIBAN_JSON}.tmp" && mv "${VIBAN_JSON}.tmp" "$VIBAN_JSON"
181
+
182
+ set_issue_meta "$viban_id" "$remote_id" "$remote_updated" "$now"
183
+ echo " <- ${ext_id} \"${title}\" (new card created)"
184
+ fi
185
+ ((pulled++))
186
+ else
187
+ # Existing card - check for changes
188
+ local viban_id viban_updated
189
+ viban_id=$(echo "$viban_card" | jq -r '.id')
190
+ viban_updated=$(echo "$viban_card" | jq -r '.updated_at')
191
+
192
+ # Get last known sync timestamps
193
+ local meta
194
+ meta=$(get_issue_meta "$viban_id")
195
+
196
+ if [[ -z "$meta" ]]; then
197
+ # First time seeing this linked card in sync - record and skip
198
+ set_issue_meta "$viban_id" "$remote_id" "$remote_updated" "$viban_updated"
199
+ echo " == ${ext_id} \"${title}\" (tracking started)"
200
+ ((unchanged++))
201
+ continue
202
+ fi
203
+
204
+ local last_remote_updated last_viban_updated
205
+ last_remote_updated=$(echo "$meta" | jq -r '.remote_updated_at')
206
+ last_viban_updated=$(echo "$meta" | jq -r '.viban_updated_at')
207
+
208
+ local remote_changed=false viban_changed=false
209
+ [[ "$remote_updated" != "$last_remote_updated" ]] && remote_changed=true
210
+ [[ "$viban_updated" != "$last_viban_updated" ]] && viban_changed=true
211
+
212
+ if [[ "$remote_changed" == "false" && "$viban_changed" == "false" ]]; then
213
+ echo " == ${ext_id} \"${title}\" (no changes)"
214
+ ((unchanged++))
215
+ elif [[ "$remote_changed" == "true" && "$viban_changed" == "false" ]]; then
216
+ # Only remote changed -> pull
217
+ if [[ "$dry_run" == "true" ]]; then
218
+ echo " <- ${ext_id} \"${title}\" (remote updated, will pull)"
219
+ else
220
+ local now
221
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
222
+
223
+ local type_val="null"
224
+ [[ -n "$type" && "$type" != "null" ]] && type_val="\"$type\""
225
+
226
+ jq --argjson vid "$viban_id" --arg title "$title" --arg desc "$description" \
227
+ --arg status "$status" --arg priority "$priority" \
228
+ --arg now "$now" --argjson type_val "$type_val" \
229
+ '(.issues[] | select((.id | tonumber) == $vid)) |=
230
+ . + {title: $title, description: $desc, status: $status,
231
+ priority: $priority, type: $type_val, updated_at: $now}' \
232
+ "$VIBAN_JSON" > "${VIBAN_JSON}.tmp" && mv "${VIBAN_JSON}.tmp" "$VIBAN_JSON"
233
+
234
+ set_issue_meta "$viban_id" "$remote_id" "$remote_updated" "$now"
235
+ echo " <- ${ext_id} \"${title}\" (pulled remote changes)"
236
+ fi
237
+ ((updated++))
238
+ elif [[ "$remote_changed" == "false" && "$viban_changed" == "true" ]]; then
239
+ # Only viban changed -> will be handled in push phase
240
+ echo " == ${ext_id} \"${title}\" (local changes, will push)"
241
+ ((unchanged++))
242
+ else
243
+ # Both changed -> conflict resolution (remote wins by default)
244
+ if [[ "$dry_run" == "true" ]]; then
245
+ echo " !! ${ext_id} \"${title}\" (conflict: remote wins)"
246
+ else
247
+ local now
248
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
249
+
250
+ local type_val="null"
251
+ [[ -n "$type" && "$type" != "null" ]] && type_val="\"$type\""
252
+
253
+ jq --argjson vid "$viban_id" --arg title "$title" --arg desc "$description" \
254
+ --arg status "$status" --arg priority "$priority" \
255
+ --arg now "$now" --argjson type_val "$type_val" \
256
+ '(.issues[] | select((.id | tonumber) == $vid)) |=
257
+ . + {title: $title, description: $desc, status: $status,
258
+ priority: $priority, type: $type_val, updated_at: $now}' \
259
+ "$VIBAN_JSON" > "${VIBAN_JSON}.tmp" && mv "${VIBAN_JSON}.tmp" "$VIBAN_JSON"
260
+
261
+ set_issue_meta "$viban_id" "$remote_id" "$remote_updated" "$now"
262
+ echo " !! ${ext_id} \"${title}\" (conflict: remote wins)"
263
+ fi
264
+ ((pulled++))
265
+ fi
266
+ fi
267
+ done
268
+
269
+ echo "Pull: $pulled new/conflict, $updated updated, $unchanged unchanged"
270
+ }
271
+
272
+ # ============================================================
273
+ # Sync Push (viban -> remote)
274
+ # ============================================================
275
+
276
+ sync_push() {
277
+ local repo="$1" dry_run="${2:-false}" push_new="${3:-false}"
278
+ local pushed=0 closed=0 unchanged=0
279
+
280
+ local provider_prefix
281
+ provider_prefix="$(provider_name):"
282
+
283
+ # Get all viban cards with this provider's external_id
284
+ local linked_cards
285
+ linked_cards=$(jq -r --arg prefix "$provider_prefix" \
286
+ '[.issues[] | select(.external_id != null and (.external_id | startswith($prefix)))]' \
287
+ "$VIBAN_JSON")
288
+
289
+ local count
290
+ count=$(echo "$linked_cards" | jq 'length')
291
+
292
+ for i in $(seq 0 $((count - 1))); do
293
+ local card
294
+ card=$(echo "$linked_cards" | jq ".[$i]")
295
+
296
+ local viban_id ext_id remote_id title status viban_updated
297
+ viban_id=$(echo "$card" | jq -r '.id')
298
+ ext_id=$(echo "$card" | jq -r '.external_id')
299
+ remote_id="${ext_id#${provider_prefix}}"
300
+ title=$(echo "$card" | jq -r '.title')
301
+ status=$(echo "$card" | jq -r '.status')
302
+ viban_updated=$(echo "$card" | jq -r '.updated_at')
303
+
304
+ # Get last known sync timestamps
305
+ local meta
306
+ meta=$(get_issue_meta "$viban_id")
307
+ if [[ -z "$meta" ]]; then
308
+ ((unchanged++))
309
+ continue
310
+ fi
311
+
312
+ local last_viban_updated
313
+ last_viban_updated=$(echo "$meta" | jq -r '.viban_updated_at')
314
+
315
+ if [[ "$viban_updated" == "$last_viban_updated" ]]; then
316
+ ((unchanged++))
317
+ continue
318
+ fi
319
+
320
+ # Viban card changed since last sync -> push
321
+ if [[ "$dry_run" == "true" ]]; then
322
+ echo " -> ${ext_id} \"${title}\" (will push local changes)"
323
+ ((pushed++))
324
+ else
325
+ echo "$card" | jq '{
326
+ title: .title,
327
+ description: (.description // ""),
328
+ status: .status,
329
+ priority: (.priority // "P3"),
330
+ type: (.type // "")
331
+ }' | provider_update_issue "$repo" "$remote_id"
332
+
333
+ local now remote_updated
334
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
335
+ # Fetch updated remote timestamp
336
+ remote_updated="$now"
337
+
338
+ set_issue_meta "$viban_id" "$remote_id" "$remote_updated" "$viban_updated"
339
+ echo " -> ${ext_id} \"${title}\" (pushed)"
340
+ ((pushed++))
341
+ fi
342
+ done
343
+
344
+ # Handle push-new: viban cards without external_id
345
+ if [[ "$push_new" == "true" ]]; then
346
+ local unlinked_cards
347
+ unlinked_cards=$(jq -r \
348
+ '[.issues[] | select(.external_id == null or .external_id == "")]' \
349
+ "$VIBAN_JSON")
350
+
351
+ local unlinked_count
352
+ unlinked_count=$(echo "$unlinked_cards" | jq 'length')
353
+
354
+ for i in $(seq 0 $((unlinked_count - 1))); do
355
+ local card
356
+ card=$(echo "$unlinked_cards" | jq ".[$i]")
357
+
358
+ local viban_id title
359
+ viban_id=$(echo "$card" | jq -r '.id')
360
+ title=$(echo "$card" | jq -r '.title')
361
+
362
+ if [[ "$dry_run" == "true" ]]; then
363
+ echo " -> (new) #${viban_id} \"${title}\" (will create remote issue)"
364
+ ((pushed++))
365
+ else
366
+ local new_remote_id
367
+ new_remote_id=$(echo "$card" | jq '{
368
+ title: .title,
369
+ description: (.description // ""),
370
+ status: .status,
371
+ priority: (.priority // "P3"),
372
+ type: (.type // "")
373
+ }' | provider_create_issue "$repo") || {
374
+ echo " !! Failed to create remote issue for #${viban_id}"
375
+ continue
376
+ }
377
+
378
+ local ext_id="${provider_prefix}${new_remote_id}"
379
+ local now
380
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
381
+
382
+ # Update viban card with external_id
383
+ jq --argjson vid "$viban_id" --arg eid "$ext_id" --arg now "$now" \
384
+ '(.issues[] | select((.id | tonumber) == $vid)) |= . + {external_id: $eid, updated_at: $now}' \
385
+ "$VIBAN_JSON" > "${VIBAN_JSON}.tmp" && mv "${VIBAN_JSON}.tmp" "$VIBAN_JSON"
386
+
387
+ set_issue_meta "$viban_id" "$new_remote_id" "$now" "$now"
388
+ echo " -> ${ext_id} #${viban_id} \"${title}\" (created remote issue)"
389
+ ((pushed++))
390
+ fi
391
+ done
392
+ fi
393
+
394
+ echo "Push: $pushed pushed, $closed closed, $unchanged unchanged"
395
+ }
396
+
397
+ # ============================================================
398
+ # Sync Status
399
+ # ============================================================
400
+
401
+ sync_status() {
402
+ if [[ ! -f "$SYNC_JSON" ]]; then
403
+ echo "Sync not configured. Run: viban sync --init"
404
+ return 1
405
+ fi
406
+
407
+ local meta
408
+ meta=$(read_sync_meta)
409
+
410
+ local provider repo last_sync tracked_count
411
+ provider=$(echo "$meta" | jq -r '.provider')
412
+ repo=$(echo "$meta" | jq -r '.provider_config.repo // "unknown"')
413
+ last_sync=$(echo "$meta" | jq -r '.last_sync_at // "never"')
414
+ tracked_count=$(echo "$meta" | jq '.issues | length')
415
+
416
+ local provider_prefix="${provider}:"
417
+ local total_cards linked_cards unlinked_cards
418
+ total_cards=$(jq '.issues | length' "$VIBAN_JSON")
419
+ linked_cards=$(jq --arg prefix "$provider_prefix" \
420
+ '[.issues[] | select(.external_id != null and (.external_id | startswith($prefix)))] | length' \
421
+ "$VIBAN_JSON")
422
+ unlinked_cards=$((total_cards - linked_cards))
423
+
424
+ echo "Sync status:"
425
+ echo " Provider: $provider ($repo)"
426
+ echo " Last sync: $last_sync"
427
+ echo " Tracked: $tracked_count issues"
428
+ echo " Linked cards: $linked_cards"
429
+ echo " Unlinked cards: $unlinked_cards"
430
+ }
431
+
432
+ # ============================================================
433
+ # Full Sync
434
+ # ============================================================
435
+
436
+ sync_full() {
437
+ local repo="$1" dry_run="${2:-false}" push_new="${3:-false}"
438
+
439
+ echo "Syncing with $(provider_name) ($repo)..."
440
+ sync_pull "$repo" "$dry_run"
441
+ sync_push "$repo" "$dry_run" "$push_new"
442
+
443
+ if [[ "$dry_run" == "false" ]]; then
444
+ # Update last_sync_at
445
+ local now
446
+ now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
447
+ local meta
448
+ meta=$(read_sync_meta)
449
+ meta=$(echo "$meta" | jq --arg now "$now" '.last_sync_at = $now')
450
+ write_sync_meta "$meta"
451
+ fi
452
+ }
453
+
454
+ # ============================================================
455
+ # Main CLI Parser
456
+ # ============================================================
457
+
458
+ main() {
459
+ local action="full"
460
+ local dry_run=false
461
+ local push_new=false
462
+ local pull_only=false
463
+ local push_only=false
464
+ local repo_override=""
465
+
466
+ while [[ $# -gt 0 ]]; do
467
+ case "$1" in
468
+ --init) action="init"; shift ;;
469
+ --status) action="status"; shift ;;
470
+ --dry-run) dry_run=true; shift ;;
471
+ --push-new) push_new=true; shift ;;
472
+ --pull-only) pull_only=true; shift ;;
473
+ --push-only) push_only=true; shift ;;
474
+ --repo) repo_override="$2"; shift 2 ;;
475
+ --provider) shift 2 ;; # Already handled by bin/viban
476
+ *) echo "Unknown option: $1"; exit 1 ;;
477
+ esac
478
+ done
479
+
480
+ # Load provider
481
+ load_provider || exit 1
482
+
483
+ # Handle init
484
+ if [[ "$action" == "init" ]]; then
485
+ sync_init "$repo_override"
486
+ return
487
+ fi
488
+
489
+ # Handle status
490
+ if [[ "$action" == "status" ]]; then
491
+ sync_status
492
+ return
493
+ fi
494
+
495
+ # For sync operations, check that sync is configured
496
+ if [[ ! -f "$SYNC_JSON" ]]; then
497
+ echo "Sync not configured. Run: viban sync --init"
498
+ exit 1
499
+ fi
500
+
501
+ # Check provider deps and auth
502
+ provider_check_deps || exit 1
503
+ provider_check_auth || exit 1
504
+
505
+ # Get repo from sync config or override
506
+ local repo
507
+ if [[ -n "$repo_override" ]]; then
508
+ repo="$repo_override"
509
+ else
510
+ repo=$(jq -r '.provider_config.repo' "$SYNC_JSON")
511
+ fi
512
+
513
+ if [[ -z "$repo" || "$repo" == "null" ]]; then
514
+ echo "Error: No repo configured. Run: viban sync --init"
515
+ exit 1
516
+ fi
517
+
518
+ # Execute sync
519
+ if [[ "$pull_only" == "true" ]]; then
520
+ echo "Pulling from $(provider_name) ($repo)..."
521
+ sync_pull "$repo" "$dry_run"
522
+ elif [[ "$push_only" == "true" ]]; then
523
+ echo "Pushing to $(provider_name) ($repo)..."
524
+ sync_push "$repo" "$dry_run" "$push_new"
525
+ else
526
+ sync_full "$repo" "$dry_run" "$push_new"
527
+ fi
528
+ }
529
+
530
+ main "$@"
@@ -0,0 +1,100 @@
1
+ ---
2
+ name: sync
3
+ description: "Sync viban board with external issue tracker (GitHub, Jira, etc.)"
4
+ ---
5
+
6
+ # /sync - External Issue Tracker Sync
7
+
8
+ Sync the viban board with an external issue tracker. Currently supports GitHub Issues via `gh` CLI.
9
+
10
+ > **Principle**: Show what will happen before doing it. Never sync without user confirmation.
11
+
12
+ ## Input
13
+
14
+ **User Input**: `$ARGUMENTS`
15
+
16
+ ## Step 1: Check Sync Configuration
17
+
18
+ ```bash
19
+ # Check if sync is already configured
20
+ if [ -f "$(git rev-parse --git-common-dir 2>/dev/null || echo .viban)/sync.json" ]; then
21
+ echo "Sync configured"
22
+ viban sync --status
23
+ else
24
+ echo "Sync not configured"
25
+ fi
26
+ ```
27
+
28
+ - If **not configured**, proceed to Step 2
29
+ - If **configured**, skip to Step 3
30
+
31
+ ## Step 2: Initialize Sync
32
+
33
+ ```bash
34
+ viban sync --init
35
+ ```
36
+
37
+ This will:
38
+ - Auto-detect the provider from git remote (defaults to GitHub)
39
+ - Check that `gh` CLI is installed and authenticated
40
+ - Create required labels on the remote repo
41
+ - Initialize `sync.json` metadata
42
+
43
+ If initialization fails, report the error and suggest fixes (install `gh`, run `gh auth login`, etc.).
44
+
45
+ ## Step 3: Preview Changes (Dry Run)
46
+
47
+ ```bash
48
+ viban sync --dry-run
49
+ ```
50
+
51
+ Show the user what will happen:
52
+ - `<-` Issues to pull from remote
53
+ - `->` Cards to push to remote
54
+ - `==` Unchanged items
55
+ - `!!` Conflicts (and resolution strategy)
56
+
57
+ ## Step 4: Confirm and Sync
58
+
59
+ Ask the user for confirmation using AskUserQuestion:
60
+
61
+ - header: "Sync"
62
+ - question: "Apply these sync changes?"
63
+ - options:
64
+ - "Yes, sync now"
65
+ - "Sync and push new local cards too (--push-new)"
66
+ - "Pull only (remote -> local)"
67
+ - "Cancel"
68
+ - multiSelect: false
69
+
70
+ Based on the answer:
71
+
72
+ ```bash
73
+ # Yes, sync now
74
+ viban sync
75
+
76
+ # With push-new
77
+ viban sync --push-new
78
+
79
+ # Pull only
80
+ viban sync --pull-only
81
+ ```
82
+
83
+ ## Step 5: Report Results
84
+
85
+ Show the sync summary:
86
+ ```
87
+ Sync complete:
88
+ Pulled: N new/updated cards from remote
89
+ Pushed: N cards to remote
90
+ Conflicts: N (resolved by: remote wins)
91
+ Unchanged: N
92
+ ```
93
+
94
+ ## Notes
95
+
96
+ - **First sync imports all open issues** as backlog cards with `github:N` external IDs
97
+ - **Conflicts**: when both sides changed, remote wins by default (with warning)
98
+ - **Closed issues**: remote closed issues move viban card to `review` status
99
+ - **Done cards**: `viban done` then sync closes the remote issue
100
+ - **New local cards** are NOT pushed unless `--push-new` is specified (local-first default)