require 'time' require 'open-uri' require 'tins/go' require 'set' require 'infobar' class Git::Story::App class ::String include Term::ANSIColor end include Git::Story::Utils extend Git::Story::Utils include ComplexConfig::Provider::Shortcuts include Tins::GO annotate :command BRANCH_NAME_REGEX = /\A(story|feature)_([a-z0-9-]+)_(\d+)\z/ module StoryAccessors attr_accessor :story_name attr_accessor :story_base_name attr_accessor :story_id attr_accessor :story_created_at attr_accessor :story_author end def initialize(argv = ARGV.dup, debug: ENV['DEBUG'].to_i == 1) @rest_argv = (sep = argv.index('--')) ? argv.slice!(sep..-1).tap(&:shift) : [] @argv = argv @opts = go 'n:', @argv @command = @argv.shift&.to_sym @debug = debug end def run Git::Story::Setup.perform if command_of(@command) if method(@command).parameters.include?(%i[key rest]) puts __send__(@command, *@argv, rest: @rest_argv) else puts __send__(@command, *@argv) end else @command and @command = @command.inspect @command ||= 'n/a' STDERR.puts "Unknown command #{@command}\n\n#{help.join(?\n)}" exit 1 end rescue Errno::EPIPE end command doc: 'this help' def help result = [ 'Available commands are:' ] longest = command_annotations.keys.map(&:size).max result.concat( command_annotations.map { |name, a| "#{name.to_s.ljust(longest)} #{a[:doc]}" } ) end command doc: 'output the current story branch if it is checked out' def current(check: true) if check if cb = current_branch_checked? cb else error 'Switch to a story branch first for this operation!' end else current_branch end end def provide_name(story_id = nil) until story_id.present? story_id = ask(prompt: 'Story id? ').strip end story_id = story_id.gsub(/[^0-9]+/, '') @story_id = Integer(story_id) if stories.any? { |s| s.story_id == @story_id } @reason = "story for ##@story_id already created" return end if name = fetch_story_name(@story_id) name = normalize_name( name, max_size: 128 - 'story'.size - @story_id.to_s.size - 2 * ?_.size ).full? || name [ 'story', name, @story_id ] * ?_ else @reason = "name for ##@story_id could not be fetched from tracker" return end end command doc: '[BRANCH] display test status of branch, -n SECONDS refreshes' def test_status(branch = current(check: false)) url = nil watch do auth_token = complex_config.story.semaphore_auth_token project = complex_config.story.semaphore_test_project url = "https://semaphoreci.com/api/v1/projects/#{project}/#{branch}/status?auth_token=#{auth_token}" Git::Story::SemaphoreResponse.get(url, debug: @debug) end rescue => e "Getting #{url.inspect} => #{e.class}: #{e}".red end command doc: '[SERVER] display deploy status of branch, -n SECONDS refreshes' def deploy_status(server = complex_config.story.semaphore_default_server) url = nil watch do auth_token = complex_config.story.semaphore_auth_token project = complex_config.story.semaphore_test_project url = "https://semaphoreci.com/api/v1/projects/#{project}/servers/#{server}?auth_token=#{auth_token}" server = Git::Story::SemaphoreResponse.get(url, debug: @debug) deploys = server.deploys upcoming = deploys.select(&:pending?)&.last passed = deploys.select(&:passed?) current = passed.first if !passed.empty? && upcoming upcoming.estimated_duration = passed.sum { |d| d.duration.to_f } / passed.size end <<~end Server: #{server.server_name&.green} Branch: #{server.branch_name&.color('#ff5f00')} Semaphore: #{server.server_url} Strategy: #{server.strategy} Upcoming: #{upcoming} Current: #{current} end end rescue => e "Getting #{url.inspect} => #{e.class}: #{e}".red end command doc: '[BRANCH] display build status for branch, -n SECONDS refreshes' def build_status(branch = current(check: false)) watch do [ "Test Status".bold, test_status(branch) || 'n/a', "Deploy Status".bold, deploy_status || 'n/a', ] * "\n\n" end end command doc: '[STORY_ID] fetch status of current story, -n SECONDS refreshes' def status(story_id = current(check: true)&.[](/_(\d+)\z/, 1)&.to_i) if story = fetch_story(story_id) color_state = case cs = story.current_state when 'unscheduled', 'planned', 'unstarted' cs when 'rejected' cs.white.on_red when 'accepted' cs.green else cs.yellow end.italic color_type = case t = story.story_type when 'bug' t.red.bold when 'feature' t.yellow.bold when 'chore' t.white.bold else t end owners = fetch_story_owners(story_id).map { |o| "#{o.name} <#{o.email}>" } result = <<~end Id: #{(?# + story.id.to_s).green} Name: #{story.name.inspect.bold} Type: #{color_type} Estimate: #{story.estimate.to_s.full? { |e| e.yellow.bold } || 'n/a'} State: #{color_state} Branch: #{current_branch_checked?&.color('#ff5f00')} Labels: #{story.labels.map(&:name).join(' ').on_color(91)} Owners: #{owners.join(', ').yellow} Pivotal: #{story.url.color(33)} end if url = github_url(current_branch_checked?) result << "Github: #{url.color(33)}\n" end result end rescue => e "Getting pivotal story status => #{e.class}: #{e}\n#{e.backtrace.join(?n)}".red end command doc: '[AUTHOR] list all stories' def list(author = nil, mark_red: current(check: false)) stories.map { |b| next if author && !b.story_author.include?(author) (bn = b.story_base_name) == mark_red ? bn.red : bn.green }.compact end command doc: '[AUTHOR] list all stories with details' def details(author = nil, mark_red: current(check: false)) stories.sort_by { |b| -b.story_created_at.to_f }.map { |b| next if author && !b.story_author.include?(author) name = (bn = b.story_base_name) == mark_red ? bn.red : bn.green "#{name} #{b.story_author} #{b.story_created_at.iso8601.yellow}" }.compact end alias list_details details command doc: 'list all production deploy tags' def deploy_tags capture(tags).lines.map(&:chomp) end command doc: 'output the times of all production deploys' def deploys deploy_tags.map { |t| format_tag(t) } end alias deploy_list deploys command doc: 'output the last production deploy tag' def deploy_tags_last deploy_tags.last end command doc: 'output the time of the last production deploy' def deploy_last tag = deploy_tags_last format_tag(tag) end command doc: '[REF] output log of changes since last production deploy tag' def deploy_log(ref = default_ref, rest: []) ref = build_ref_range(ref) fetch_commits fetch_tags opts = ([ '--color', '--pretty=tformat:"%C(yellow)%h%Creset %C(green)%ci%Creset %s (%Cred%an <%ae>%Creset)"' ] | rest) * ' ' capture("git log #{opts} #{ref}") end command doc: '[REF] List all stories scheduled for next deploy' def deploy_stories(ref = default_ref, rest: []) ref = build_ref_range(ref) fetch_commits fetch_tags opts = ([ '--color=never', '--pretty=%B' ] | rest) * ' ' output = capture("git log #{opts} #{ref}") pivotal_ids = SortedSet[] output.scan(/\[\s*#\s*(\d+)\s*\]/) { pivotal_ids << $1.to_i } fetch_statuses(pivotal_ids) * (?┄ * Tins::Terminal.cols << ?\n) end def fetch_statuses(pivotal_ids) tg = ThreadGroup.new pivotal_ids.each do |pid| tg.add Thread.new { Thread.current[:status] = status(pid) } end tg.list.with_infobar(label: 'Story').map do |t| +infobar t.join t[:status] end end command doc: '[REF] output diff since last production deploy tag' def deploy_diff(ref = default_ref, rest: []) ref = build_ref_range(ref) fetch_commits opts = (%w[ --color -u ] | rest) * ' ' capture("git diff #{opts} #{ref}") end command doc: '[REF] output migration diff since last production deploy tag' def deploy_migrate_diff(ref = default_ref, rest: []) ref = build_ref_range(ref) fetch_commits opts = (%w[ --color -u ] | rest) * ' ' capture("git diff #{opts} #{ref} -- db/migrate") end command doc: '[STORYID] create a story for story STORYID' def create(story_id = nil) fetch_commits name = provide_name(story_id) or error "Cannot provide a new story name for story ##{story_id}: #{@reason.inspect}" if old_story = stories.find { |s| s.story_id == @story_id } error "story ##{@story_id} already exists in #{old_story}".red end puts "Now creating story #{name.inspect}".green sh "git checkout --track -b #{name}" sh "git push -u origin #{name}" "Story #{name} created.".green end command doc: '[PATTERN] switch to story matching PATTERN' def switch(pattern = nil) fetch_commits ss = stories.map(&:story_base_name) if pattern.present? b = apply_pattern(pattern, ss) if b.size == 1 b = b.first else b = nil end end loop do unless b b = complete prompt: 'Story ? '.bright_blue do |pattern| apply_pattern(pattern, ss) end&.strip b.empty? and return end if branch = ss.find { |f| f == b } sh "git checkout #{branch}" return "Switched to story: #{branch}".green else b = nil end end rescue Interrupt end command doc: '[BRANCH] open branch on github' def github(branch = current(check: false)) if url = github_url(branch) system "open #{url.inspect}" end nil end command doc: '[BRANCH] open branch on pivotaltracker' def pivotal(branch = current(check: true)) if story_id = branch&.[](/_(\d+)\z/, 1)&.to_i story_url = fetch_story(story_id)&.url system "open #{story_url}" end nil end private def default_ref deploy_tags.last end def build_ref_range(ref) if 'previous' == ref and (previous = deploy_tags.last(2)).size == 2 previous * '..' elsif /^(?.+?)?\.\.(?.+)?\z/ =~ ref if before && after "#{before}..#{after}" elsif !before "#{default_ref}..#{after}" elsif !after "#{before}.." else "#{default_ref}.." end else "#{ref}.." end end def tags fetch_tags if command = complex_config.story.deploy_tag_command? command else "git tag | grep ^#{complex_config.story.deploy_tag_prefix} | sort" end end def watch(&block) if seconds = @opts[?n]&.to_i and !@watching @watching = true if seconds == 0 seconds = 60 end loop do r = block.() system('clear') start = Time.now puts r refresh_at = start + seconds duration = refresh_at - start if duration > 0 puts "<<< #{Time.now.iso8601} Refresh every #{seconds} seconds >>>".rjust(Tins::Terminal.cols) sleep duration end end else block.() end end def normalize_name(name, max_size: nil) name = name.downcase name = name.tr('äöü', 'aou').tr(?ß, 'ss') name = name.gsub(/[^a-z0-9-]+/, '-') name = name.gsub(/(\A-*|[\-0-9]*\z)/, '') name = name.gsub(/-+/, ?-) max_size and name = name[0, max_size] name end def apply_pattern(pattern, stories) pattern = pattern.gsub(?#, '') stories.grep(/#{Regexp.quote(pattern)}/) end def error(msg) puts msg.red exit 1 end def pivotal_project complex_config.story.pivotal_project end def pivotal_token complex_config.story.pivotal_token end def fetch_story_name(story_id) fetch_story(story_id)&.name end def fetch_story(story_id) pivotal_get("projects/#{pivotal_project}/stories/#{story_id}").full? end def fetch_story_owners(story_id) pivotal_get("projects/#{pivotal_project}/stories/#{story_id}/owners") end def pivotal_get(path) path = path.sub(/\A\/*/, '') url = "https://www.pivotaltracker.com/services/v5/#{path}" @debug and STDERR.puts "Fetching #{url.inspect}" open(url, 'X-TrackerToken' => pivotal_token, 'Content-Type' => 'application/xml', ) do |io| JSON.parse(io.read, object_class: JSON::GenericObject) end rescue OpenURI::HTTPError => e if e.message =~ /401/ raise e.exception, "#{e.message}: API-TOKEN in env var PIVOTAL_TOKEN invalid?" end end memoize method: def fetch_tags sh 'git fetch --tags' end memoize method: def fetch_commits sh 'git fetch' end def apply_story_accessors(ref) branch = ref[0] branch =~ BRANCH_NAME_REGEX or return branch.extend StoryAccessors branch.story_base_name = "#$1_#$2_#$3" branch.story_name = $2 branch.story_id = $3.to_i branch.story_created_at = ref[1] branch.story_author = ref[2] branch end def stories sh 'git remote prune origin', error: false refs = capture("git for-each-ref --format='%(refname);%(committerdate);%(authorname) %(authoremail)'") refs = refs.lines.map { |l| ref = l.chomp.split(?;) next unless ref[0] =~ %r(/origin/) ref[0] = File.basename(ref[0]) next unless ref[0] =~ BRANCH_NAME_REGEX ref[1] = Time.parse(ref[1]) ref }.compact.map do |ref| apply_story_accessors ref end.compact end def current_branch capture("git rev-parse --abbrev-ref HEAD").strip end def current_branch_checked? if (cb = current_branch) =~ BRANCH_NAME_REGEX cb end end def tag_time(tag) case tag when /\d{4}_\d{2}_\d{2}-\d{2}_\d{2}/ Time.strptime($&, '%Y_%m_%d-%H_%M') end end def format_tag(tag) if time = tag_time(tag) day = Time::RFC2822_DAY_NAME[time.wday] "%s %s %s %s" % [ time.iso8601.green, day.green, tag.to_s.yellow, "was #{Tins::Duration.new((Time.now - time).floor)} ago".green, ] else tag end end def remote_url(name = 'origin') capture("git remote -v").lines.grep(/^#{name}/).first&.split(/\s+/).full?(:[], 1) end def github_url(branch) branch.full? or return url = remote_url('github') || remote_url or return url = url.sub('git@github.com:', 'https://github.com/') url = url.sub(/(\.git)\z/, "/tree/#{branch}") end end