Skip to content

Instantly share code, notes, and snippets.

@WanderingDaniel
Forked from keshav-space/transporter.py
Last active May 8, 2025 12:15
Show Gist options
  • Save WanderingDaniel/adda849dd5116045c942c9bb6776f725 to your computer and use it in GitHub Desktop.
Save WanderingDaniel/adda849dd5116045c942c9bb6776f725 to your computer and use it in GitHub Desktop.
Migrate GitHub project between accounts - copying from one existing project to another existing project. Also copies over custom fields created.
#
# Copyright (c) nexB Inc. and others. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
# See http://www.apache.org/licenses/LICENSE-2.0 for the license text.
# See https://aboutcode.org for more information about nexB OSS projects.
#
import getpass
from traceback import format_exc as traceback_format_exc
import requests
# Can be 'ORGANIZATION' or 'USER'
SOURCE_ACCOUNT_TYPE = "USER"
SOURCE_ACCOUNT_NAME = ""
TARGET_ACCOUNT_TYPE = "ORGANIZATION"
TARGET_ACCOUNT_NAME = ""
GITHUB_TOKEN = None # Removed hardcoded token for security reasons
def get_github_api():
global GITHUB_TOKEN
GITHUB_TOKEN = getpass.getpass(
prompt="Enter your GitHub API token (with permission to read and write projects): "
)
def graphql_query(query, variables=None):
url = "https://api.github.com/graphql"
headers = {"Authorization": f"Bearer {GITHUB_TOKEN}", "Accept": "application/vnd.github+json"}
response = requests.post(url, headers=headers, json={"query": query, "variables": variables})
result = response.json()
if response.status_code != 200 or 'errors' in result:
raise Exception(
f"Query failed with status code {response.status_code}. Response: {response.text}"
)
else:
return result
def fetch_project_node_id(account_type, account_name, project_number):
query = f"""
query {{
{account_type.lower()}(login: "{account_name}") {{
projectV2(number: {project_number}) {{
id
}}
}}
}}
"""
data = graphql_query(query)
project = data["data"][account_type.lower()]["projectV2"]
if project is None:
raise Exception(f"Project number {project_number} not found for {account_type} '{account_name}'.")
return project["id"]
def get_project_fields(project_id):
"""Fetches the fields of a project."""
fields = {}
has_next_page = True
cursor = None
while has_next_page:
query = """
query($projectId: ID!, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 50, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
__typename
... on ProjectV2FieldCommon {
id
name
dataType
}
... on ProjectV2SingleSelectField {
id
name
dataType
options {
id
name
}
}
... on ProjectV2IterationField {
id
name
dataType
}
# Other field types as needed
}
}
}
}
}
"""
variables = {"projectId": project_id, "cursor": cursor}
data = graphql_query(query, variables)
fields_data = data["data"]["node"]["fields"]
for field in fields_data["nodes"]:
field_name = field["name"]
fields[field_name] = field
page_info = fields_data["pageInfo"]
has_next_page = page_info["hasNextPage"]
cursor = page_info["endCursor"]
return fields
def fetch_all_project_items_with_fields(project_id):
all_items = []
has_next_page = True
cursor = None
while has_next_page:
query = """
query($projectId: ID!, $cursor: String) {
node(id: $projectId) {
... on ProjectV2 {
items(first: 50, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
id
content {
... on DraftIssue {
id
title
body
}
... on Issue {
id
title
url
}
... on PullRequest {
id
title
url
}
}
fieldValues(first: 50) {
nodes {
__typename
... on ProjectV2ItemFieldSingleSelectValue {
field {
... on ProjectV2SingleSelectField {
name
}
}
name
}
... on ProjectV2ItemFieldTextValue {
field {
... on ProjectV2FieldCommon {
name
}
}
text
}
... on ProjectV2ItemFieldNumberValue {
field {
... on ProjectV2FieldCommon {
name
}
}
number
}
... on ProjectV2ItemFieldDateValue {
field {
... on ProjectV2FieldCommon {
name
}
}
date
}
# Add other field value types as needed
}
}
}
}
}
}
}
"""
variables = {"projectId": project_id, "cursor": cursor}
data = graphql_query(query, variables)
items = data["data"]["node"]["items"]
all_items.extend(items["nodes"])
page_info = items["pageInfo"]
has_next_page = page_info["hasNextPage"]
cursor = page_info["endCursor"]
return all_items
def add_item_to_project(project_id, content_id):
query = """
mutation($projectId: ID!, $contentId: ID!) {
addProjectV2ItemById(input: {projectId: $projectId, contentId: $contentId}) {
item {
id
content {
__typename
}
}
}
}
"""
variables = {"projectId": project_id, "contentId": content_id}
data = graphql_query(query, variables)
if data.get("data", {}).get("addProjectV2ItemById") is None:
raise Exception(f"Could not add item with ID {content_id} to project {project_id}.")
return data["data"]["addProjectV2ItemById"]["item"]
def create_draft_issue_in_project(project_id, title, body):
query = """
mutation($projectId: ID!, $title: String!, $body: String!) {
addProjectV2DraftIssue(input: {projectId: $projectId, title: $title, body: $body}) {
projectItem {
id
}
}
}
"""
variables = {"projectId": project_id, "title": title, "body": body}
data = graphql_query(query, variables)
if data.get("data", {}).get("addProjectV2DraftIssue") is None:
raise Exception(f"Could not create draft issue with title {title} in project {project_id}.")
return data["data"]["addProjectV2DraftIssue"]["projectItem"]["id"]
def update_project_item_field(project_id, item_id, field_id, value):
"""Updates a field value for a project item."""
# Determine the input type based on the value type
value_input = {}
if isinstance(value, dict) and "optionId" in value:
# For single select fields
value_input["singleSelectOptionId"] = value["optionId"]
elif isinstance(value, str):
# For text fields and date fields
value_input["text"] = value
elif isinstance(value, (int, float)):
# For number fields
value_input["number"] = value
elif isinstance(value, dict) and "date" in value:
# For date fields with more complex structures
value_input["date"] = value["date"]
else:
# Unsupported field type
return
mutation = """
mutation UpdateItemField($input: UpdateProjectV2ItemFieldValueInput!) {
updateProjectV2ItemFieldValue(input: $input) {
projectV2Item {
id
}
}
}
"""
variables = {
"input": {
"projectId": project_id,
"itemId": item_id,
"fieldId": field_id,
"value": value_input
}
}
data = graphql_query(mutation, variables)
if data.get("data", {}).get("updateProjectV2ItemFieldValue") is None:
raise Exception(f"Could not update field {field_id} for item {item_id}.")
def get_project_url(project_id, account_type, account_name):
query = """
query($id: ID!) {
node(id: $id) {
... on ProjectV2 {
title
number
}
}
}
"""
data = graphql_query(query, variables={"id": project_id})
if data.get('errors'):
raise Exception(f"GraphQL error: {data['errors']}")
node = data.get("data", {}).get("node")
if not node:
raise Exception(f"No project found with ID {project_id}")
number = node.get("number")
if number is None:
raise Exception(f"Project with ID {project_id} does not have a number.")
a_type = "users" if account_type == "USER" else "orgs"
return f"https://github.com/{a_type}/{account_name}/projects/{number}"
def handler():
try:
get_github_api()
source_project_number = int(input("Enter source project #: "))
target_project_number = int(input("Enter target project #: "))
# Fetch source and target project IDs
source_project_id = fetch_project_node_id(
SOURCE_ACCOUNT_TYPE,
SOURCE_ACCOUNT_NAME,
source_project_number,
)
target_project_id = fetch_project_node_id(
TARGET_ACCOUNT_TYPE,
TARGET_ACCOUNT_NAME,
target_project_number,
)
# Fetch fields for source and target projects
source_fields = get_project_fields(source_project_id)
target_fields = get_project_fields(target_project_id)
# Create a mapping of field names to field IDs between source and target projects
field_mapping = {}
for field_name in source_fields:
if field_name in target_fields:
field_mapping[source_fields[field_name]['id']] = target_fields[field_name]['id']
# Map options for single select fields
option_mapping = {}
for field_name, source_field in source_fields.items():
if source_field['__typename'] == 'ProjectV2SingleSelectField' and field_name in target_fields:
target_field = target_fields[field_name]
if target_field['__typename'] != 'ProjectV2SingleSelectField':
continue
source_options = {opt['name']: opt['id'] for opt in source_field['options']}
target_options = {opt['name']: opt['id'] for opt in target_field['options']}
for opt_name in source_options:
if opt_name in target_options:
source_option_id = source_options[opt_name]
target_option_id = target_options[opt_name]
option_mapping[source_option_id] = target_option_id
items = fetch_all_project_items_with_fields(source_project_id)
# Add items to the existing project and update field values
for item in items:
if "content" not in item:
print(f"Skipping empty item.")
continue
content = item["content"]
if content is None:
print("Skipping item with no content.")
continue
# Determine if the item is a DraftIssue
is_draft_issue = content.get("__typename") == "DraftIssue"
if "id" in content:
content_id = content["id"]
added_item = add_item_to_project(target_project_id, content_id)
new_item_id = added_item["id"]
new_item_type = added_item["content"]["__typename"]
print(f"Added item with ID {content_id} to target project as {new_item_type}.")
else:
# Handle draft issues
draft_title = content.get("title", "Untitled")
draft_body = content.get("body", "")
new_item_id = create_draft_issue_in_project(target_project_id, draft_title, draft_body)
new_item_type = "DraftIssue"
print(f"Created draft issue with title '{draft_title}' in target project.")
# Update field values for the new item
for field_value in item.get("fieldValues", {}).get("nodes", []):
field = field_value.get("field")
if not field:
continue
field_name = field.get("name")
if field_name not in source_fields:
continue
source_field = source_fields[field_name]
source_field_id = source_field["id"]
if source_field_id not in field_mapping:
continue
target_field_id = field_mapping[source_field_id]
value = None
typename = field_value.get("__typename")
# Skip updating 'title' if the item is not a DraftIssue
if field_name.lower() == "title" and not is_draft_issue:
print(f"Skipping update for 'title' field on non-DraftIssue item {new_item_id}.")
continue
if typename == "ProjectV2ItemFieldSingleSelectValue":
source_option_id = None
for opt in source_field.get("options", []):
if opt["name"] == field_value["name"]:
source_option_id = opt["id"]
break
if source_option_id and source_option_id in option_mapping:
target_option_id = option_mapping[source_option_id]
value = {"optionId": target_option_id}
elif typename == "ProjectV2ItemFieldTextValue":
value = field_value.get("text")
elif typename == "ProjectV2ItemFieldNumberValue":
value = field_value.get("number")
elif typename == "ProjectV2ItemFieldDateValue":
value = field_value.get("date")
# Add other field types as needed
if value is not None:
try:
update_project_item_field(target_project_id, new_item_id, target_field_id, value)
print(f"Updated field '{field_name}' for item {new_item_id}.")
except Exception as e:
print(f"Failed to update field '{field_name}' for item {new_item_id}: {e}")
else:
print(f"Could not map value for field '{field_name}'.")
new_project_url = get_project_url(
target_project_id,
TARGET_ACCOUNT_TYPE,
TARGET_ACCOUNT_NAME,
)
print("Project migration completed.")
print(f"Visit the target project: {new_project_url}")
except Exception as e:
print(f"An error occurred: {e} \n{traceback_format_exc()}")
if __name__ == "__main__":
handler()
@mariafisherk1838
Copy link

This worked great for me! The only thing that it balked at was Date fields with this error:

"Did not receive a date value to update a field of type date"

but that's a pretty small issue all told. This saved me tons of time -- Thanks!

@oktaal
Copy link

oktaal commented May 8, 2025

Thanks! It worked great! To get a token which worked for transferring between organizations linking to issues, I had to create a Personal access token (classic).

Then I selected:

image

And:

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment