diff --git a/CHANGELOG.md b/CHANGELOG.md index 50ff58c..7f5ca6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html ## Unreleased ### Compatible changes +* `geordi deploy` will now offer to move deployed issues to a new state if linear_team_ids are configured ### Breaking changes diff --git a/README.md b/README.md index 0bef522..1ce720d 100644 --- a/README.md +++ b/README.md @@ -170,6 +170,8 @@ Finds available Capistrano stages by their prefix, e.g. `geordi deploy p` will deploy production, `geordi deploy mak` will deploy a `makandra` stage if there is a file config/deploy/makandra.rb. +If Linear team ids are configured (see `geordi commit`'), will offer to move deployed issues to a new state. + When your project is running Capistrano 3, deployment will use `cap deploy` instead of `cap deploy:migrations`. You can force using `deploy` by passing the -M option: `geordi deploy -M staging`. diff --git a/features/deploy.feature b/features/deploy.feature index 486e48c..a75db0d 100644 --- a/features/deploy.feature +++ b/features/deploy.feature @@ -5,23 +5,31 @@ Feature: The deploy command And a file named "config/deploy.rb" with "deploy.rb exists" And a file named "config/deploy/staging.rb" with "staging.rb exists" - Scenario: Deploying from master to staging Unfortunately, Aruba cannot run commands truly interactively. We need to answer prompts blindly, and check the output afterwards. + Given the commit with the message "[W-367] Test commit" is going to be deployed + And a file named "tmp/local_settings.yml" with "linear_team_ids: abc23-def456-ghi67" + When I run `geordi deploy` interactively - # Answer three prompts + # Answer prompts And I type "staging" And I type "master" - And I type "" + And I type "target-branch" + And I type "Target Linear State" # Confirm deployment And I type "yes" Then the output should contain: """ # You are about to: + > Merge branch master into target-branch + > Push these commits: + Util.run! git --no-pager log origin/target-branch..master --oneline > Deploy to staging + > Move these Linear issues to state "Target Linear State": + [W-367] Test commit Go ahead with the deployment? [n] """ And the output should contain: @@ -32,7 +40,6 @@ Feature: The deploy command > Deployment complete. """ - Scenario: Deploying the current branch Deploying the current branch requires support by the deployed application: diff --git a/features/step_definitions/miscellaneous_steps.rb b/features/step_definitions/miscellaneous_steps.rb index e3145a8..e2388a1 100644 --- a/features/step_definitions/miscellaneous_steps.rb +++ b/features/step_definitions/miscellaneous_steps.rb @@ -30,6 +30,10 @@ ENV['GEORDI_TESTING_ISSUE_MATCHES'] = 'true' end +Given(/^the commit with the message "(.*)" is going to be deployed$/) do |commit_message| + ENV['GEORDI_TESTING_GIT_COMMIT'] = commit_message +end + After do ENV['GEORDI_TESTING_STAGED_CHANGES'] = 'false' ENV['GEORDI_TESTING_GIT_BRANCHES'] = nil @@ -38,4 +42,5 @@ ENV['GEORDI_TESTING_RUBY_VERSION'] = nil ENV['GEORDI_TESTING_DEFAULT_BRANCH'] = nil ENV['GEORDI_TESTING_ISSUE_MATCHES'] = nil + ENV['GEORDI_TESTING_GIT_COMMIT'] = nil end diff --git a/lib/geordi/commands/branch.rb b/lib/geordi/commands/branch.rb index 1ab577f..6d7d5fe 100644 --- a/lib/geordi/commands/branch.rb +++ b/lib/geordi/commands/branch.rb @@ -9,8 +9,22 @@ option :from_master, aliases: %w[-m --from-main], type: :boolean, desc: 'Branch from master instead of the current branch' def branch - require 'geordi/gitlinear' - Gitlinear.new.branch(from_master: options.from_master) + require 'geordi/linear_client' + require 'geordi/git' + + issue = LinearClient.new.choose_issue + + local_branches = Git.local_branch_names + matching_local_branch = local_branches.find { |branch_name| branch_name == issue['branchName'] } + matching_local_branch ||= local_branches.find { |branch_name| branch_name.include? issue['identifier'].to_s } + + if matching_local_branch + Util.run! ['git', 'checkout', matching_local_branch] + else + default_branch = Git.default_branch + Util.run! ['git', 'checkout', default_branch] if options.from_master + Util.run! ['git', 'checkout', '-b', issue['branchName']] + end Hint.did_you_know [ :commit, diff --git a/lib/geordi/commands/commit.rb b/lib/geordi/commands/commit.rb index 24e79fd..ce5fdba 100644 --- a/lib/geordi/commands/commit.rb +++ b/lib/geordi/commands/commit.rb @@ -9,8 +9,23 @@ LONGDESC def commit(*git_args) - require 'geordi/gitlinear' - Gitlinear.new.commit(git_args) + require 'geordi/linear_client' + require 'geordi/git' + require 'highline' + + Interaction.warn <<~WARNING unless Git.staged_changes? + No staged changes. Will create an empty commit. + WARNING + + linear_client = LinearClient.new + highline = HighLine.new + + issue = linear_client.issue_from_branch || linear_client.choose_issue + title = "[#{issue['identifier']}] #{issue['title']}" + description = "Issue: #{issue['url']}" + extra = highline.ask("\nAdd an optional message").strip + title << ' - ' << extra if extra != '' + Util.run!(['git', 'commit', '--allow-empty', '-m', title, '-m', description, *git_args]) Hint.did_you_know [ :branch, diff --git a/lib/geordi/commands/deploy.rb b/lib/geordi/commands/deploy.rb index d559e63..573144c 100644 --- a/lib/geordi/commands/deploy.rb +++ b/lib/geordi/commands/deploy.rb @@ -28,6 +28,8 @@ deploy production, `geordi deploy mak` will deploy a `makandra` stage if there is a file config/deploy/makandra.rb. +If Linear team ids are configured (see `geordi commit`), will offer to move deployed issues to a new state. + When your project is running Capistrano 3, deployment will use `cap deploy` instead of `cap deploy:migrations`. You can force using `deploy` by passing the -M option: `geordi deploy -M staging`. @@ -39,6 +41,12 @@ desc: 'Set DEPLOY_BRANCH to the current branch during deploy' def deploy(target_stage = nil) + require 'geordi/git' + require 'geordi/linear_client' + + settings = Settings.new + linear_client = LinearClient.new + # Set/Infer default values branch_stage_map = { 'master' => 'staging', 'main' => 'staging', 'production' => 'production' } if target_stage && !Util.deploy_targets.include?(target_stage) @@ -48,7 +56,7 @@ def deploy(target_stage = nil) end # Ask for required information - target_stage ||= Interaction.prompt 'Deployment stage:', branch_stage_map.fetch(Util.current_branch, 'staging') + target_stage ||= Interaction.prompt 'Deployment stage:', branch_stage_map.fetch(Git.current_branch, 'staging') capistrano_config = CapistranoConfig.new(target_stage) if options.current_branch @@ -60,18 +68,25 @@ def deploy(target_stage = nil) set :branch, ENV['DEPLOY_BRANCH'] || 'master' ERROR - source_branch = target_branch = Util.current_branch + source_branch = target_branch = Git.current_branch else # Normal deploy - source_branch = Interaction.prompt 'Source branch:', Util.current_branch + source_branch = Interaction.prompt 'Source branch:', Git.current_branch deploy_branch = capistrano_config.branch - deploy_branch ||= Util.git_default_branch + deploy_branch ||= Git.default_branch target_branch = Interaction.prompt 'Deploy branch:', deploy_branch end + if settings.linear_integration_set_up? + config_state = settings.linear_state_after_deploy(target_stage) + config_state = 'skip' if config_state.empty? + target_state = Interaction.prompt("Move deployed Linear issues to state:", config_state) + target_state = '' if target_state.empty? || target_state == 'skip' + settings.persist_linear_state_after_deploy(target_stage, target_state) + end + merge_needed = (source_branch != target_branch) push_needed = merge_needed || `git cherry -v | wc -l`.strip.to_i > 0 - push_needed = false if Util.testing? # Hard to test Interaction.announce "Checking whether your #{source_branch} branch is ready" ############ Util.run!("git checkout #{source_branch}") @@ -89,13 +104,23 @@ def deploy(target_stage = nil) Interaction.announce 'You are about to:' ################################################# Interaction.note "Merge branch #{source_branch} into #{target_branch}" if merge_needed + linear_issue_ids = [] if push_needed - Interaction.note 'Push these commits:' if push_needed + Interaction.note 'Push these commits:' Util.run!("git --no-pager log origin/#{target_branch}..#{source_branch} --oneline") + + commit_messages = Git.commits_between(source_branch, target_branch) + linear_issue_ids = linear_client.extract_issue_ids(commit_messages) end Interaction.note "Deploy to #{target_stage}" Interaction.note "From current branch #{source_branch}" if options.current_branch + if !linear_issue_ids.empty? && target_state && !target_state.empty? + relevant_commits = linear_client.filter_by_issue_ids(commit_messages, linear_issue_ids) + Interaction.note("Move these Linear issues to state \"#{target_state}\":") + puts relevant_commits.join("\n") + end + if Interaction.prompt('Go ahead with the deployment?', 'n', /y|yes/) puts git_call = [] @@ -115,6 +140,10 @@ def deploy(target_stage = nil) Util.run!(capistrano_call, show_cmd: true) + if !linear_issue_ids.empty? && target_state && !target_state.empty? + linear_client.move_issues_to_state(linear_issue_ids, target_state) + end + Interaction.success 'Deployment complete.' Hint.did_you_know [ diff --git a/lib/geordi/commands/security_update.rb b/lib/geordi/commands/security_update.rb index bfa6eeb..600a3e4 100644 --- a/lib/geordi/commands/security_update.rb +++ b/lib/geordi/commands/security_update.rb @@ -9,7 +9,9 @@ LONGDESC def security_update(step = 'prepare') - master = Util.git_default_branch + require 'geordi/git' + + master = Git.default_branch case step when 'prepare' diff --git a/lib/geordi/git.rb b/lib/geordi/git.rb new file mode 100644 index 0000000..f572ed2 --- /dev/null +++ b/lib/geordi/git.rb @@ -0,0 +1,53 @@ +module Geordi + class Git + class << self + def local_branch_names + @local_branch_names ||= begin + branch_list_string = if Util.testing? + ENV['GEORDI_TESTING_GIT_BRANCHES'].to_s + else + `git branch --format="%(refname:short)"` + end + + branch_list_string.strip.split("\n") + end + end + + def current_branch + if Util.testing? + default_branch + else + `git rev-parse --abbrev-ref HEAD`.strip + end + end + + def staged_changes? + if Util.testing? + ENV['GEORDI_TESTING_STAGED_CHANGES'] == 'true' + else + statuses = `git status --porcelain`.split("\n") + statuses.any? { |l| /^[A-Z]/i =~ l } + end + end + + def default_branch + default_branch = if Util.testing? + ENV['GEORDI_TESTING_DEFAULT_BRANCH'] + else + head_symref = `git ls-remote --symref origin HEAD` + head_symref[%r{\Aref: refs/heads/(\S+)\sHEAD}, 1] + end + + default_branch || 'master' + end + + def commits_between(source_branch, target_branch) + return [ENV['GEORDI_TESTING_GIT_COMMIT']] if Util.testing? + + commits = `git --no-pager log --pretty=format:%s origin/#{target_branch}..#{source_branch}` + + commits&.split("\n") + end + end + end +end diff --git a/lib/geordi/gitlinear.rb b/lib/geordi/linear_client.rb similarity index 62% rename from lib/geordi/gitlinear.rb rename to lib/geordi/linear_client.rb index d7960ec..56fe21e 100644 --- a/lib/geordi/gitlinear.rb +++ b/lib/geordi/linear_client.rb @@ -4,7 +4,7 @@ require 'json' module Geordi - class Gitlinear + class LinearClient # This require-style is to prevent Ruby from loading files of a different # version of Geordi. require File.expand_path('settings', __dir__) @@ -16,47 +16,6 @@ def initialize self.settings = Settings.new end - def commit(git_args) - Interaction.warn <<~WARNING unless Util.staged_changes? - No staged changes. Will create an empty commit. - WARNING - - issue = issue_from_branch || choose_issue - create_commit "[#{issue['identifier']}] #{issue['title']}", "Issue: #{issue['url']}", *git_args - end - - def branch(from_master: false) - issue = choose_issue - - local_branches = local_branch_names - matching_local_branch = local_branches.find { |branch_name| branch_name == issue['branchName'] } - matching_local_branch ||= local_branches.find { |branch_name| branch_name.include? issue['identifier'].to_s } - - if matching_local_branch - Util.run! ['git', 'checkout', matching_local_branch] - else - default_branch = Util.git_default_branch - Util.run! ['git', 'checkout', default_branch] if from_master - Util.run! ['git', 'checkout', '-b', issue['branchName']] - end - end - - private - - attr_accessor :highline, :settings - - def local_branch_names - @local_branch_names ||= begin - branch_list_string = if Util.testing? - ENV['GEORDI_TESTING_GIT_BRANCHES'].to_s - else - `git branch --format="%(refname:short)"` - end - - branch_list_string.strip.split("\n") - end - end - def choose_issue if Util.testing? return dummy_issue_for_testing @@ -94,12 +53,27 @@ def choose_issue nil end + def move_issues_to_state(issue_identifiers, state) + return if Util.testing? + + issues = fetch_linear_issues # This only retrieves issues for the configured linear team ids + state_ids_by_team_id = state_ids_by_team_id(state) + + issue_identifiers.each do |identifier| + issue = issues.find { |i| i['identifier'] == identifier } + + skip unless issue && (state_id = state_ids_by_team_id[issue.dig('team', 'id')]) + + update_issue_state(issue['id'], state_id) + end + end + def issue_from_branch issue = if Util.testing? dummy_issue_for_testing if ENV['GEORDI_TESTING_ISSUE_MATCHES'] == 'true' else - current_branch = Util.current_branch - issue = fetch_linear_issues.find { |issue| issue['branchName'] == current_branch } + current_branch = Git.current_branch + fetch_linear_issues.find { |issue| issue['branchName'] == current_branch } end return unless issue @@ -111,6 +85,30 @@ def issue_from_branch Interaction.prompt("Use it?", "y", /y|yes/i) ? issue : nil end + def extract_issue_ids(commit_messages) + found_ids = [] + + regex = /^\[[A-Z]+\d*-\d+\]/ + + commit_messages&.each do |line| + line&.scan(regex) do |match| + found_ids << match + end + end + + found_ids.map { |id| id.delete('[]') } # [W-365] => W-365 + end + + def filter_by_issue_ids(list_of_strings, issue_ids) + list_of_strings.select do |message| + issue_ids.any? { |id| message.start_with?("[#{id}]") } + end + end + + private + + attr_accessor :highline, :settings + def dummy_issue_for_testing settings.linear_api_key ENV['GEORDI_TESTING_NO_LINEAR_ISSUES'] == 'true' ? Geordi::Interaction.fail('No issues to offer.') : { @@ -123,12 +121,6 @@ def dummy_issue_for_testing } end - def create_commit(title, description, *git_args) - extra = highline.ask("\nAdd an optional message").strip - title << ' - ' << extra if extra != '' - Util.run!(['git', 'commit', '--allow-empty', '-m', title, '-m', description, *git_args]) - end - def fetch_linear_issues @linear_issues ||= begin team_ids = settings.linear_team_ids @@ -150,6 +142,7 @@ def fetch_linear_issues nodes { title identifier + id url branchName assignee { @@ -159,7 +152,10 @@ def fetch_linear_issues state { name position - } + } + team { + id + } } } } @@ -169,6 +165,70 @@ def fetch_linear_issues end end + def state_ids_by_team_id(state_name) + result = {} + + team_ids = settings.linear_team_ids + filter = { + "team": { + "id": { + "in": team_ids, + } + } + } + response = query_api(<<~GRAPHQL, filter: filter) + query workflowStates($filter: WorkflowStateFilter) { + workflowStates(filter: $filter) { + nodes { + id + name + team { + id + name + } + } + } + } + GRAPHQL + + response = response.dig(*%w[workflowStates nodes]) + + team_ids.each do |team_id| + found_state = response.find do |item| + item["team"]["id"] == team_id && item["name"] == state_name + end + + if found_state + result[team_id] = found_state["id"] + else + team_identifier = response.find { |item| item.dig('team', 'id') == team_id }&.dig('team', 'name') || team_id + Interaction.warn("Could not find the state \"#{state_name}\" for team \"#{team_identifier}\". Skipping its issues.") + end + end + + if result.empty? + Interaction.fail("The issue state #{state_name.inspect} does not exist.") + end + + result + end + + def update_issue_state(issue_id, state_id) + query_api(<<~GRAPHQL, nil) + mutation UpdateIssueState { + issueUpdate( + id: "#{issue_id}" + input: { + stateId: "#{state_id}" + } + ) + { + success + } + } + GRAPHQL + end + def query_api(attributes, variables) uri = URI(API_ENDPOINT) loading_message = "Connecting to #{uri.host} ... " diff --git a/lib/geordi/settings.rb b/lib/geordi/settings.rb index ed54be4..189c1d0 100644 --- a/lib/geordi/settings.rb +++ b/lib/geordi/settings.rb @@ -16,7 +16,7 @@ class Settings linear_team_ids ].freeze - ALLOWED_LOCAL_SETTINGS = %w[ linear_team_ids irb_flags ].freeze + ALLOWED_LOCAL_SETTINGS = %w[ linear_team_ids linear_state_after_deploy irb_flags].freeze SETTINGS_WARNED = 'GEORDI_INVALID_SETTINGS_WARNED' @@ -75,6 +75,32 @@ def linear_team_ids team_ids end + def linear_integration_set_up? + team_ids = get_linear_team_ids + !team_ids.empty? + end + + def linear_state_after_deploy(stage) + config_state = @local_settings['linear_state_after_deploy'] + + if config_state && config_state[stage] + config[stage] + else + '' + end + end + + def persist_linear_state_after_deploy(stage, target_state) + config_state = @local_settings.dig('linear_state_after_deploy', stage) + + unless target_state.eql?(config_state) + @local_settings['linear_state_after_deploy'] ||= Hash.new + @local_settings['linear_state_after_deploy'][stage] = target_state + save_local_settings + end + end + + private def read_settings @@ -125,6 +151,16 @@ def save_global_settings end end + def save_local_settings + unless Util.testing? + local_path = LOCAL_SETTINGS_FILE_NAME + + File.open(local_path, 'w') do |file| + file.write @local_settings.to_yaml + end + end + end + def inquire_linear_api_key Geordi::Interaction.note 'Create a personal API key here: https://linear.app/makandra/settings/account/security' token = Geordi::Interaction.prompt("Please enter the API key:") @@ -135,6 +171,13 @@ def inquire_linear_api_key token end + def get_linear_team_ids + local_team_ids = normalize_team_ids(@local_settings['linear_team_ids']) + global_team_ids = normalize_team_ids(@global_settings['linear_team_ids']) + + local_team_ids | global_team_ids + end + def normalize_team_ids(team_ids) case team_ids when Array diff --git a/lib/geordi/util.rb b/lib/geordi/util.rb index 38c5fc9..6b62274 100644 --- a/lib/geordi/util.rb +++ b/lib/geordi/util.rb @@ -119,22 +119,6 @@ def server_command end end - def current_branch - if testing? - git_default_branch - else - `git rev-parse --abbrev-ref HEAD`.strip - end - end - - def staged_changes? - if testing? - ENV['GEORDI_TESTING_STAGED_CHANGES'] == 'true' - else - statuses = `git status --porcelain`.split("\n") - statuses.any? { |l| /^[A-Z]/i =~ l } - end - end def deploy_targets Dir['config/deploy/*'].map do |f| @@ -221,18 +205,6 @@ def cucumber_path?(path) def rspec_path?(path) %r{(^|\/)spec|_spec\.rb($|:)}.match?(path) end - - def git_default_branch - default_branch = if testing? - ENV['GEORDI_TESTING_DEFAULT_BRANCH'] - else - head_symref = `git ls-remote --symref origin HEAD` - head_symref[%r{\Aref: refs/heads/(\S+)\sHEAD}, 1] - end - - default_branch || 'master' - end - end end end diff --git a/spec/linear_client_spec.rb b/spec/linear_client_spec.rb new file mode 100644 index 0000000..a3d7ebf --- /dev/null +++ b/spec/linear_client_spec.rb @@ -0,0 +1,16 @@ +RSpec.describe Geordi::LinearClient do + describe '.extract_linear_issue_id' do + it 'returns extracted issue ids (only from the beginning of the commit message to avoid false matches)' do + commit_messages = ["first example commit", "[W-365] Linear Issue Commit", "Commit with id [A-123] that gets ignored"] + expect(described_class.new.extract_issue_ids(commit_messages)).to eq ["W-365"] + end + end + + describe '.relevant_linear_commit_messages' do + it 'returns all commits starting with any given linear issue id' do + commit_messages = ["first example commit", "[W-365] Linear Issue Commit", "Commit with id [W-365] that gets ignored", "[W-366] Linear Issue Commit 2"] + relevant_ids = %w[W-365 W-366] + expect(described_class.new.filter_by_issue_ids(commit_messages, relevant_ids)).to eq ["[W-365] Linear Issue Commit", "[W-366] Linear Issue Commit 2"] + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 84cdc8b..b1403ab 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -21,6 +21,8 @@ ::Dir.glob('./lib/geordi/*.rb').each { |f| require f } +ENV['GEORDI_TESTING'] = 'true' + # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration RSpec.configure do |config| # rspec-expectations config goes here. You can use an alternate