Last active
October 21, 2022 13:35
-
-
Save ericboehs/40693c258ac33f9e5b7fdd2b5c48b1dc to your computer and use it in GitHub Desktop.
Rude and crude one-way sync of ZenHub Workspaces to GitHub Projects (V2)
This file contains 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
require 'bundler/inline' | |
gemfile do | |
source 'https://rubygems.org' | |
gem 'zenhub_ruby', github: 'Reddshift/zenhub_ruby' | |
gem 'octokit' | |
gem 'pry' | |
end | |
require 'json' | |
require 'zenhub_ruby' | |
ORG = 'department-of-veterans-affairs' | |
REPO = 'va.gov-team' | |
ZH_WORKSPACE_ID = '6335ab9b1901b99243ce7601' | |
GH_LABEL = 'platform-tech-team-3' | |
GH_PROJECT_NUMBER = 807 | |
COLUMN_NAMES_MAP = { | |
'Backlog' => ['Icebox', 'Backlog', 'Upcoming Sprint', 'Current Sprint'], | |
'In Progress' => ['Review/QA', 'Blocked/Has Dependency', 'In Progress'] | |
# 'Blocked' => ['Review/QA', 'Blocked/Has Dependency'] | |
} | |
class SyncZenHubWorkspaceToGitHubProject | |
def run | |
zh_issues_for_workspace # cache / allow for errors before removing | |
gh_remove_all_issues_from_project gh_project_node_id # NOTE: CAREFUL BUDDY | |
# diff;exit | |
zh_issues_for_workspace.to_a.reverse.to_h.map do |zh_column_name, issue_numbers| | |
gh_column_name = gh_column_name_for zh_column_name | |
next unless gh_column_name | |
issue_numbers.each do |issue_number| | |
issue_content_id = gh_issue_content_id_for_adding_issue(REPO, issue_number) | |
issue_node_id = gh_add_issue_to_project(gh_project_node_id, issue_content_id)[:data][:addProjectV2ItemById][:item][:id] | |
status_field = gh_node_for_field gh_project_node_id, 'Status' | |
status_option_id = status_field[:options].find { |option| option[:name] == gh_column_name }[:id] | |
$stdout.puts "Adding ##{issue_number} to #{gh_column_name}." | |
gh_set_issue_field(gh_project_node_id, issue_node_id, status_field[:id], status_option_id) | |
end | |
end | |
end | |
def diff | |
require 'pry'; binding.pry | |
gh_issues_grouped = gh_issue_numbers_for_project_by_status(gh_project_node_id) | |
zh_issues_grouped = zh_issues_for_workspace.map do |zh_column_name, issue_numbers| | |
next unless gh_column_name_for(zh_column_name) | |
next unless issue_numbers.any? | |
[gh_column_name_for(zh_column_name), issue_numbers] | |
end.compact | |
gh_issues_grouped.map { |column_name, issue_numbers| [column_name, issue_numbers] }.to_h | |
end | |
private | |
def gh_column_name_for(zh_column_name) | |
COLUMN_NAMES_MAP.map do |column, value| | |
column if value.include? zh_column_name | |
end.compact.first | |
end | |
def gh_add_issue_to_project(gh_project_node_id, issue_node_id) | |
query = <<-GRAPHQL | |
mutation { | |
addProjectV2ItemById(input: {projectId: "#{gh_project_node_id}" contentId: "#{issue_node_id}"}) {item {id}} | |
} | |
GRAPHQL | |
gh_graphql_query query | |
end | |
def gh_set_issue_field(gh_project_node_id, project_issue_node_id, field_node_id, option_node_id) | |
query = <<-GRAPHQL | |
mutation { | |
updateProjectV2ItemFieldValue( | |
input: { | |
projectId: "#{gh_project_node_id}" | |
itemId: "#{project_issue_node_id}" | |
fieldId: "#{field_node_id}" | |
value: { | |
singleSelectOptionId: "#{option_node_id}" | |
} | |
} | |
) { | |
projectV2Item { | |
id | |
} | |
} | |
} | |
GRAPHQL | |
gh_graphql_query query | |
end | |
def gh_remove_issue_from_project(gh_project_node_id, project_issue_node_id) | |
query = <<-GRAPHQL | |
mutation { | |
deleteProjectV2Item( | |
input: { | |
projectId: "#{gh_project_node_id}" | |
itemId: "#{project_issue_node_id}" | |
} | |
) { | |
deletedItemId | |
} | |
} | |
GRAPHQL | |
gh_graphql_query query | |
end | |
def gh_node_for_field(gh_project_node_id, field_name) | |
query = %Q{ | |
query{ | |
node(id: "#{gh_project_node_id}") { | |
... on ProjectV2 { fields(first: 20) { nodes { ... on ProjectV2Field { id name } ... on ProjectV2IterationField { id name configuration { iterations { startDate id }}} ... on ProjectV2SingleSelectField { id name options { id name }}}}} | |
} | |
} | |
} | |
response = gh_graphql_query query | |
response[:data][:node][:fields][:nodes].find { |field| field[:name] == field_name } | |
end | |
def gh_project_node_id | |
@gh_project_node_id ||= gh_node_id_for_project GH_PROJECT_NUMBER | |
end | |
def gh_node_id_for_project(project_number) | |
query = %Q{query{organization(login: "#{ORG}") {projectV2(number: #{project_number}){id}}}} | |
response = gh_graphql_query query | |
response[:data][:organization][:projectV2][:id] | |
end | |
def gh_issue_content_id_for_adding_issue(repo_name, issue_number) | |
query = %Q{query{repository(owner: "#{ORG}", name: "#{repo_name}") {issue(number: #{issue_number}){id}}}} | |
response = gh_graphql_query query | |
response[:data][:repository][:issue][:id] | |
end | |
def gh_remove_all_issues_from_project(gh_project_node_id) | |
$stdout.puts "Removing all issues from GH Project #{GH_PROJECT_NUMBER}." | |
gh_issue_item_ids_for_project(gh_project_node_id).each do |gh_issue_item_id| | |
gh_remove_issue_from_project gh_project_node_id, gh_issue_item_id | |
end | |
end | |
def gh_issue_numbers_for_project_by_status(gh_project_node_id) | |
gh_issues = gh_issues_for_project(gh_project_node_id) | |
unfiltered_gh_issues_grouped = gh_issues[:data][:node][:items][:nodes].map do |gh_issue| | |
[ | |
gh_issue[:content][:number], | |
gh_issue[:fieldValues][:nodes].find { |field| field[:field][:name] == "Status" rescue nil }[:name] | |
] | |
end.to_h | |
unfiltered_gh_issues_grouped.keys.group_by { |status| unfiltered_gh_issues_grouped.to_h[status] } | |
end | |
def gh_issue_item_ids_for_project(gh_project_node_id) | |
gh_issues = gh_issues_for_project(gh_project_node_id) | |
gh_issues[:data][:node][:items][:nodes].map { |node| node[:id] } | |
end | |
def gh_issues_for_project(gh_project_node_id) | |
query = %Q{ | |
query{ | |
node(id: "#{gh_project_node_id}") { | |
... on ProjectV2 { | |
items(first: 100) { | |
nodes{ | |
id | |
fieldValues(first: 10) { | |
nodes{ | |
... on ProjectV2ItemFieldTextValue { | |
text | |
field { | |
... on ProjectV2FieldCommon { | |
name | |
} | |
} | |
} | |
... on ProjectV2ItemFieldDateValue { | |
date | |
field { | |
... on ProjectV2FieldCommon { | |
name | |
} | |
} | |
} | |
... on ProjectV2ItemFieldSingleSelectValue { | |
name | |
field { | |
... on ProjectV2FieldCommon { | |
name | |
} | |
} | |
} | |
} | |
} | |
content{ | |
... on DraftIssue { | |
title | |
body | |
} | |
...on Issue { | |
title | |
number | |
assignees(first: 10) { | |
nodes{ | |
login | |
} | |
} | |
} | |
...on PullRequest { | |
title | |
number | |
assignees(first: 10) { | |
nodes{ | |
login | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
response = gh_graphql_query query | |
end | |
def gh_issues_for_label | |
@gh_issues_for_label ||= ( | |
response = gh_client.search_issues("label:#{GH_LABEL} is:issue is:open", per_page: 100, page: 1) | |
if response[:total_count] > 100 | |
raise 'Need to implement paginated querying of issues' | |
end | |
response[:items] | |
) | |
end | |
def gh_graphql_query(query) | |
gh_client.post '/graphql', { query: query }.to_json | |
end | |
def gh_client | |
@gh_client = Octokit::Client.new(access_token: ENV['GH_TOKEN']) | |
end | |
def zh_issues_for_workspace | |
@zh_issues_for_workspace ||= ( | |
$stdout.puts "Loading ZenHub Workspace." | |
response = zh_client.workspace_data("#{ORG}/#{REPO}", ZH_WORKSPACE_ID) | |
gh_issue_numbers_for_label = gh_issues_for_label.map { |issue| issue[:number] } | |
response.body['pipelines'].map do |pipeline| | |
issues = pipeline['issues'].map { |issue| issue['issue_number'] } | |
issues = issues.select { |issue| gh_issue_numbers_for_label.include? issue } | |
[pipeline['name'], issues] | |
end.to_h | |
) | |
end | |
def zh_client | |
@zh_client = ZenhubRuby::Client.new(ENV['ZH_TOKEN'], ENV['GH_TOKEN']) | |
end | |
end | |
SyncZenHubWorkspaceToGitHubProject.new.run |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Run via:
Make sure you update the constants near the top or it won't do anything.