diff --git a/VERSION b/VERSION index 3eefcb9..9084fa2 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.1.0 diff --git a/bin/josh-sync b/bin/josh-sync index e3c244d..c3a99a1 100755 --- a/bin/josh-sync +++ b/bin/josh-sync @@ -9,6 +9,8 @@ # preflight Validate config, connectivity, auth # import Initial import: pull subrepo into monorepo # reset Reset subrepo to josh-filtered view +# onboard Import existing subrepo into monorepo (interactive) +# migrate-pr [PR#...] [--all] Move PRs from archived to new subrepo # status Show target config and sync state # state show|reset Manage sync state directly # @@ -39,6 +41,8 @@ source "${JOSH_LIB_DIR}/auth.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 ──────────────────────────────────────────────────────── @@ -69,6 +73,8 @@ Commands: preflight Validate config, connectivity, auth, workflow coverage import Initial import: pull existing subrepo into monorepo (creates PR) reset Reset subrepo to josh-filtered view (after merging import PR) + onboard Import existing subrepo into monorepo (interactive, resumable) + migrate-pr [PR#...] [--all] Move PRs from archived to new subrepo status Show target config and sync state state show [branch] Show sync state JSON state reset [branch] Reset sync state to {} @@ -643,6 +649,146 @@ cmd_state() { 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 [--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 [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() { @@ -662,12 +808,14 @@ main() { esac case "$command" in - sync) cmd_sync "$@" ;; - preflight) cmd_preflight "$@" ;; - import) cmd_import "$@" ;; - reset) cmd_reset "$@" ;; - status) cmd_status "$@" ;; - state) cmd_state "$@" ;; + 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 diff --git a/lib/auth.sh b/lib/auth.sh index 229ff88..851f940 100644 --- a/lib/auth.sh +++ b/lib/auth.sh @@ -39,16 +39,15 @@ subrepo_ls_remote() { } # ─── PR Creation ──────────────────────────────────────────────────── -# Shared helper for creating PRs on Gitea/GitHub API. +# Shared helpers for creating PRs on Gitea/GitHub API. # Usage: create_pr <body> +# number=$(create_pr_number <api_url> <token> <base> <head> <title> <body>) +# +# create_pr — fire-and-forget (stdout suppressed, safe inside sync functions) +# create_pr_number — returns the new PR number via stdout -create_pr() { - local api_url="$1" - local token="$2" - local base="$3" - local head="$4" - local title="$5" - local body="$6" +create_pr_number() { + local api_url="$1" token="$2" base="$3" head="$4" title="$5" body="$6" curl -sf -X POST \ -H "Authorization: token ${token}" \ @@ -59,5 +58,36 @@ create_pr() { --arg title "$title" \ --arg body "$body" \ '{base:$base, head:$head, title:$title, body:$body}')" \ - "${api_url}/pulls" >/dev/null + "${api_url}/pulls" | jq -r '.number' +} + +create_pr() { + create_pr_number "$@" >/dev/null +} + +# ─── PR API Helpers ────────────────────────────────────────────── +# Used by onboard and migrate-pr commands. + +# List open PRs on a repo. Returns JSON array. +# Usage: list_open_prs <api_url> <token> +list_open_prs() { + local api_url="$1" token="$2" + curl -sf -H "Authorization: token ${token}" \ + "${api_url}/pulls?state=open&limit=50" +} + +# Get PR diff as plain text. +# Usage: get_pr_diff <api_url> <token> <pr_number> +get_pr_diff() { + local api_url="$1" token="$2" pr_number="$3" + curl -sf -H "Authorization: token ${token}" \ + "${api_url}/pulls/${pr_number}.diff" +} + +# Get single PR as JSON (for checking merge status, metadata, etc.). +# Usage: get_pr <api_url> <token> <pr_number> +get_pr() { + local api_url="$1" token="$2" pr_number="$3" + curl -sf -H "Authorization: token ${token}" \ + "${api_url}/pulls/${pr_number}" } diff --git a/lib/onboard.sh b/lib/onboard.sh new file mode 100644 index 0000000..01a229a --- /dev/null +++ b/lib/onboard.sh @@ -0,0 +1,425 @@ +#!/usr/bin/env bash +# lib/onboard.sh — Onboard orchestration and PR migration +# +# Provides: +# onboard_flow() — Interactive: import → wait for merge → reset to new repo +# migrate_one_pr() — Migrate a single PR from archived repo to new repo +# +# Onboard state is stored on the josh-sync-state branch at <target>/onboard.json. +# Steps: start → importing → waiting-for-merge → resetting → complete +# +# Requires: lib/core.sh, lib/config.sh, lib/auth.sh, lib/state.sh, lib/sync.sh sourced +# Expects: JOSH_SYNC_TARGET_NAME, BOT_NAME, BOT_EMAIL, SUBREPO_API, SUBREPO_TOKEN, etc. + +# ─── Onboard State Helpers ──────────────────────────────────────── +# Follow the same pattern as read_state()/write_state() in lib/state.sh. + +read_onboard_state() { + local target_name="${1:-$JOSH_SYNC_TARGET_NAME}" + git fetch origin "$STATE_BRANCH" 2>/dev/null || true + git show "origin/${STATE_BRANCH}:${target_name}/onboard.json" 2>/dev/null || echo '{}' +} + +write_onboard_state() { + local target_name="${1:-$JOSH_SYNC_TARGET_NAME}" + local state_json="$2" + local key="${target_name}/onboard" + local tmp_dir + tmp_dir=$(mktemp -d) + + if git rev-parse "origin/${STATE_BRANCH}" >/dev/null 2>&1; then + git worktree add "$tmp_dir" "origin/${STATE_BRANCH}" 2>/dev/null + else + git worktree add --detach "$tmp_dir" 2>/dev/null + (cd "$tmp_dir" && git checkout --orphan "$STATE_BRANCH" && { git rm -rf . 2>/dev/null || true; }) + fi + + mkdir -p "$(dirname "${tmp_dir}/${key}.json")" + echo "$state_json" | jq '.' > "${tmp_dir}/${key}.json" + + ( + cd "$tmp_dir" || exit + git add -A + if ! git diff --cached --quiet 2>/dev/null; then + git -c user.name="$BOT_NAME" -c user.email="$BOT_EMAIL" \ + commit -m "onboard: update ${target_name}" + git push origin "HEAD:${STATE_BRANCH}" || log "WARN" "Failed to push onboard state" + fi + ) + + git worktree remove "$tmp_dir" 2>/dev/null || rm -rf "$tmp_dir" +} + +# ─── Derive Archived API URL ───────────────────────────────────── +# Given a URL like "git@host:org/repo-archived.git" or +# "https://host/org/repo-archived.git", derive the Gitea API URL. + +_archived_api_from_url() { + local url="$1" + # Strip .git suffix first — avoids non-greedy regex issues in POSIX ERE + url="${url%.git}" + local host repo_path + + if echo "$url" | grep -qE '^(ssh://|git@)'; then + # SSH URL + if echo "$url" | grep -q '^ssh://'; then + host=$(echo "$url" | sed -E 's|ssh://[^@]*@([^/]+)/.*|\1|') + repo_path=$(echo "$url" | sed -E 's|ssh://[^@]*@[^/]+/(.+)$|\1|') + else + host=$(echo "$url" | sed -E 's|git@([^:/]+)[:/].*|\1|') + repo_path=$(echo "$url" | sed -E 's|git@[^:/]+[:/](.+)$|\1|') + fi + else + # HTTPS URL + host=$(echo "$url" | sed -E 's|https?://([^/]+)/.*|\1|') + repo_path=$(echo "$url" | sed -E 's|https?://[^/]+/(.+)$|\1|') + fi + + echo "https://${host}/api/v1/repos/${repo_path}" +} + +# ─── Onboard Flow ──────────────────────────────────────────────── +# Interactive orchestrator with checkpoint/resume. +# Usage: onboard_flow <target_json> <restart> + +onboard_flow() { + local target_json="$1" + local restart="${2:-false}" + local target_name="$JOSH_SYNC_TARGET_NAME" + + # Load existing onboard state (or empty) + local onboard_state + onboard_state=$(read_onboard_state "$target_name") + local current_step + current_step=$(echo "$onboard_state" | jq -r '.step // "start"') + + if [ "$restart" = true ]; then + log "INFO" "Restarting onboard from scratch" + current_step="start" + onboard_state='{}' + fi + + log "INFO" "Onboard step: ${current_step}" + + # ── Step 1: Prerequisites + archived repo info ── + if [ "$current_step" = "start" ]; then + echo "" >&2 + echo "=== Onboarding ${target_name} ===" >&2 + echo "" >&2 + echo "Before proceeding, you should have:" >&2 + echo " 1. Renamed the existing subrepo (e.g., storefront → storefront-archived)" >&2 + echo " 2. Created a new EMPTY repo at the original URL" >&2 + echo "" >&2 + + # Verify the new (empty) subrepo is reachable (no HEAD ref — works on empty repos) + if git ls-remote "$(subrepo_auth_url)" >/dev/null 2>&1; then + # shellcheck disable=SC2001 # sed is clearer for URL pattern replacement + log "INFO" "New subrepo is reachable at $(echo "$SUBREPO_URL" | sed 's|://[^@]*@|://***@|')" + else + log "WARN" "New subrepo is not reachable — make sure you created the new empty repo" + fi + + echo "Enter the archived repo URL (e.g., git@host:org/repo-archived.git):" >&2 + local archived_url + read -r archived_url + [ -n "$archived_url" ] || die "Archived URL is required" + + # Determine auth type for archived repo (same as current subrepo) + local archived_auth="${SUBREPO_AUTH:-https}" + + # Derive API URL + local archived_api + archived_api=$(_archived_api_from_url "$archived_url") + + # Verify archived repo is reachable via API + if curl -sf -H "Authorization: token ${SUBREPO_TOKEN}" \ + "${archived_api}" >/dev/null 2>&1; then + log "INFO" "Archived repo reachable: ${archived_api}" + else + log "WARN" "Cannot reach archived repo API — check URL and token" + echo "Continue anyway? (y/N):" >&2 + local confirm + read -r confirm + [ "$confirm" = "y" ] || [ "$confirm" = "Y" ] || die "Aborted" + fi + + # Save state + onboard_state=$(jq -n \ + --arg step "importing" \ + --arg archived_api "$archived_api" \ + --arg archived_url "$archived_url" \ + --arg archived_auth "$archived_auth" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{step:$step, archived_api:$archived_api, archived_url:$archived_url, + archived_auth:$archived_auth, import_prs:{}, reset_branches:[], + migrated_prs:[], timestamp:$ts}') + write_onboard_state "$target_name" "$onboard_state" + current_step="importing" + fi + + # ── Step 2: Import (reuses initial_import()) ── + if [ "$current_step" = "importing" ]; then + echo "" >&2 + log "INFO" "Step 2: Importing subrepo content into monorepo..." + + local branches + branches=$(echo "$target_json" | jq -r '.branches | keys[]') + + # Load existing import_prs from state (resume support) + local import_prs + import_prs=$(echo "$onboard_state" | jq -r '.import_prs // {}') + + for branch in $branches; do + local mapped + mapped=$(echo "$target_json" | jq -r --arg b "$branch" '.branches[$b] // empty') + [ -z "$mapped" ] && continue + + # Skip branches that already have an import PR recorded + if echo "$import_prs" | jq -e --arg b "$branch" 'has($b)' >/dev/null 2>&1; then + log "INFO" "Import PR already recorded for ${branch} — skipping" + continue + fi + + export SYNC_BRANCH_MONO="$branch" + export SYNC_BRANCH_SUBREPO="$mapped" + + log "INFO" "Importing branch: ${branch} (subrepo: ${mapped})" + local result + result=$(initial_import) + log "INFO" "Import result for ${branch}: ${result}" + + if [ "$result" = "pr-created" ]; then + # Find the import PR number via API + local prs pr_number + prs=$(list_open_prs "$MONOREPO_API" "$GITEA_TOKEN") + pr_number=$(echo "$prs" | jq -r --arg t "$target_name" --arg b "$branch" \ + '[.[] | select(.title | test("\\[Import\\] " + $t + ":")) | select(.base.ref == $b)] | .[0].number // empty') + + if [ -n "$pr_number" ]; then + import_prs=$(echo "$import_prs" | jq --arg b "$branch" --arg n "$pr_number" '. + {($b): ($n | tonumber)}') + log "INFO" "Import PR for ${branch}: #${pr_number}" + else + log "WARN" "Could not find import PR number for ${branch} — check monorepo PRs" + fi + fi + + # Save progress after each branch (resume support) + onboard_state=$(echo "$onboard_state" | jq --argjson prs "$import_prs" '.import_prs = $prs') + write_onboard_state "$target_name" "$onboard_state" + done + + # Update state + onboard_state=$(echo "$onboard_state" | jq \ + --arg step "waiting-for-merge" \ + --argjson prs "$import_prs" \ + '.step = $step | .import_prs = $prs') + write_onboard_state "$target_name" "$onboard_state" + current_step="waiting-for-merge" + fi + + # ── Step 3: Wait for merge ── + if [ "$current_step" = "waiting-for-merge" ]; then + echo "" >&2 + log "INFO" "Step 3: Waiting for import PR(s) to be merged..." + + local import_prs + import_prs=$(echo "$onboard_state" | jq -r '.import_prs') + local pr_count + pr_count=$(echo "$import_prs" | jq 'length') + + if [ "$pr_count" -eq 0 ]; then + log "WARN" "No import PRs recorded — skipping merge check" + else + echo "" >&2 + echo "Import PRs to merge:" >&2 + echo "$import_prs" | jq -r 'to_entries[] | " \(.key): PR #\(.value)"' >&2 + echo "" >&2 + echo "Merge the import PR(s) on the monorepo, then press Enter..." >&2 + read -r + + # Verify each PR is merged + local all_merged=true + for branch in $(echo "$import_prs" | jq -r 'keys[]'); do + local pr_number + pr_number=$(echo "$import_prs" | jq -r --arg b "$branch" '.[$b]') + local pr_json merged + pr_json=$(get_pr "$MONOREPO_API" "$GITEA_TOKEN" "$pr_number") + merged=$(echo "$pr_json" | jq -r '.merged // false') + + if [ "$merged" = "true" ]; then + log "INFO" "PR #${pr_number} (${branch}): merged" + else + log "ERROR" "PR #${pr_number} (${branch}): NOT merged — merge it first" + all_merged=false + fi + done + + if [ "$all_merged" = false ]; then + die "Not all import PRs are merged. Re-run 'josh-sync onboard ${target_name}' after merging." + fi + fi + + # Update state + onboard_state=$(echo "$onboard_state" | jq '.step = "resetting"') + write_onboard_state "$target_name" "$onboard_state" + current_step="resetting" + fi + + # ── Step 4: Reset (pushes josh-filtered history to new repo) ── + if [ "$current_step" = "resetting" ]; then + echo "" >&2 + log "INFO" "Step 4: Pushing josh-filtered history to new subrepo..." + + local branches + branches=$(echo "$target_json" | jq -r '.branches | keys[]') + local already_reset + already_reset=$(echo "$onboard_state" | jq -r '.reset_branches // []') + + for branch in $branches; do + # Skip branches already reset (resume support) + if echo "$already_reset" | jq -e --arg b "$branch" 'index($b) != null' >/dev/null 2>&1; then + log "INFO" "Branch ${branch} already reset — skipping" + continue + fi + + 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" "Reset result for ${branch}: ${result}" + + # Track progress + onboard_state=$(echo "$onboard_state" | jq --arg b "$branch" \ + '.reset_branches += [$b]') + write_onboard_state "$target_name" "$onboard_state" + done + + # Update state + onboard_state=$(echo "$onboard_state" | jq \ + --arg step "complete" \ + --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '.step = $step | .timestamp = $ts') + write_onboard_state "$target_name" "$onboard_state" + current_step="complete" + fi + + # ── Step 5: Done ── + if [ "$current_step" = "complete" ]; then + echo "" >&2 + echo "=== Onboarding complete! ===" >&2 + echo "" >&2 + echo "The new subrepo now has josh-filtered history." >&2 + echo "Developers should re-clone or reset their local copies:" >&2 + echo " git fetch origin && git reset --hard origin/main" >&2 + echo "" >&2 + echo "To migrate open PRs from the archived repo:" >&2 + echo " josh-sync migrate-pr ${target_name} # interactive picker" >&2 + echo " josh-sync migrate-pr ${target_name} --all # migrate all" >&2 + echo " josh-sync migrate-pr ${target_name} 5 8 12 # specific PRs" >&2 + fi +} + +# ─── Migrate One PR ────────────────────────────────────────────── +# Applies a PR's diff from the archived repo to the new subrepo. +# Usage: migrate_one_pr <pr_number> +# +# Expects: JOSH_SYNC_TARGET_NAME, SUBREPO_API, SUBREPO_TOKEN, BOT_NAME, BOT_EMAIL loaded + +migrate_one_pr() { + local pr_number="$1" + local target_name="$JOSH_SYNC_TARGET_NAME" + + # Read 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 + + # Check if this PR was already migrated + local already_migrated + already_migrated=$(echo "$onboard_state" | jq -r \ + --argjson num "$pr_number" '.migrated_prs // [] | map(select(.old_number == $num)) | length') + if [ "$already_migrated" -gt 0 ]; then + log "INFO" "PR #${pr_number} already migrated — skipping" + return 0 + fi + + # Same credentials — the repo was just renamed + local archived_token="$SUBREPO_TOKEN" + + # 1. Get PR metadata from archived repo + local pr_json title base head body + pr_json=$(get_pr "$archived_api" "$archived_token" "$pr_number") \ + || die "Failed to fetch PR #${pr_number} from archived repo" + title=$(echo "$pr_json" | jq -r '.title') + base=$(echo "$pr_json" | jq -r '.base.ref') + head=$(echo "$pr_json" | jq -r '.head.ref') + body=$(echo "$pr_json" | jq -r '.body // ""') + + log "INFO" "Migrating PR #${pr_number}: \"${title}\" (${base} <- ${head})" + + # 2. Get diff from archived repo + local diff + diff=$(get_pr_diff "$archived_api" "$archived_token" "$pr_number") + if [ -z "$diff" ]; then + log "WARN" "Empty diff for PR #${pr_number} — skipping" + return 1 + fi + + # 3. Clone new subrepo, apply patch + # Save cwd so we can restore it (function runs in caller's shell, not subshell) + local original_dir + original_dir=$(pwd) + + local work_dir + work_dir=$(mktemp -d) + # shellcheck disable=SC2064 # Intentional early expansion + trap "cd '$original_dir' 2>/dev/null; rm -rf '$work_dir'" RETURN + + git clone "$(subrepo_auth_url)" --branch "$base" --single-branch \ + "${work_dir}/subrepo" 2>&1 || die "Failed to clone new subrepo (branch: ${base})" + + cd "${work_dir}/subrepo" || exit + git config user.name "$BOT_NAME" + git config user.email "$BOT_EMAIL" + + git checkout -B "$head" >&2 + + if echo "$diff" | git apply --check 2>/dev/null; then + echo "$diff" | git apply + git add -A + git commit -m "${title} + +Migrated from archived repo PR #${pr_number}" >&2 + + git push "$(subrepo_auth_url)" "$head" >&2 \ + || die "Failed to push branch ${head}" + + # 4. Create PR on new repo + local new_number + new_number=$(create_pr_number "$SUBREPO_API" "$SUBREPO_TOKEN" \ + "$base" "$head" "$title" "$body") + log "INFO" "Migrated PR #${pr_number} -> #${new_number}: \"${title}\"" + + # 5. Record in onboard state + cd "$original_dir" || true + onboard_state=$(read_onboard_state "$target_name") + onboard_state=$(echo "$onboard_state" | jq \ + --argjson old "$pr_number" \ + --argjson new_num "${new_number}" \ + --arg title "$title" \ + '.migrated_prs += [{"old_number":$old, "new_number":$new_num, "title":$title}]') + write_onboard_state "$target_name" "$onboard_state" + else + log "ERROR" "Patch doesn't apply cleanly for PR #${pr_number} — skipping" + log "ERROR" "Manual migration needed: get diff from archived repo and resolve conflicts" + return 1 + fi +}