# 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}" 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, 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 } integrationResources { nodes { pullRequest { repoName number } } } } } } } )) end def process_team_issue(team, issue) return if issue.pull_requests.empty? oids = issue.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("integrationResources", "nodes").map do |pr| PullRequest.new( repo_name: pr.dig("pullRequest", "repoName"), number: pr.dig("pullRequest", "number"), ) end, ) 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