New commands for safely onboarding existing subrepos into the monorepo without losing open PRs: - josh-sync onboard <target>: interactive, resumable 5-step flow (import → wait for merge → reset to new repo) - josh-sync migrate-pr <target> [PR#...] [--all]: migrate PRs from archived repo to new repo via patch application Also refactors create_pr() to wrap create_pr_number(), eliminating duplicated curl/jq logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
828 lines
26 KiB
Bash
Executable File
828 lines
26 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# shellcheck disable=SC1091 # Source paths resolved at runtime
|
|
# bin/josh-sync — CLI entrypoint for josh-sync
|
|
#
|
|
# Usage: josh-sync <command> [flags]
|
|
#
|
|
# Commands:
|
|
# sync Run forward and/or reverse sync
|
|
# preflight Validate config, connectivity, auth
|
|
# import <target> Initial import: pull subrepo into monorepo
|
|
# reset <target> Reset subrepo to josh-filtered view
|
|
# onboard <target> Import existing subrepo into monorepo (interactive)
|
|
# migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
|
# status Show target config and sync state
|
|
# state show|reset Manage sync state directly
|
|
#
|
|
# Global flags:
|
|
# --config FILE Config path (default: .josh-sync.yml)
|
|
# --debug Verbose logging
|
|
# --version Show version
|
|
# --help Show usage
|
|
|
|
set -euo pipefail
|
|
|
|
# ─── Resolve library root ──────────────────────────────────────────
|
|
|
|
if [ -n "${JOSH_SYNC_ROOT:-}" ]; then
|
|
JOSH_LIB_DIR="${JOSH_SYNC_ROOT}/lib"
|
|
else
|
|
JOSH_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../lib" && pwd)"
|
|
fi
|
|
|
|
# Source library modules
|
|
# shellcheck source=../lib/core.sh
|
|
source "${JOSH_LIB_DIR}/core.sh"
|
|
# shellcheck source=../lib/config.sh
|
|
source "${JOSH_LIB_DIR}/config.sh"
|
|
# shellcheck source=../lib/auth.sh
|
|
source "${JOSH_LIB_DIR}/auth.sh"
|
|
# shellcheck source=../lib/state.sh
|
|
source "${JOSH_LIB_DIR}/state.sh"
|
|
# shellcheck source=../lib/sync.sh
|
|
source "${JOSH_LIB_DIR}/sync.sh"
|
|
# shellcheck source=../lib/onboard.sh
|
|
source "${JOSH_LIB_DIR}/onboard.sh"
|
|
|
|
# ─── Version ────────────────────────────────────────────────────────
|
|
|
|
josh_sync_version() {
|
|
local version_file
|
|
if [ -n "${JOSH_SYNC_ROOT:-}" ]; then
|
|
version_file="${JOSH_SYNC_ROOT}/VERSION"
|
|
else
|
|
version_file="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/VERSION"
|
|
fi
|
|
if [ -f "$version_file" ]; then
|
|
cat "$version_file"
|
|
else
|
|
echo "dev"
|
|
fi
|
|
}
|
|
|
|
# ─── Usage ──────────────────────────────────────────────────────────
|
|
|
|
usage() {
|
|
cat >&2 <<EOF
|
|
josh-sync $(josh_sync_version) — bidirectional monorepo ↔ subrepo sync via josh-proxy
|
|
|
|
Usage: josh-sync <command> [flags]
|
|
|
|
Commands:
|
|
sync Run forward and/or reverse sync
|
|
preflight Validate config, connectivity, auth, workflow coverage
|
|
import <target> Initial import: pull existing subrepo into monorepo (creates PR)
|
|
reset <target> Reset subrepo to josh-filtered view (after merging import PR)
|
|
onboard <target> Import existing subrepo into monorepo (interactive, resumable)
|
|
migrate-pr <target> [PR#...] [--all] Move PRs from archived to new subrepo
|
|
status Show target config and sync state
|
|
state show <target> [branch] Show sync state JSON
|
|
state reset <target> [branch] Reset sync state to {}
|
|
|
|
Global flags:
|
|
--config FILE Config path (default: .josh-sync.yml)
|
|
--debug Verbose logging
|
|
--version Show version and exit
|
|
--help Show this help
|
|
|
|
Sync flags:
|
|
--forward Forward only (mono → subrepo)
|
|
--reverse Reverse only (subrepo → mono)
|
|
--target NAME Filter to target(s) — comma-separated for multiple (env: JOSH_SYNC_TARGET)
|
|
--branch BRANCH Filter to one branch
|
|
|
|
Environment:
|
|
JOSH_SYNC_TARGET Restrict to a single target name
|
|
JOSH_SYNC_STATE_BRANCH State branch name (default: josh-sync-state)
|
|
SYNC_BOT_USER Git username for auth
|
|
SYNC_BOT_TOKEN Token with repo scope
|
|
SUBREPO_TOKEN Subrepo-specific token (optional)
|
|
SUBREPO_SSH_KEY SSH key for SSH targets (optional)
|
|
EOF
|
|
}
|
|
|
|
# ─── Sync Command ───────────────────────────────────────────────────
|
|
|
|
cmd_sync() {
|
|
local direction="both"
|
|
local filter_target="${JOSH_SYNC_TARGET:-}"
|
|
local filter_branch=""
|
|
local config_file=".josh-sync.yml"
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--forward) direction="forward"; shift ;;
|
|
--reverse) direction="reverse"; shift ;;
|
|
--target) filter_target="$2"; shift 2 ;;
|
|
--branch) filter_branch="$2"; shift 2 ;;
|
|
--config) config_file="$2"; shift 2 ;;
|
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
|
*) die "Unknown flag: $1" ;;
|
|
esac
|
|
done
|
|
|
|
parse_config "$config_file"
|
|
|
|
# Forward sync
|
|
if [ "$direction" = "forward" ] || [ "$direction" = "both" ]; then
|
|
_sync_direction "forward" "$filter_target" "$filter_branch"
|
|
fi
|
|
|
|
# Reverse sync
|
|
if [ "$direction" = "reverse" ] || [ "$direction" = "both" ]; then
|
|
_sync_direction "reverse" "$filter_target" "$filter_branch"
|
|
fi
|
|
}
|
|
|
|
_sync_direction() {
|
|
local direction="$1"
|
|
local filter_target="$2"
|
|
local filter_branch="$3"
|
|
|
|
while read -r TARGET_JSON; do
|
|
local target_name
|
|
target_name=$(echo "$TARGET_JSON" | jq -r '.name')
|
|
|
|
# Filter to specific target if requested
|
|
if [ -n "$filter_target" ] && ! echo ",$filter_target," | grep -q ",${target_name},"; then
|
|
continue
|
|
fi
|
|
|
|
log "INFO" "══════ Target: ${target_name} (${direction}) ══════"
|
|
load_target "$TARGET_JSON"
|
|
|
|
# Resolve branches
|
|
local branches
|
|
if [ -n "$filter_branch" ]; then
|
|
branches="$filter_branch"
|
|
elif [ "$direction" = "reverse" ]; then
|
|
# Exclude forward_only branches for reverse sync
|
|
branches=$(echo "$TARGET_JSON" | jq -r '
|
|
(.forward_only // []) as $fwd |
|
|
.branches | keys[] | select(. as $b | $fwd | index($b) | not)
|
|
')
|
|
else
|
|
branches=$(echo "$TARGET_JSON" | jq -r '.branches | keys[]')
|
|
fi
|
|
|
|
# Sync each branch
|
|
for branch in $branches; do
|
|
echo "" >&2
|
|
log "INFO" "━━━ Processing branch: ${branch} (${direction}) ━━━"
|
|
|
|
# Resolve branch mapping
|
|
local mapped
|
|
mapped=$(echo "$TARGET_JSON" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
|
if [ -z "$mapped" ]; then
|
|
log "WARN" "No mapping for branch ${branch} — skipping"
|
|
continue
|
|
fi
|
|
|
|
export SYNC_BRANCH_MONO="$branch"
|
|
export SYNC_BRANCH_SUBREPO="$mapped"
|
|
|
|
# Check state BEFORE cloning (skip if unchanged)
|
|
local state prev_sha current_sha
|
|
state=$(read_state "$branch")
|
|
|
|
if [ "$direction" = "forward" ]; then
|
|
prev_sha=$(echo "$state" | jq -r '.last_forward.mono_sha // empty')
|
|
current_sha=$(git rev-parse "origin/${branch}" 2>/dev/null || echo "unknown")
|
|
|
|
if [ "$prev_sha" = "$current_sha" ] && [ -n "$prev_sha" ]; then
|
|
log "INFO" "Branch ${branch} unchanged since last sync — skipping"
|
|
continue
|
|
fi
|
|
else
|
|
prev_sha=$(echo "$state" | jq -r '.last_reverse.subrepo_sha // empty')
|
|
current_sha=$(subrepo_ls_remote "${mapped}")
|
|
|
|
if [ -z "$current_sha" ]; then
|
|
log "WARN" "Subrepo branch ${mapped} doesn't exist — skipping"
|
|
continue
|
|
fi
|
|
|
|
if [ "$prev_sha" = "$current_sha" ] && [ -n "$prev_sha" ]; then
|
|
log "INFO" "Subrepo branch ${mapped} unchanged — skipping"
|
|
continue
|
|
fi
|
|
fi
|
|
|
|
# Run sync
|
|
local result
|
|
if [ "$direction" = "forward" ]; then
|
|
result=$(forward_sync)
|
|
else
|
|
result=$(reverse_sync)
|
|
fi
|
|
log "INFO" "Result: ${result}"
|
|
|
|
# Handle warnings
|
|
if [ "$result" = "lease-rejected" ]; then
|
|
echo "::warning::Target ${target_name}, branch ${branch}: subrepo changed during sync — will retry"
|
|
continue
|
|
fi
|
|
if [ "$result" = "conflict" ]; then
|
|
echo "::warning::Target ${target_name}, branch ${branch}: merge conflict — PR created on subrepo"
|
|
fi
|
|
if [ "$result" = "josh-rejected" ]; then
|
|
echo "::error::Target ${target_name}, branch ${branch}: josh rejected push — check proxy logs"
|
|
continue
|
|
fi
|
|
|
|
# Update state (only on success)
|
|
local new_state
|
|
if [ "$direction" = "forward" ]; then
|
|
local subrepo_sha_now
|
|
subrepo_sha_now=$(subrepo_ls_remote "${mapped}")
|
|
new_state=$(jq -n \
|
|
--arg m_sha "$current_sha" \
|
|
--arg s_sha "${subrepo_sha_now:-}" \
|
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
--arg status "$result" \
|
|
--argjson prev "$state" \
|
|
'$prev + {last_forward: {mono_sha:$m_sha, subrepo_sha:$s_sha, timestamp:$ts, status:$status}}')
|
|
else
|
|
local mono_sha_now
|
|
mono_sha_now=$(git rev-parse "origin/${branch}" 2>/dev/null || echo "")
|
|
new_state=$(jq -n \
|
|
--arg s_sha "$current_sha" \
|
|
--arg m_sha "${mono_sha_now:-}" \
|
|
--arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
--arg status "$result" \
|
|
--argjson prev "$state" \
|
|
'$prev + {last_reverse: {subrepo_sha:$s_sha, mono_sha:$m_sha, timestamp:$ts, status:$status}}')
|
|
fi
|
|
|
|
write_state "$branch" "$new_state"
|
|
done
|
|
done < <(echo "$JOSH_SYNC_TARGETS" | jq -c '.[]')
|
|
}
|
|
|
|
# ─── Preflight Command ─────────────────────────────────────────────
|
|
|
|
cmd_preflight() {
|
|
local config_file=".josh-sync.yml"
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) config_file="$2"; shift 2 ;;
|
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
|
*) die "Unknown flag: $1" ;;
|
|
esac
|
|
done
|
|
|
|
local RED='\033[0;31m'
|
|
local GREEN='\033[0;32m'
|
|
local YELLOW='\033[1;33m'
|
|
local NC='\033[0m'
|
|
|
|
pass() { echo -e " ${GREEN}✓${NC} $1"; }
|
|
fail() { echo -e " ${RED}✗${NC} $1"; ERRORS=$((ERRORS + 1)); }
|
|
warn() { echo -e " ${YELLOW}!${NC} $1"; }
|
|
|
|
local ERRORS=0
|
|
|
|
echo "Josh Sync — Pre-flight Check"
|
|
echo "============================="
|
|
echo ""
|
|
|
|
# 1. Config
|
|
echo "1. Configuration"
|
|
|
|
if [ ! -f "$config_file" ]; then
|
|
fail "${config_file} not found (run from monorepo root)"
|
|
exit 1
|
|
fi
|
|
pass "${config_file} exists"
|
|
|
|
parse_config "$config_file"
|
|
|
|
# shellcheck disable=SC2015 # A && B || C is intentional — pass/warn/fail never fail
|
|
{
|
|
[ -n "$JOSH_PROXY_URL" ] && pass "proxy_url: ${JOSH_PROXY_URL}" || fail "proxy_url missing"
|
|
[ -n "$MONOREPO_PATH" ] && pass "monorepo_path: ${MONOREPO_PATH}" || fail "monorepo_path missing"
|
|
[ -n "$BOT_TRAILER" ] && pass "trailer: ${BOT_TRAILER}" || fail "trailer missing"
|
|
[ -n "${GITEA_TOKEN:-}" ] && pass "SYNC_BOT_TOKEN is set" || warn "SYNC_BOT_TOKEN missing in .env"
|
|
[ -n "${BOT_USER:-}" ] && pass "BOT_USER: ${BOT_USER}" || warn "BOT_USER missing in .env"
|
|
}
|
|
|
|
local target_count
|
|
target_count=$(echo "$JOSH_SYNC_TARGETS" | jq 'length')
|
|
pass "Targets configured: ${target_count}"
|
|
|
|
# 2. Per-target checks
|
|
echo ""
|
|
echo "2. Targets"
|
|
|
|
while read -r TARGET_JSON; do
|
|
local target_name subfolder auth
|
|
target_name=$(echo "$TARGET_JSON" | jq -r '.name')
|
|
subfolder=$(echo "$TARGET_JSON" | jq -r '.subfolder')
|
|
auth=$(echo "$TARGET_JSON" | jq -r '.subrepo_auth // "https"')
|
|
|
|
echo ""
|
|
echo " ── Target: ${target_name} ──"
|
|
|
|
load_target "$TARGET_JSON"
|
|
|
|
# shellcheck disable=SC2015
|
|
{
|
|
[ -n "$JOSH_FILTER" ] && pass "josh_filter: ${JOSH_FILTER}" || fail "josh_filter missing"
|
|
[ -n "$SUBREPO_URL" ] && pass "subrepo_url: ${SUBREPO_URL}" || fail "subrepo_url missing"
|
|
}
|
|
|
|
# Subfolder exists
|
|
if [ -d "$subfolder" ]; then
|
|
local file_count
|
|
file_count=$(find "$subfolder" -type f | wc -l)
|
|
pass "Subfolder ${subfolder}/ exists (${file_count} files)"
|
|
else
|
|
fail "Subfolder ${subfolder}/ not found"
|
|
if [ -z "${CI:-}" ]; then
|
|
echo " If the subrepo already has content, run: josh-sync import ${target_name}"
|
|
fi
|
|
fi
|
|
|
|
# SSH key check
|
|
if [ "$auth" = "ssh" ]; then
|
|
local local_ssh_key_var
|
|
local_ssh_key_var=$(echo "$TARGET_JSON" | jq -r '.subrepo_ssh_key_var // "SUBREPO_SSH_KEY"')
|
|
if [ -n "${!local_ssh_key_var:-}" ]; then
|
|
pass "SSH key set (${local_ssh_key_var})"
|
|
else
|
|
warn "SSH key missing (${local_ssh_key_var})"
|
|
fi
|
|
fi
|
|
|
|
# Josh proxy connectivity
|
|
if timeout 60 git ls-remote "$(josh_auth_url)" HEAD >/dev/null 2>&1; then
|
|
pass "Josh filter works for ${target_name}"
|
|
else
|
|
fail "Cannot ls-remote through josh for ${target_name}"
|
|
echo " Try: git ls-remote ${JOSH_PROXY_URL}/${MONOREPO_PATH}.git${JOSH_FILTER}.git"
|
|
fi
|
|
|
|
# Subrepo connectivity
|
|
if [ "$auth" = "ssh" ]; then
|
|
local local_ssh_key_var2
|
|
local_ssh_key_var2=$(echo "$TARGET_JSON" | jq -r '.subrepo_ssh_key_var // "SUBREPO_SSH_KEY"')
|
|
if [ -n "${!local_ssh_key_var2:-}" ]; then
|
|
if timeout 10 git ls-remote "$(subrepo_auth_url)" HEAD >/dev/null 2>&1; then
|
|
pass "Subrepo reachable via SSH"
|
|
else
|
|
fail "Cannot ls-remote subrepo via SSH"
|
|
fi
|
|
else
|
|
warn "Skipping subrepo check — no SSH key"
|
|
fi
|
|
else
|
|
if timeout 10 git ls-remote "$(subrepo_auth_url)" HEAD >/dev/null 2>&1; then
|
|
pass "Subrepo reachable via HTTPS"
|
|
else
|
|
fail "Cannot ls-remote subrepo via HTTPS"
|
|
fi
|
|
fi
|
|
|
|
# Branch mapping
|
|
echo "$TARGET_JSON" | jq -r '
|
|
(.forward_only // []) as $fwd |
|
|
.branches | to_entries[] |
|
|
.key as $k | .value as $v |
|
|
" mono/\($k) \(if ($fwd | index($k)) then "→ only" else "↔" end) subrepo/\($v)"
|
|
'
|
|
done < <(echo "$JOSH_SYNC_TARGETS" | jq -c '.[]')
|
|
|
|
# 3. Workflow path coverage
|
|
echo ""
|
|
echo "3. Workflow path coverage"
|
|
|
|
local forward_workflow=".gitea/workflows/josh-sync-forward.yml"
|
|
if [ -f "$forward_workflow" ]; then
|
|
pass "${forward_workflow} exists"
|
|
|
|
local workflow_paths
|
|
workflow_paths=$(yq -r '.on.push.paths[]?' "$forward_workflow" 2>/dev/null \
|
|
| sed 's|/\*\*$||; s|/\*$||' || echo "")
|
|
|
|
while read -r subfolder; do
|
|
if echo "$workflow_paths" | grep -q "^${subfolder}$"; then
|
|
pass "Workflow path covers ${subfolder}"
|
|
else
|
|
warn "Workflow paths missing ${subfolder}/** — add it to ${forward_workflow}"
|
|
fi
|
|
done < <(echo "$JOSH_SYNC_TARGETS" | jq -r '.[].subfolder')
|
|
else
|
|
warn "${forward_workflow} not found (optional for local-only use)"
|
|
fi
|
|
|
|
if [ -f ".gitea/workflows/josh-sync-reverse.yml" ]; then
|
|
pass ".gitea/workflows/josh-sync-reverse.yml exists"
|
|
else
|
|
warn ".gitea/workflows/josh-sync-reverse.yml not found (optional)"
|
|
fi
|
|
|
|
# Summary
|
|
echo ""
|
|
echo "============================="
|
|
if [ "$ERRORS" -eq 0 ]; then
|
|
echo -e "${GREEN}All checks passed.${NC} Ready to deploy."
|
|
else
|
|
echo -e "${RED}${ERRORS} check(s) failed.${NC} Fix issues above before deploying."
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# ─── Import Command ────────────────────────────────────────────────
|
|
|
|
cmd_import() {
|
|
local config_file=".josh-sync.yml"
|
|
local target_name=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) config_file="$2"; shift 2 ;;
|
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
|
-*) die "Unknown flag: $1" ;;
|
|
*) target_name="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$target_name" ]; then
|
|
echo "Usage: josh-sync import <target>" >&2
|
|
parse_config "$config_file"
|
|
echo "Available targets:" >&2
|
|
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
|
|
exit 1
|
|
fi
|
|
|
|
parse_config "$config_file"
|
|
|
|
local target_json
|
|
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
|
|
[ -n "$target_json" ] || die "Target '${target_name}' not found in config"
|
|
|
|
log "INFO" "══════ Import target: ${target_name} ══════"
|
|
load_target "$target_json"
|
|
|
|
local branches
|
|
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
|
|
|
for branch in $branches; do
|
|
local mapped
|
|
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
|
[ -z "$mapped" ] && continue
|
|
|
|
export SYNC_BRANCH_MONO="$branch"
|
|
export SYNC_BRANCH_SUBREPO="$mapped"
|
|
|
|
local result
|
|
result=$(initial_import)
|
|
log "INFO" "Result: ${result}"
|
|
done
|
|
}
|
|
|
|
# ─── Reset Command ─────────────────────────────────────────────────
|
|
|
|
cmd_reset() {
|
|
local config_file=".josh-sync.yml"
|
|
local target_name=""
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) config_file="$2"; shift 2 ;;
|
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
|
-*) die "Unknown flag: $1" ;;
|
|
*) target_name="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$target_name" ]; then
|
|
echo "Usage: josh-sync reset <target>" >&2
|
|
parse_config "$config_file"
|
|
echo "Available targets:" >&2
|
|
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
|
|
exit 1
|
|
fi
|
|
|
|
parse_config "$config_file"
|
|
|
|
local target_json
|
|
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
|
|
[ -n "$target_json" ] || die "Target '${target_name}' not found in config"
|
|
|
|
log "INFO" "══════ Reset target: ${target_name} ══════"
|
|
load_target "$target_json"
|
|
|
|
local branches
|
|
branches=$(echo "$target_json" | jq -r '.branches | keys[]')
|
|
|
|
for branch in $branches; do
|
|
local mapped
|
|
mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty')
|
|
[ -z "$mapped" ] && continue
|
|
|
|
export SYNC_BRANCH_MONO="$branch"
|
|
export SYNC_BRANCH_SUBREPO="$mapped"
|
|
|
|
local result
|
|
result=$(subrepo_reset)
|
|
log "INFO" "Result: ${result}"
|
|
done
|
|
}
|
|
|
|
# ─── Status Command ────────────────────────────────────────────────
|
|
|
|
cmd_status() {
|
|
local config_file=".josh-sync.yml"
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) config_file="$2"; shift 2 ;;
|
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
|
*) die "Unknown flag: $1" ;;
|
|
esac
|
|
done
|
|
|
|
parse_config "$config_file"
|
|
|
|
echo "Josh Sync Status"
|
|
echo "================"
|
|
echo ""
|
|
echo "Josh proxy: ${JOSH_PROXY_URL}"
|
|
echo "Monorepo: ${MONOREPO_PATH}"
|
|
echo "Bot: ${BOT_NAME} <${BOT_EMAIL}>"
|
|
echo ""
|
|
|
|
while read -r TARGET_JSON; do
|
|
local target_name subfolder auth
|
|
target_name=$(echo "$TARGET_JSON" | jq -r '.name')
|
|
subfolder=$(echo "$TARGET_JSON" | jq -r '.subfolder')
|
|
auth=$(echo "$TARGET_JSON" | jq -r '.subrepo_auth // "https"')
|
|
|
|
echo "Target: ${target_name}"
|
|
echo " Subfolder: ${subfolder}"
|
|
echo " Subrepo: $(echo "$TARGET_JSON" | jq -r '.subrepo_url')"
|
|
echo " Auth: ${auth}"
|
|
echo " Filter: $(echo "$TARGET_JSON" | jq -r '.josh_filter')"
|
|
|
|
load_target "$TARGET_JSON"
|
|
|
|
echo " Branches:"
|
|
echo "$TARGET_JSON" | jq -r '.branches | to_entries[] | " \(.key) → \(.value)"'
|
|
|
|
local fwd_only
|
|
fwd_only=$(echo "$TARGET_JSON" | jq -r '(.forward_only // []) | join(", ")')
|
|
[ -n "$fwd_only" ] && echo " Forward only: ${fwd_only}"
|
|
|
|
# Show state for each branch
|
|
echo " State:"
|
|
for branch in $(echo "$TARGET_JSON" | jq -r '.branches | keys[]'); do
|
|
local state
|
|
state=$(read_state "$branch")
|
|
if [ "$state" = "{}" ]; then
|
|
echo " ${branch}: (no state)"
|
|
else
|
|
local fwd_status fwd_ts rev_status rev_ts
|
|
fwd_status=$(echo "$state" | jq -r '.last_forward.status // "-"')
|
|
fwd_ts=$(echo "$state" | jq -r '.last_forward.timestamp // "-"')
|
|
rev_status=$(echo "$state" | jq -r '.last_reverse.status // "-"')
|
|
rev_ts=$(echo "$state" | jq -r '.last_reverse.timestamp // "-"')
|
|
echo " ${branch}: fwd=${fwd_status} (${fwd_ts}), rev=${rev_status} (${rev_ts})"
|
|
fi
|
|
done
|
|
echo ""
|
|
done < <(echo "$JOSH_SYNC_TARGETS" | jq -c '.[]')
|
|
}
|
|
|
|
# ─── State Command ─────────────────────────────────────────────────
|
|
|
|
cmd_state() {
|
|
local subcmd="${1:-}"
|
|
shift || true
|
|
|
|
local config_file=".josh-sync.yml"
|
|
local target_name=""
|
|
local branch="main"
|
|
|
|
# Parse remaining args
|
|
local args=()
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) config_file="$2"; shift 2 ;;
|
|
-*) die "Unknown flag: $1" ;;
|
|
*) args+=("$1"); shift ;;
|
|
esac
|
|
done
|
|
|
|
[ "${#args[@]}" -ge 1 ] && target_name="${args[0]}"
|
|
[ "${#args[@]}" -ge 2 ] && branch="${args[1]}"
|
|
|
|
case "$subcmd" in
|
|
show)
|
|
[ -n "$target_name" ] || { echo "Usage: josh-sync state show <target> [branch]" >&2; exit 1; }
|
|
parse_config "$config_file"
|
|
|
|
local target_json
|
|
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
|
|
[ -n "$target_json" ] || die "Target '${target_name}' not found"
|
|
|
|
load_target "$target_json"
|
|
read_state "$branch" | jq '.'
|
|
;;
|
|
reset)
|
|
[ -n "$target_name" ] || { echo "Usage: josh-sync state reset <target> [branch]" >&2; exit 1; }
|
|
parse_config "$config_file"
|
|
|
|
local target_json
|
|
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
|
|
[ -n "$target_json" ] || die "Target '${target_name}' not found"
|
|
|
|
load_target "$target_json"
|
|
write_state "$branch" "{}"
|
|
log "INFO" "State reset for target '${target_name}', branch '${branch}'"
|
|
;;
|
|
*)
|
|
echo "Usage: josh-sync state <show|reset> <target> [branch]" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# ─── Onboard Command ──────────────────────────────────────────────
|
|
|
|
cmd_onboard() {
|
|
local config_file=".josh-sync.yml"
|
|
local target_name=""
|
|
local restart=false
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) config_file="$2"; shift 2 ;;
|
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
|
--restart) restart=true; shift ;;
|
|
-*) die "Unknown flag: $1" ;;
|
|
*) target_name="$1"; shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$target_name" ]; then
|
|
echo "Usage: josh-sync onboard <target> [--restart]" >&2
|
|
parse_config "$config_file"
|
|
echo "Available targets:" >&2
|
|
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
|
|
exit 1
|
|
fi
|
|
|
|
parse_config "$config_file"
|
|
|
|
local target_json
|
|
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
|
|
[ -n "$target_json" ] || die "Target '${target_name}' not found in config"
|
|
|
|
log "INFO" "══════ Onboard target: ${target_name} ══════"
|
|
load_target "$target_json"
|
|
onboard_flow "$target_json" "$restart"
|
|
}
|
|
|
|
# ─── Migrate PR Command ──────────────────────────────────────────
|
|
|
|
cmd_migrate_pr() {
|
|
local config_file=".josh-sync.yml"
|
|
local target_name=""
|
|
local all=false
|
|
local pr_numbers=()
|
|
|
|
while [ $# -gt 0 ]; do
|
|
case "$1" in
|
|
--config) config_file="$2"; shift 2 ;;
|
|
--debug) export JOSH_SYNC_DEBUG=1; shift ;;
|
|
--all) all=true; shift ;;
|
|
-*) die "Unknown flag: $1" ;;
|
|
*)
|
|
if [ -z "$target_name" ]; then
|
|
target_name="$1"
|
|
else
|
|
pr_numbers+=("$1")
|
|
fi
|
|
shift ;;
|
|
esac
|
|
done
|
|
|
|
if [ -z "$target_name" ]; then
|
|
echo "Usage: josh-sync migrate-pr <target> [PR#...] [--all]" >&2
|
|
parse_config "$config_file"
|
|
echo "Available targets:" >&2
|
|
echo "$JOSH_SYNC_TARGETS" | jq -r '.[].name' | sed 's/^/ /' >&2
|
|
exit 1
|
|
fi
|
|
|
|
parse_config "$config_file"
|
|
|
|
local target_json
|
|
target_json=$(echo "$JOSH_SYNC_TARGETS" | jq -c --arg n "$target_name" '.[] | select(.name == $n)')
|
|
[ -n "$target_json" ] || die "Target '${target_name}' not found in config"
|
|
|
|
load_target "$target_json"
|
|
|
|
# Load archived repo info from onboard state
|
|
local onboard_state archived_api
|
|
onboard_state=$(read_onboard_state "$target_name")
|
|
archived_api=$(echo "$onboard_state" | jq -r '.archived_api')
|
|
if [ -z "$archived_api" ] || [ "$archived_api" = "null" ]; then
|
|
die "No archived repo info found. Run 'josh-sync onboard ${target_name}' first."
|
|
fi
|
|
|
|
log "INFO" "Archived repo: ${archived_api}"
|
|
|
|
if [ "$all" = true ]; then
|
|
# Migrate all open PRs from archived repo
|
|
local prs
|
|
prs=$(list_open_prs "$archived_api" "$SUBREPO_TOKEN") \
|
|
|| die "Failed to list PRs on archived repo"
|
|
local count
|
|
count=$(echo "$prs" | jq 'length')
|
|
log "INFO" "Found ${count} open PR(s) on archived repo"
|
|
|
|
echo "$prs" | jq -r '.[] | .number' | while read -r num; do
|
|
migrate_one_pr "$num" || true
|
|
done
|
|
|
|
elif [ ${#pr_numbers[@]} -gt 0 ]; then
|
|
# Migrate specific PR numbers
|
|
for num in "${pr_numbers[@]}"; do
|
|
migrate_one_pr "$num" || true
|
|
done
|
|
|
|
else
|
|
# Interactive: list open PRs, let user pick
|
|
local prs
|
|
prs=$(list_open_prs "$archived_api" "$SUBREPO_TOKEN") \
|
|
|| die "Failed to list PRs on archived repo"
|
|
local count
|
|
count=$(echo "$prs" | jq 'length')
|
|
|
|
if [ "$count" -eq 0 ]; then
|
|
log "INFO" "No open PRs on archived repo"
|
|
return
|
|
fi
|
|
|
|
echo "" >&2
|
|
echo "Open PRs on archived repo:" >&2
|
|
echo "$prs" | jq -r '.[] | " #\(.number): \(.title) (\(.base.ref) <- \(.head.ref))"' >&2
|
|
echo "" >&2
|
|
echo "Enter PR numbers to migrate (space-separated), or 'all':" >&2
|
|
local selection
|
|
read -r selection
|
|
|
|
if [ "$selection" = "all" ]; then
|
|
echo "$prs" | jq -r '.[] | .number' | while read -r num; do
|
|
migrate_one_pr "$num" || true
|
|
done
|
|
else
|
|
for num in $selection; do
|
|
migrate_one_pr "$num" || true
|
|
done
|
|
fi
|
|
fi
|
|
|
|
log "INFO" "PR migration complete"
|
|
}
|
|
|
|
# ─── Main ───────────────────────────────────────────────────────────
|
|
|
|
main() {
|
|
local command="${1:-}"
|
|
shift || true
|
|
|
|
# Handle global flags that can appear before command
|
|
case "$command" in
|
|
--version|-v)
|
|
echo "josh-sync $(josh_sync_version)"
|
|
exit 0
|
|
;;
|
|
--help|-h|"")
|
|
usage
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
case "$command" in
|
|
sync) cmd_sync "$@" ;;
|
|
preflight) cmd_preflight "$@" ;;
|
|
import) cmd_import "$@" ;;
|
|
reset) cmd_reset "$@" ;;
|
|
onboard) cmd_onboard "$@" ;;
|
|
migrate-pr) cmd_migrate_pr "$@" ;;
|
|
status) cmd_status "$@" ;;
|
|
state) cmd_state "$@" ;;
|
|
*)
|
|
echo "Unknown command: ${command}" >&2
|
|
usage
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|