require "rubygems" require "pry" require "thor" require "open3" require "json" require "terminal-table" require_relative "gh_cli_client.rb" require_relative "daisybill.rb" module GithubWorkflow class Cli < Thor PROCEED_TEXT = "Proceed? [y,yes]: ".freeze TRUNCATE_DEFAULT = 50 include Thor::Actions default_task :start desc "start", "Create branch named with issue number and issue title" method_option :issue_id, aliases: "-i", type: :string, required: true method_option :truncate_branch_at, aliases: "-t", type: :numeric, default: TRUNCATE_DEFAULT method_option :no_checkout, type: :boolean, required: false, default: false def start stash rebase_main create_branch(options[:no_checkout]) stash_pop end desc "create_pr", "Convert Issue to Pull Request" method_option :base_branch, aliases: "-b", type: :string method_option :draft, aliases: "-d", type: :boolean, required: false, default: true def create_pr ensure_origin_exists convert_issue_to_pr end desc "push_and_pr", "Push branch to origin and convert Issue to Pull Request" method_option :base_branch, aliases: "-b", type: :string method_option :draft, aliases: "-d", type: :boolean, required: false, default: true def push_and_pr push_and_set_upstream convert_issue_to_pr end desc "status", "Check PR CI status" def status ensure_origin_exists response = github_client.get_statuses if response.empty? alert "No statuses yet. Have you pushed your branch?" else table = Terminal::Table.new(style: { width: 80 }) do |table_rows| table_rows << %w(CI Status Description) table_rows << :separator response.map { |status| status["context"] }.uniq.map do |status| response.select { |st| st["context"] == status }.sort_by { |st| st["updated_at"] }.last end.each do |status| table_rows << %w(context state description).map { |key| status[key] } end end puts table end end desc "info", "Print out issue description" def info issue = github_client.get_issue(issue_number_from_branch) puts "#{issue["html_url"]}\n\n" puts "[##{issue["number"]}] #{issue["title"]}\n" puts issue["body"] end desc "open", "Open issue or PR in browser" def open open_url(github_client.get_issue(issue_number_from_branch)["html_url"]) end desc "create_and_start", "Create and start issue" method_option :name, aliases: "-m", type: :string, required: true method_option :truncate_branch_at, aliases: "-t", type: :numeric, default: TRUNCATE_DEFAULT method_option :no_checkout, type: :boolean, required: false, default: false def create_and_start create_issue end desc "cleanup", "Remove merged PR branches" def cleanup say_info("Checking merge status") merged_pr_branches puts "\n" if merged_pr_branches.any? say_info("Are you sure you want to delete the branches with checkmarks?") if yes?(PROCEED_TEXT) pass("Deleting Branches") merged_pr_branches.each do |branch| `git branch -D #{branch}` end else failure("Cleanup aborted") end else failure("No merged branches to delete") end end desc "reviews", "Displays count of requested reviews for each user" def reviews reviewers = github_client.get_prs_list.map { |pr| pr["requested_reviewers"].map { |r| r["login"] } }.flatten reviewer_counts = reviewers.group_by { |i| i }.map { |k, v| [k, v.count] } if reviewer_counts.any? table = Terminal::Table.new(style: { width: 50 }) do |table_rows| table_rows << ["Username", "Requested PRs Count"] table_rows << :separator reviewer_counts.each { |reviewer_count| table_rows << reviewer_count } end puts table end end desc "deploy_diff", "view deployment diff between staging and production for apps" method_option :all, aliases: "-a", type: :boolean, default: false def deploy_diff apps = Daisybill::APPS unless options[:all] current_repo = github_client.get_repo_info apps.reject! do |a| a[:github_org] != current_repo[:owner] || a[:github_repo] != current_repo[:repo] end end apps.each do |app| owner = app[:github_org] repo = app[:github_repo] puts "\n**** #{owner}/#{repo} ****\n" full_diff = `heroku pipelines:diff -a #{app[:heroku_staging_app]}` puts full_diff revisions = full_diff.split("compare/").last.strip.split("...") latest_commit = github_client.latest_remote_commit(owner: owner, repo: repo) next if revisions.size < 2 if latest_commit != revisions.last puts "WARNING - heroku staging app deploy is behind at commit #{revisions.last}. The repository is at commit #{latest_commit}" end puts formatted_deploy_notes(revisions.join('...'), owner: owner, repo: repo) end end desc "deploy_notes", "Generate Deploy notes for a range of commits" method_option :commit_range, aliases: "-r", type: :string, required: true def deploy_notes puts formatted_deploy_notes(options[:commit_range], owner: github_client.get_repo_info[:owner], repo: github_client.get_repo_info[:repo]) puts formatted_deploy_notes end no_tasks do def open_url(url) `/usr/bin/open -a "/Applications/Google Chrome.app" '#{url}'` end def create_branch(no_checkout = false) if no_checkout `git branch #{branch_name_for_issue_number} main` else `git checkout -b #{branch_name_for_issue_number} main` end say_info("created branch #{branch_name_for_issue_number}") end def ensure_origin_exists Open3.capture2("git rev-parse --abbrev-ref --symbolic-full-name @{u}").tap do |_, status| unless status.success? failure("Upstream branch does not exist. Please set before creating pull request. E.g., `git push -u origin branch_name`") end end end def push_and_set_upstream `git rev-parse --abbrev-ref HEAD | xargs git push origin -u` end def create_issue github_client.create_issue( title: options[:name] ).tap do |response| if response["number"] pass("Issue created") @issue_id = response["number"] start else alert("An error occurred when creating issue:") alert("#{response['message']}") end end end def issue_id @issue_id ||= options[:issue_id] end def convert_issue_to_pr github_client.convert_issue_to_pr( issue_number_from_branch.to_i, head: current_branch, base: options[:base_branch] || "main", draft: options[:draft], ).tap do |response| if response["url"] pass("Issue converted to Pull Request") say_info(response["html_url"]) else alert("An error occurred when creating PR:") alert("#{response['message']}") end end end def issue_number_from_branch current_branch.split("_").first.tap do |issue_number| if !issue_number failure("Unable to parse issue number from branch. Are you sure you have a branch checked out?") end end end def current_branch `git rev-parse --abbrev-ref HEAD`.chomp end def branch_name_for_issue_number issue = github_client.get_issue(issue_id) branch_name ="#{issue['number']}_#{issue['title'].strip.downcase.gsub(/[^a-zA-Z0-9]/, '_').squeeze("_")}" if options[:truncate_branch_at] branch_name[0...options[:truncate_branch_at]] else branch_name end end def github_client @gh_client ||= GhCliClient.new end def rebase_main say_info("Fetching changes and rebasing main") if success?("git pull origin main:main --rebase") pass("Fetched and rebased") else failure("Failed to fetch or rebase") end end def stash `git diff --quiet` if !$?.success? say_info("Stashing local changes") `git stash --quiet` @stashed = true end end def stash_pop if @stashed say_info("Stash pop") `git stash pop` nil end end def merged_pr_branches @merged_pr_branches ||= pr_branches.map do |branch| id = branch.split("_")[0].to_i merged = !!github_client.get_pr(id)["merged"] print merged ? "✅ " : "❌ " puts " #{branch}" merged ? branch : nil end.compact end def pr_branches `git branch`.gsub(" ", "").split("\n").select { |br| br.match /^[0-9]/ } end def pull_request_in_commit_range(commits, owner:, repo:) pr_ids = commits.map do |commit| commit.dig("commit", "message").to_s.match(/(?<=\[#)\d{4,5}(?=\])/).to_s.to_i end.uniq.compact prs = pr_ids.map do |id| say_info("Fetching Pull Request ##{id}") pr = github_client.get_pr(id, owner: owner, repo: repo) next pr if pr["number"] github_client.get_issue(id, owner: owner, repo: repo) end end def formatted_deploy_notes(commit_range, owner:, repo:) notes = pull_request_in_commit_range( github_client.commits_for_range(commit_range, owner: owner, repo: repo)["commits"], owner: owner, repo: repo ).map do |pr| deploy_note = pr["body"].to_s.split("**Deploy Note:**")[1].to_s.split(/\n/)[0].to_s.strip if deploy_note.length > 3 "- [##{pr["number"]}] #{deploy_note}" end end.compact notes = ['- No notes'] if notes.empty? notes.unshift("[#{repo}]") end def success?(command) IO.popen(command) do |output| output.each { |line| puts line } end $?.success? end def alert(message) say_status("ALERT", message, :red) end def say_info(message) say_status("INFO", message, :black) end def pass(message) say_status("OK", message, :green) end def failure(message) say_status("FAIL", message, :red) exit end end end end