Skip to content

Instantly share code, notes, and snippets.

@ericboehs
Last active October 21, 2022 13:35
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save ericboehs/40693c258ac33f9e5b7fdd2b5c48b1dc to your computer and use it in GitHub Desktop.
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)
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
@ericboehs
Copy link
Author

ericboehs commented Oct 6, 2022

Run via:

ZH_TOKEN=abc123 GH_TOKEN=xyz789 ruby sync.rb

Make sure you update the constants near the top or it won't do anything.

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