Skip to content

Instantly share code, notes, and snippets.

@asterite
Last active February 27, 2023 15:45
Show Gist options
  • Save asterite/2fc71ac9765815a7093e02d7341096f2 to your computer and use it in GitHub Desktop.
Save asterite/2fc71ac9765815a7093e02d7341096f2 to your computer and use it in GitHub Desktop.
A Ruby script that moves Linear issues to staging/production columns
# 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