Last active
July 19, 2026 01:04
-
-
Save drewr/9075e531ce45b2c1bbee48c8f303c653 to your computer and use it in GitHub Desktop.
Transfer GitHub issues across repos and orgs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # transfer-issue.sh — Transfer a GitHub issue across repos and organizations. | |
| # | |
| # Usage: ./transfer-issue.sh [--force-public] <source-issue-url> <dest-org/repo> | |
| # | |
| # Example: | |
| # ./transfer-issue.sh https://github.com/srcorg/srcrepo/issues/42 destorg/destrepo | |
| # --- Argument parsing --- | |
| FORCE_PUBLIC=false | |
| START_STEP=1 | |
| while [[ "${1:-}" == --* ]]; do | |
| case "$1" in | |
| --force-public) FORCE_PUBLIC=true ;; | |
| --step) START_STEP="$2"; shift 2 ;; | |
| *) echo "Unknown flag: $1" >&2; exit 1 ;; | |
| esac | |
| done | |
| if [[ $# -ne 2 ]]; then | |
| echo "Usage: $0 [--force-public] [--step N] <source-issue-url> <dest-org/repo>" >&2 | |
| exit 1 | |
| fi | |
| SOURCE_URL="$1" | |
| DEST_SPEC="$2" | |
| # --- Parse source URL --- | |
| if [[ ! "$SOURCE_URL" =~ ^https://github\.com/([^/]+)/([^/]+)/issues/([0-9]+)$ ]]; then | |
| echo "Error: Invalid GitHub issue URL: $SOURCE_URL" >&2 | |
| exit 1 | |
| fi | |
| SRC_ORG="${BASH_REMATCH[1]}" | |
| SRC_REPO="${BASH_REMATCH[2]}" | |
| ISSUE_NUM="${BASH_REMATCH[3]}" | |
| # --- Parse destination --- | |
| if [[ ! "$DEST_SPEC" =~ ^([^/]+)/(.+)$ ]]; then | |
| echo "Error: Destination must be org/repo: $DEST_SPEC" >&2 | |
| exit 1 | |
| fi | |
| DEST_ORG="${BASH_REMATCH[1]}" | |
| DEST_REPO="${BASH_REMATCH[2]}" | |
| # ============================================================ | |
| # Helpers | |
| # ============================================================ | |
| fail() { echo "FAILED: $1" >&2; exit 1; } | |
| cleanup_stale_repos() { | |
| local label="$1" | |
| for CHECK_ORG in "$SRC_ORG" "$DEST_ORG"; do | |
| local stale_list | |
| stale_list=$(gh api "orgs/${CHECK_ORG}/repos?per_page=100" --jq '[.[] | select(.name | startswith("tmp-"))] | .[] | .full_name' 2>/dev/null) || continue | |
| if [[ -z "$stale_list" ]]; then continue; fi | |
| while IFS= read -r stale; do | |
| local count | |
| count=$(gh search issues "repo:$stale" --json number --jq 'length' 2>/dev/null) || count=0 | |
| if [[ "$count" -gt 0 ]]; then | |
| echo " $label: skipping $stale — has $count issue(s)" >&2 | |
| continue | |
| fi | |
| echo " $label: deleting stale repo $stale" >&2 | |
| gh repo delete "$stale" --yes >/dev/null 2>&1 || \ | |
| echo " WARNING: could not delete $stale (missing delete_repo scope)" >&2 | |
| done <<< "$stale_list" | |
| done | |
| } | |
| # ============================================================ | |
| # Phase 0: Pre-flight checks | |
| # ============================================================ | |
| echo "=== Pre-flight checks ===" >&2 | |
| # 0a: gh CLI authenticated | |
| gh auth status --hostname github.com >/dev/null 2>&1 || \ | |
| fail "Not authenticated. Run: gh auth login" | |
| # 0b: Source issue exists (skip if resuming — issue may already be transferred) | |
| ISSUE_TITLE=$(gh issue view "$ISSUE_NUM" --repo "$SRC_ORG/$SRC_REPO" --json title --jq '.title' 2>/dev/null) || true | |
| if [[ -z "$ISSUE_TITLE" && "$START_STEP" -eq 1 ]]; then | |
| fail "Cannot access source issue $SOURCE_URL" | |
| fi | |
| # If resuming and title not found in source, try to find it in a temp repo | |
| if [[ -z "$ISSUE_TITLE" ]]; then | |
| PATTERN="tmp_${SRC_ORG}_${SRC_REPO}_${ISSUE_NUM}_" | |
| for CHECK_ORG in "$SRC_ORG" "$DEST_ORG"; do | |
| stale_list=$(gh api "orgs/${CHECK_ORG}/repos?per_page=100" --jq "[.[] | select(.name | startswith(\"${PATTERN}\"))] | .[] | .full_name" 2>/dev/null) || continue | |
| if [[ -n "$stale_list" ]]; then | |
| while IFS= read -r stale; do | |
| ISSUE_TITLE=$(gh search issues "repo:$stale" --json title --jq '.[0].title' 2>/dev/null) || true | |
| if [[ -n "$ISSUE_TITLE" ]]; then | |
| echo " Resumed: found issue title in $stale" >&2 | |
| break 2 | |
| fi | |
| done <<< "$stale_list" | |
| fi | |
| done | |
| [[ -n "$ISSUE_TITLE" ]] || fail "Cannot find issue title (resuming from --step $START_STEP)" | |
| fi | |
| # Build temp repo name: tmp_ORG_REPO_ISSUENUM_TITLESLUG | |
| STRING_PART=$(echo "$ISSUE_TITLE" | cut -c1-20 | tr ' ' '-' | sed 's/[^a-zA-Z0-9-]//g') | |
| TMP_REPO="tmp_${SRC_ORG}_${SRC_REPO}_${ISSUE_NUM}_${STRING_PART}" | |
| # 0c: Source repo exists | |
| gh api "repos/${SRC_ORG}/${SRC_REPO}" >/dev/null 2>&1 || \ | |
| fail "Source repo $SRC_ORG/$SRC_REPO does not exist" | |
| # 0d: Destination repo exists | |
| gh api "repos/${DEST_ORG}/${DEST_REPO}" >/dev/null 2>&1 || \ | |
| fail "Destination repo $DEST_ORG/$DEST_REPO does not exist" | |
| # 0e: Visibility + org check | |
| SRC_PRIVATE=$(gh api "repos/${SRC_ORG}/${SRC_REPO}" --jq '.private') | |
| DEST_PRIVATE=$(gh api "repos/${DEST_ORG}/${DEST_REPO}" --jq '.private') | |
| SAME_ORG=false | |
| [[ "$SRC_ORG" == "$DEST_ORG" ]] && SAME_ORG=true | |
| # Fast path: same org, matching visibility → direct transfer, no temp repo | |
| DIRECT_TRANSFER=false | |
| if [[ "$SAME_ORG" == true && "$SRC_PRIVATE" == "$DEST_PRIVATE" && "$START_STEP" -eq 1 ]]; then | |
| DIRECT_TRANSFER=true | |
| fi | |
| if [[ "$SRC_PRIVATE" == "true" && "$DEST_PRIVATE" != "true" && "$FORCE_PUBLIC" != true ]]; then | |
| fail "Source is private but destination is public. Use --force-public." | |
| fi | |
| # 0f: Admin rights in source org (only needed for temp repo approach) | |
| if [[ "$DIRECT_TRANSFER" != true ]]; then | |
| SRC_ADMIN=$(gh api "orgs/${SRC_ORG}/memberships/$(gh api user --jq '.login')" 2>/dev/null | grep -c '"role":"admin"') || true | |
| if [[ "$SRC_ADMIN" -eq 0 ]]; then | |
| fail "No admin rights in $SRC_ORG (needed to create temp repo)" | |
| fi | |
| fi | |
| # 0g: Admin rights in dest org (cross-org only) | |
| if [[ "$SAME_ORG" != true ]]; then | |
| DEST_ADMIN=$(gh api "orgs/${DEST_ORG}/memberships/$(gh api user --jq '.login')" 2>/dev/null | grep -c '"role":"admin"') || true | |
| if [[ "$DEST_ADMIN" -eq 0 ]]; then | |
| fail "No admin rights in $DEST_ORG (needed to receive repo transfer)" | |
| fi | |
| fi | |
| # 0h: Clean stale temp repos (only if temp repo approach, skip if resuming past cleanup) | |
| if [[ "$DIRECT_TRANSFER" != true && "$START_STEP" -le 1 ]]; then | |
| cleanup_stale_repos "Pre-flight" | |
| # 0i: Check for existing temp repo (deterministic name — reuse if exists) | |
| for CHECK_ORG in "$SRC_ORG" "$DEST_ORG"; do | |
| existing=$(gh api "orgs/${CHECK_ORG}/repos?per_page=100" --jq "[.[] | select(.name == \"${TMP_REPO}\")] | length" 2>/dev/null) || continue | |
| if [[ "$existing" -gt 0 ]]; then | |
| echo " Resumed: temp repo $CHECK_ORG/$TMP_REPO already exists" >&2 | |
| fi | |
| done | |
| fi | |
| # Determine temp repo visibility (only if temp repo approach) | |
| if [[ "$DIRECT_TRANSFER" != true ]]; then | |
| if [[ "$FORCE_PUBLIC" == true ]]; then | |
| REPO_VIS_FLAG="--private" | |
| elif [[ "$DEST_PRIVATE" == "true" ]]; then | |
| REPO_VIS_FLAG="--private" | |
| else | |
| REPO_VIS_FLAG="--public" | |
| fi | |
| fi | |
| # Count steps | |
| if [[ "$DIRECT_TRANSFER" == true ]]; then | |
| TOTAL_STEPS=2 | |
| else | |
| TOTAL_STEPS=5 | |
| [[ "$SAME_ORG" != true ]] && TOTAL_STEPS=$((TOTAL_STEPS + 1)) | |
| [[ "$FORCE_PUBLIC" == true ]] && TOTAL_STEPS=$((TOTAL_STEPS + 1)) | |
| fi | |
| echo " All checks passed." >&2 | |
| echo "" >&2 | |
| echo "Source : $SRC_ORG/$SRC_REPO#$ISSUE_NUM" >&2 | |
| echo "Title : $ISSUE_TITLE" >&2 | |
| echo "Dest : $DEST_ORG/$DEST_REPO" >&2 | |
| if [[ "$DIRECT_TRANSFER" == true ]]; then | |
| echo "Mode : direct transfer (same org, matching visibility)" | |
| else | |
| echo "Temp : $SRC_ORG/$TMP_REPO" >&2 | |
| echo "Vis : $REPO_VIS_FLAG" >&2 | |
| [[ "$FORCE_PUBLIC" == true ]] && echo "Force : --force-public" >&2 | |
| fi | |
| echo "" >&2 | |
| # ============================================================ | |
| # Execution | |
| # ============================================================ | |
| step=0 | |
| TRANSFER_OUTPUT="" | |
| NEW_ISSUE_URL="" | |
| next_step() { | |
| step=$((step + 1)) | |
| if [[ "$step" -lt "$START_STEP" ]]; then | |
| echo "[$step/$TOTAL_STEPS] SKIPPED: $1 (--step $START_STEP)" >&2 | |
| return 1 | |
| fi | |
| echo "[$step/$TOTAL_STEPS] $1" >&2 | |
| return 0 | |
| } | |
| if [[ "$DIRECT_TRANSFER" == true ]]; then | |
| # Step 1: Direct issue transfer | |
| if next_step "Transferring issue #$ISSUE_NUM to $DEST_ORG/$DEST_REPO"; then | |
| TRANSFER_OUTPUT=$(gh issue transfer "$ISSUE_NUM" "$DEST_ORG/$DEST_REPO" --repo "$SRC_ORG/$SRC_REPO" 2>&1) || \ | |
| fail "Could not transfer issue: $TRANSFER_OUTPUT" | |
| echo " Transferred." >&2 | |
| fi | |
| # Step 2: Post origin comment | |
| if next_step "Posting origin comment"; then | |
| NEW_ISSUE_URL=$(echo "$TRANSFER_OUTPUT" | grep -oE 'https://github\.com/[^/]+/[^/]+/issues/[0-9]+' | head -1) | |
| if [[ -n "$NEW_ISSUE_URL" ]]; then | |
| NEW_ISSUE_NUM=$(echo "$NEW_ISSUE_URL" | grep -oE '[0-9]+$') | |
| gh api "repos/${DEST_ORG}/${DEST_REPO}/issues/${NEW_ISSUE_NUM}/comments" \ | |
| -X POST -f "body=Moved from $SOURCE_URL" >/dev/null 2>&1 || \ | |
| echo " WARNING: could not post comment" >&2 | |
| echo " Commented on #$NEW_ISSUE_NUM." >&2 | |
| else | |
| echo " WARNING: could not determine new issue URL, skipping comment" >&2 | |
| fi | |
| fi | |
| else | |
| # Step 1: Create temp repo | |
| if next_step "Creating temp repo $SRC_ORG/$TMP_REPO"; then | |
| gh repo create "$SRC_ORG/$TMP_REPO" "$REPO_VIS_FLAG" >/dev/null 2>&1 || \ | |
| fail "Could not create temp repo" | |
| echo " Created." >&2 | |
| fi | |
| # Step 2: Transfer issue to temp repo | |
| if next_step "Transferring issue #$ISSUE_NUM to temp repo"; then | |
| gh issue transfer "$ISSUE_NUM" "$SRC_ORG/$TMP_REPO" --repo "$SRC_ORG/$SRC_REPO" >/dev/null 2>&1 || \ | |
| fail "Could not transfer issue to temp repo" | |
| echo " Transferred." >&2 | |
| fi | |
| # Step 3 (cross-org only): Transfer temp repo to dest org | |
| if [[ "$SAME_ORG" != true ]]; then | |
| if next_step "Transferring repo to $DEST_ORG"; then | |
| for attempt in $(seq 1 5); do | |
| TRANSFER_RESULT=$(gh api "repos/${SRC_ORG}/${TMP_REPO}/transfer" -X POST -f new_owner="$DEST_ORG" 2>&1) && break | |
| if echo "$TRANSFER_RESULT" | grep -q "previous repository operation is still in progress"; then | |
| echo " GitHub repo lock, retrying in 15s... (attempt $attempt/5)" >&2 | |
| sleep 15 | |
| else | |
| fail "Could not transfer repo to $DEST_ORG: $TRANSFER_RESULT" | |
| fi | |
| done | |
| sleep 5 | |
| echo " Transferred." >&2 | |
| fi | |
| fi | |
| # Step (force-public): Make temp repo public + poll | |
| if [[ "$FORCE_PUBLIC" == true ]]; then | |
| if next_step "Making temp repo public"; then | |
| PATCH_RESULT=$(gh api "repos/${DEST_ORG}/${TMP_REPO}" -X PATCH -f public=true 2>&1) || \ | |
| fail "Could not make temp repo public: $PATCH_RESULT" | |
| for i in $(seq 1 24); do | |
| sleep 5 | |
| vis=$(gh api "repos/${DEST_ORG}/${TMP_REPO}" --jq '.private' 2>/dev/null) || vis="unknown" | |
| if [[ "$vis" == "false" ]]; then | |
| echo " Propagated after $((i * 5))s." >&2 | |
| break | |
| fi | |
| if [[ $i -eq 24 ]]; then | |
| fail "Visibility did not propagate within 120s (current: private=$vis). Check org settings." | |
| fi | |
| echo " Waiting... ($((i * 5))s/120s)" >&2 | |
| done | |
| fi | |
| fi | |
| # Look up actual issue number in temp repo | |
| # Use issue number directly (transferred issues retain their number); | |
| # fall back to title search with proper escaping for special characters. | |
| echo " Looking up issue in temp repo..." >&2 | |
| ACTUAL_ISSUE_NUM="" | |
| # Try by issue number first (transferred issues keep their number) | |
| ACTUAL_ISSUE_NUM=$(gh issue view "$ISSUE_NUM" --repo "$DEST_ORG/$TMP_REPO" --json number --jq '.number' 2>/dev/null) || true | |
| if [[ -z "$ACTUAL_ISSUE_NUM" ]]; then | |
| # Fallback: list all issues in temp repo and match by title via jq | |
| # (avoids shell/GH search interpretation of special chars in title) | |
| JQ_TITLE=$(printf '%s' "$ISSUE_TITLE" | sed 's/\\/\\\\/g; s/"/\\"/g') | |
| ACTUAL_ISSUE_NUM=$(gh issue list --repo "$DEST_ORG/$TMP_REPO" --json number,title --jq ".[] | select(.title == \"$JQ_TITLE\") | .number" 2>/dev/null | head -1) || true | |
| fi | |
| [[ -n "$ACTUAL_ISSUE_NUM" ]] || fail "Could not find issue '$ISSUE_TITLE' in temp repo" | |
| # Transfer issue to final destination | |
| if next_step "Transferring issue #$ACTUAL_ISSUE_NUM to $DEST_ORG/$DEST_REPO"; then | |
| TRANSFER_OUTPUT=$(gh issue transfer "$ACTUAL_ISSUE_NUM" "$DEST_ORG/$DEST_REPO" --repo "$DEST_ORG/$TMP_REPO" 2>&1) || \ | |
| fail "Could not transfer issue to destination: $TRANSFER_OUTPUT" | |
| echo " Transferred." >&2 | |
| fi | |
| # Post origin comment | |
| if next_step "Posting origin comment"; then | |
| NEW_ISSUE_URL=$(echo "$TRANSFER_OUTPUT" | grep -oE 'https://github\.com/[^/]+/[^/]+/issues/[0-9]+' | head -1) | |
| if [[ -n "$NEW_ISSUE_URL" ]]; then | |
| NEW_ISSUE_NUM=$(echo "$NEW_ISSUE_URL" | grep -oE '[0-9]+$') | |
| gh api "repos/${DEST_ORG}/${DEST_REPO}/issues/${NEW_ISSUE_NUM}/comments" \ | |
| -X POST -f "body=Moved from $SOURCE_URL" >/dev/null 2>&1 || \ | |
| echo " WARNING: could not post comment" >&2 | |
| echo " Commented on #$NEW_ISSUE_NUM." >&2 | |
| else | |
| echo " WARNING: could not determine new issue URL, skipping comment" >&2 | |
| fi | |
| fi | |
| # Delete temp repo (ONLY if no issues remain) | |
| if next_step "Cleaning up temp repo"; then | |
| REMAINING=1 | |
| for i in $(seq 1 6); do | |
| REMAINING=$(gh search issues "repo:$DEST_ORG/$TMP_REPO" --json number --jq 'length' 2>/dev/null) || REMAINING=0 | |
| if [[ "$REMAINING" -eq 0 ]]; then break; fi | |
| sleep 3 | |
| done | |
| if [[ "$REMAINING" -gt 0 ]]; then | |
| echo " ABORT: temp repo has $REMAINING issue(s), NOT deleting." >&2 | |
| elif gh repo delete "$DEST_ORG/$TMP_REPO" --yes >/dev/null 2>&1; then | |
| echo " Deleted." >&2 | |
| else | |
| echo " WARNING: could not delete (missing delete_repo scope). Clean up manually:" >&2 | |
| echo " gh repo delete $DEST_ORG/$TMP_REPO --yes" >&2 | |
| fi | |
| fi | |
| fi | |
| echo "" >&2 | |
| if [[ -n "$NEW_ISSUE_URL" ]]; then | |
| echo "Done: $NEW_ISSUE_URL" >&2 | |
| else | |
| echo "Done. Issue is now in $DEST_ORG/$DEST_REPO." >&2 | |
| fi | |
| # Post-flight: clean any remaining stale repos (only if temp repo approach) | |
| if [[ "$DIRECT_TRANSFER" != true ]]; then | |
| echo "=== Post-flight cleanup ===" >&2 | |
| cleanup_stale_repos "Post-flight" | |
| echo " Done." >&2 | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment