Last active
February 27, 2023 15:45
-
-
Save asterite/2fc71ac9765815a7093e02d7341096f2 to your computer and use it in GitHub Desktop.
A Ruby script that moves Linear issues to staging/production columns
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
# Make sure to configure this script! | |
# 1. Change `TEAM_LINEAR` properties below to match your Linear team | |
# 2. Optionally add more teams | |
# 3. Change the value of `DEPLOYED_TO_STAGING_GIT_TAG` to the tag/branch you use for deploys to staging | |
# 4. Change the value of `DEPLOYED_TO_PRODUCTION_GIT_TAG` to the tag/branch you use for deploys to production | |
# | |
# Usage: | |
# LINEAR_API_KEY=... GITHUB_API_KEY=... ruby move_deployed_linear_issues.rb | |
# | |
# Adding new teams (change "LinearTeam" to your team name): | |
# - fetch your team ID with this Graphql query: | |
# query Teams { | |
# teams(filter: {name: {eq: "LinearTeam"}}) { | |
# nodes { | |
# id | |
# name | |
# } | |
# } | |
# } | |
# - fetch your workflow state ids with this Graphql query: | |
# query WorkflowStates { | |
# workflowStates(filter: {team: {name: {eq: "LinearTeam"}}}) { | |
# nodes { | |
# id | |
# name | |
# team { | |
# name | |
# } | |
# } | |
# } | |
# } | |
# - add your team to TEAMS below | |
require "json" | |
require "uri" | |
require "net/http" | |
class GraphqlClient | |
def initialize(endpoint:, key:) | |
@uri = URI.parse(endpoint) | |
@key = key | |
@http = Net::HTTP.new(@uri.host, @uri.port) | |
end | |
def query(query) | |
response = Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == 'https') do |http| | |
request = Net::HTTP::Post.new( | |
@uri.request_uri, | |
"Content-Type" => "application/json", | |
"Authorization" => "Bearer #{@key}", | |
) | |
request.body = {query: query}.to_json | |
http.request(request) | |
end | |
unless response.code.to_i == 200 | |
raise "Got bad status code when requesting #{@uri}: #{response.code}\n#{response.body}" | |
end | |
json = JSON.parse(response.body) | |
if json.key?("errors") | |
raise "Got error on Graphql query: #{json}" | |
end | |
json | |
end | |
end | |
PullRequest = Struct.new(:repo_name, :number, :status, keyword_init: true) | |
CommitDeployStatus = Struct.new(:staging, :production, keyword_init: true) | |
class CommitDeployStatus | |
def deployed? | |
staging || production | |
end | |
end | |
LinearIssue = Struct.new(:id, :number, :title, :state, :pull_requests, keyword_init: true) | |
LinearState = Struct.new(:id, :name, keyword_init: true) | |
Team = Struct.new(:id, :name, :merged_state, :staging_state, :production_state, keyword_init: true) | |
TEAM_LINEAR = Team.new( | |
id: "some team id", | |
name: "some team name", | |
merged_state: LinearState.new( | |
id: "the id of the merged state column", | |
name: "the name of the merged state column", | |
), | |
# this one can be nil if your team doesn't have a staging column | |
staging_state: LinearState.new( | |
id: "the id of the 'staging' state column", | |
name: "the name of the 'staging' state column", | |
), | |
production_state: LinearState.new( | |
id: "the id of the 'production' state column", | |
name: "the name of the 'production' state column", | |
), | |
) | |
TEAMS = [ | |
TEAM_LINEAR, | |
].freeze | |
DEPLOYED_TO_STAGING_GIT_TAG = "origin/the-tag-you-use-for-deploys-to-staging" | |
DEPLOYED_TO_PRODUCTION_GIT_TAG = "origin/the-tag-you-use-for-deploys-to-production" | |
class LinearWorkflowChanger | |
attr_reader :linear | |
attr_reader :github | |
def initialize | |
@linear = GraphqlClient.new( | |
endpoint: "https://api.linear.app/graphql".freeze, | |
key: ENV.fetch("LINEAR_API_KEY"), | |
) | |
@github = GraphqlClient.new( | |
endpoint: "https://api.github.com/graphql".freeze, | |
key: ENV.fetch("GITHUB_API_KEY"), | |
) | |
end | |
def do_it | |
`git fetch` | |
TEAMS.each do |team| | |
puts "Processing Team #{team.name} Linear issues..." | |
do_it_for_team(team) | |
end | |
end | |
def do_it_for_team(team) | |
linear_merged_or_staging_issues(team).each do |issue| | |
process_team_issue(team, issue) | |
end | |
end | |
def linear_merged_or_staging_issues(team) | |
state_names = | |
[team.merged_state, team.staging_state] | |
.compact | |
.map(&:name) | |
fetch_linear_issues(%( | |
query Team { | |
team(id: "#{team.id}") { | |
issues(filter: { | |
state: { | |
name: { | |
in: #{state_names} | |
} | |
} | |
}) { | |
nodes { | |
id | |
number | |
title | |
state { | |
name | |
} | |
attachments { | |
nodes { | |
sourceType | |
metadata | |
} | |
} | |
} | |
} | |
} | |
} | |
)) | |
end | |
def process_team_issue(team, issue) | |
pull_requests = issue.pull_requests.reject { |pr| pr.status == "closed" } | |
return if pull_requests.empty? | |
oids = pull_requests.map { |pr| | |
fetch_github_pr_merge_commit_oid(pr) | |
} | |
# Bail out unless all PRs have been merged | |
return unless oids.all? | |
deploy_statuses = oids.map { |oid| | |
commit_deploy_status(oid) | |
} | |
# If all PRs are in production, move the issue to "Done/Deployed" | |
if deploy_statuses.all?(&:production) | |
set_linear_issue_state(issue, team.production_state) | |
return | |
end | |
staging_state = team.staging_state | |
# Not all teams have a column for "In Staging" | |
return unless staging_state | |
# If the issue in staging already, it won't move anywhere else for now | |
return if issue.state == staging_state.name | |
# There's also nothing else to do unless all PRs are in staging | |
return unless deploy_statuses.all?(&:staging) | |
set_linear_issue_state(issue, staging_state) | |
end | |
def fetch_linear_issues(query) | |
linear | |
.query(query) | |
.dig("data", "team", "issues", "nodes") | |
.map do |issue| | |
LinearIssue.new( | |
id: issue.fetch("id"), | |
number: issue.fetch("number"), | |
title: issue.fetch("title"), | |
state: issue.dig("state", "name"), | |
pull_requests: issue.dig("attachments", "nodes").filter_map { |attachment| | |
next unless attachment["sourceType"] == "github" | |
metadata = attachment["metadata"] | |
PullRequest.new( | |
repo_name: metadata["repoName"], | |
number: metadata["number"], | |
status: metadata["status"], | |
) | |
}, | |
) | |
end | |
end | |
def fetch_github_pr_merge_commit_oid(pull_request) | |
# Fetch the PR's merge commit from GitHub | |
github | |
.query(%( | |
query GitHub { | |
repository(owner: "NoRedInk", name: "#{pull_request.repo_name}") { | |
pullRequest(number: #{pull_request.number}) { | |
mergeCommit { | |
oid | |
} | |
} | |
} | |
} | |
)) | |
.dig("data", "repository", "pullRequest", "mergeCommit", "oid") | |
end | |
def commit_deploy_status(commit_oid) | |
# Check in which remote branches this commit is included. | |
branches = `git branch --contains #{commit_oid} --format='%(refname:short)' -a -r`.split(/\n+/) | |
CommitDeployStatus.new( | |
staging: branches.include?(DEPLOYED_TO_STAGING_GIT_TAG), | |
production: branches.include?(DEPLOYED_TO_PRODUCTION_GIT_TAG), | |
) | |
end | |
def set_linear_issue_state(issue, state) | |
update_linear_issue(issue.id, "stateId", %("#{state.id}")) | |
puts "- Moved issue ##{issue.number} (#{issue.title}) from '#{issue.state}' to '#{state.name}'" | |
end | |
def update_linear_issue(issue_id, key, value) | |
linear.query(%( | |
mutation IssueUpdate { | |
issueUpdate( | |
id: "#{issue_id}", | |
input: { | |
#{key}: #{value} | |
} | |
) { | |
success | |
} | |
} | |
)) | |
end | |
end | |
LinearWorkflowChanger.new.do_it |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment