-
-
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.
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
# | |
# 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() |
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:
And:
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This worked great for me! The only thing that it balked at was Date fields with this error:
but that's a pretty small issue all told. This saved me tons of time -- Thanks!