Skip to content

Instantly share code, notes, and snippets.

@drewr
Last active July 19, 2026 01:04
Show Gist options
  • Select an option

  • Save drewr/9075e531ce45b2c1bbee48c8f303c653 to your computer and use it in GitHub Desktop.

Select an option

Save drewr/9075e531ce45b2c1bbee48c8f303c653 to your computer and use it in GitHub Desktop.
Transfer GitHub issues across repos and orgs
#!/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