Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save BarnabeD/9833b69437e7ca13d5b7ea9dbca6ebd5 to your computer and use it in GitHub Desktop.

Select an option

Save BarnabeD/9833b69437e7ca13d5b7ea9dbca6ebd5 to your computer and use it in GitHub Desktop.

Revisions

  1. @asterite asterite created this gist Aug 24, 2022.
    279 changes: 279 additions & 0 deletions move_deployed_linear_issues.rb
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,279 @@
    # 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