class Git::Story::App

Constants

BRANCH_NAME_REGEX

Public Class Methods

new(argv = ARGV.dup, debug: ENV['DEBUG'].to_i == 1) click to toggle source
# File lib/git/story/app.rb, line 36
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
  @debug     = debug
  determine_command
  Git::Story::Setup.perform
end

Public Instance Methods

create(story_id = nil) click to toggle source
# File lib/git/story/app.rb, line 251
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
current(check: true) click to toggle source
# File lib/git/story/app.rb, line 76
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
delete(pattern = nil) click to toggle source
# File lib/git/story/app.rb, line 274
def delete(pattern = nil)
  fetch_commits
  if branch = pick_branch(prompt: 'Delete story branch? %s', symbol: ?⌦)
    sh "git pull origin #{branch}"
    sh "git branch -d #{branch}"
    if ask(prompt: 'Delete remote branch? (y/N) ') =~ /\Ay\z/i
      sh "git push origin #{branch} --delete"
    end
    return "Deleted story branch: #{branch}".green
  end
end
deploy_diff(ref = default_ref, rest: []) click to toggle source
# File lib/git/story/app.rb, line 235
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
deploy_last() click to toggle source
# File lib/git/story/app.rb, line 191
def deploy_last
  format_tag(tags.last)
end
deploy_list()
Alias for: deploys
deploy_log(ref = default_ref, rest: []) click to toggle source
# File lib/git/story/app.rb, line 196
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
deploy_migrate_diff(ref = default_ref, rest: []) click to toggle source
# File lib/git/story/app.rb, line 243
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
deploy_stories(ref = default_ref, rest: []) click to toggle source
# File lib/git/story/app.rb, line 208
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
deploy_tags() click to toggle source
# File lib/git/story/app.rb, line 175
def deploy_tags
  tags.map { |t| tag_name(t) }
end
deploy_tags_last() click to toggle source
# File lib/git/story/app.rb, line 186
def deploy_tags_last
  tag_name(tags.last)
end
deploys() click to toggle source
# File lib/git/story/app.rb, line 180
def deploys
  tags.map { |t| format_tag(t) }
end
Also aliased as: deploy_list
details(author = nil, mark_red: current(check: false)) click to toggle source
# File lib/git/story/app.rb, line 165
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
Also aliased as: list_details
fetch_statuses(pivotal_ids) click to toggle source
# File lib/git/story/app.rb, line 222
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
github(branch = current(check: false)) click to toggle source
# File lib/git/story/app.rb, line 287
def github(branch = current(check: false))
  if url = github_url(branch)
    sh "open #{url.inspect}"
  end
  nil
end
help() click to toggle source
# File lib/git/story/app.rb, line 65
def help
  result = [ 'Available commands are:' ]
  longest = command_annotations.keys.map(&:size).max
  result.concat(
    command_annotations.map { |name, a|
      "#{name.to_s.gsub(?_, ' ').ljust(longest)} #{a[:doc]}"
    }
  )
end
hotfix(ref = nil) click to toggle source
# File lib/git/story/app.rb, line 310
def hotfix(ref = nil)
  if ref
    start_point = ref
  elsif tag = tags.last
    start_point = tag_name(tag)
  else
    fail 'no last deployment tag found'
  end
  branch = "hotfix_#{start_point}"
  sh "git checkout -b #{branch.inspect} #{start_point.inspect}"
  nil
end
list(author = nil, mark_red: current(check: false)) click to toggle source
# File lib/git/story/app.rb, line 157
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
list_details(author = nil, mark_red: current(check: false))
Alias for: details
pivotal(branch = current(check: true)) click to toggle source
# File lib/git/story/app.rb, line 295
def pivotal(branch = current(check: true))
  if story_id = branch&.[](/_(\d+)\z/, 1)&.to_i
    story_url = fetch_story(story_id)&.url
    sh "open #{story_url}"
  end
  nil
end
provide_name(story_id = nil) click to toggle source
# File lib/git/story/app.rb, line 88
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
run() click to toggle source
# File lib/git/story/app.rb, line 45
def run
  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
    if @command
      @command = @command.inspect
    else
      @command = 'n/a'
    end
    STDERR.puts "Unknown command #{@command}\n\n#{help.join(?\n)}"
    exit 1
  end
rescue Errno::EPIPE
end
semaphore() click to toggle source
# File lib/git/story/app.rb, line 304
def semaphore
  sh "open #{complex_config.story.semaphore_project_url}"
  nil
end
status(story_id = current(check: true)&.[](/_(\d+)\z/, 1)&.to_i) click to toggle source
# File lib/git/story/app.rb, line 111
  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.black.on_green
        else
          cs.black.on_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 = Array(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 { |l| l.name.on_color(91) }.join(' ')}
        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
switch(pattern = nil) click to toggle source
# File lib/git/story/app.rb, line 265
def switch(pattern = nil)
  fetch_commits
  if branch = pick_branch(prompt: 'Switch to story? %s')
    sh "git checkout #{branch}"
    return "Switched to story: #{branch}".green
  end
end

Private Instance Methods

apply_pattern(pattern, stories) click to toggle source
# File lib/git/story/app.rb, line 436
def apply_pattern(pattern, stories)
  pattern = pattern.gsub(?#, '')
  stories.grep(/#{Regexp.quote(pattern)}/)
end
apply_story_accessors(ref) click to toggle source
# File lib/git/story/app.rb, line 492
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
build_ref_range(ref) click to toggle source
# File lib/git/story/app.rb, line 371
def build_ref_range(ref)
  ref = tag_name(ref)
  if 'previous' == ref and (previous = tags.last(2)).size == 2
    previous.map { |t| tag_name(t) } * '..'
  elsif /^(?<before>.+?)?\.\.(?<after>.+)?\z/ =~ ref
    if before && after
      "#{before}..#{after}"
    elsif !before
      "#{default_ref}..#{after}"
    elsif !after
      "#{before}.."
    else
      "#{default_ref}.."
    end
  else
    "#{ref}.."
  end
end
current_branch() click to toggle source
# File lib/git/story/app.rb, line 519
def current_branch
  capture("git rev-parse --abbrev-ref HEAD").strip
end
current_branch_checked?() click to toggle source
# File lib/git/story/app.rb, line 523
def current_branch_checked?
  if (cb = current_branch) =~ BRANCH_NAME_REGEX
    cb
  end
end
default_ref() click to toggle source
# File lib/git/story/app.rb, line 367
def default_ref
  tags.last
end
deploy_tag_command() click to toggle source
# File lib/git/story/app.rb, line 394
def deploy_tag_command
  fetch_tags
  if command = complex_config.story.deploy_tag_command?
    command
  else
    "git tag | grep ^#{complex_config.story.deploy_tag_prefix} | sort"
  end
end
determine_command() click to toggle source
# File lib/git/story/app.rb, line 325
def determine_command
  c, command = [], nil
  possible_commands = []
  until @argv.empty?
    c << @argv.shift
    command = c.join(?_).to_sym
    if command_of(command)
      possible_commands << [ command, @argv.dup ]
    end
  end
  unless possible_commands.empty?
    @command, argv = possible_commands.last
    @argv.replace(argv)
  end
  self
end
error(msg) click to toggle source
# File lib/git/story/app.rb, line 441
def error(msg)
  puts msg.red
  exit 1
end
fetch_commits() click to toggle source
# File lib/git/story/app.rb, line 488
def fetch_commits
  sh 'git fetch'
end
fetch_story(story_id) click to toggle source
# File lib/git/story/app.rb, line 458
def fetch_story(story_id)
  pivotal_get("projects/#{pivotal_project}/stories/#{story_id}").full?
end
fetch_story_name(story_id) click to toggle source
# File lib/git/story/app.rb, line 454
def fetch_story_name(story_id)
  fetch_story(story_id)&.name
end
fetch_story_owners(story_id) click to toggle source
# File lib/git/story/app.rb, line 462
def fetch_story_owners(story_id)
  pivotal_get("projects/#{pivotal_project}/stories/#{story_id}/owners")
end
fetch_tags() click to toggle source
# File lib/git/story/app.rb, line 483
def fetch_tags
  sh 'git fetch --tags'
end
format_tag(tag) click to toggle source
# File lib/git/story/app.rb, line 544
def format_tag(tag)
  time, tag_name = tag_time(tag)
  if time
    day  = Time::RFC2822_DAY_NAME[time.wday]
    "%s %s %s %s" % [
      time.iso8601.green,
      day.green,
      tag_name.to_s.yellow,
      "was #{Tins::Duration.new((Time.now - time).floor)} ago".green,
    ]
  else
    tag_name
  end
end
github_url(branch) click to toggle source
# File lib/git/story/app.rb, line 563
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
normalize_name(name, max_size: nil) click to toggle source
# File lib/git/story/app.rb, line 426
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
pick_branch(prompt:, symbol: ?⏻) click to toggle source
# File lib/git/story/app.rb, line 342
def pick_branch(prompt:, symbol: ?⏻)
  ss = stories.map(&:story_base_name)
  branch = Search.new(
    match: -> answer {
      answer = answer.strip.delete(?#).downcase

      matcher = Amatch::PairDistance.new(answer)
      matches = ss.map { |n| [ n, -matcher.similar(n.downcase) ] }.
        select { |_, s| s < 0 }.sort_by(&:last).map(&:first)

      matches.empty? and matches = ss
      matches.first(Tins::Terminal.lines - 1)
    },
    query: -> _answer, matches, selector {
      matches.each_with_index.
        map { |m, i| i == selector ? "#{symbol} " + Search.on_blue(m) : '┊ ' + m } * ?\n
    },
    found: -> _answer, matches, selector {
      matches[selector]
    },
    prompt: prompt.bold,
    output: STDOUT
  ).start
end
pivotal_get(path) click to toggle source
# File lib/git/story/app.rb, line 466
def pivotal_get(path)
  path = path.sub(/\A\/*/, '')
  url = "https://www.pivotaltracker.com/services/v5/#{path}"
  @debug and STDERR.puts "Fetching #{url.inspect}"
  URI.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
pivotal_project() click to toggle source
# File lib/git/story/app.rb, line 446
def pivotal_project
  complex_config.story.pivotal_project
end
pivotal_token() click to toggle source
# File lib/git/story/app.rb, line 450
def pivotal_token
  complex_config.story.pivotal_token
end
remote_url(name = 'origin') click to toggle source
# File lib/git/story/app.rb, line 559
def remote_url(name = 'origin')
  capture("git remote -v").lines.grep(/^#{name}/).first&.split(/\s+/).full?(:[], 1)
end
stories() click to toggle source
# File lib/git/story/app.rb, line 504
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
tag_name(tag) click to toggle source
# File lib/git/story/app.rb, line 540
def tag_name(tag)
  tag_time(tag)&.last
end
tag_time(tag) click to toggle source
# File lib/git/story/app.rb, line 529
def tag_time(tag)
  case tag
  when %r(\A\d{4}/\d{2}/\d{2}\d{2}:\d{2}:\d{2} (\S+))
    return Time.strptime($&, '%Y/%m/%d%H:%M'), $1
  when /\d{4}_\d{2}_\d{2}-\d{2}_\d{2}\z/
    return Time.strptime($&, '%Y_%m_%d-%H_%M'), tag
  else
    return nil, tag
  end
end
tags() click to toggle source
# File lib/git/story/app.rb, line 390
def tags
  capture(deploy_tag_command).lines.map(&:chomp)
end
watch(&block) click to toggle source
# File lib/git/story/app.rb, line 403
def watch(&block)
  if seconds = @opts[?n]&.to_i and !@watching
    @watching = true
    if seconds == 0
      seconds = 60
    end
    loop do
      r = block.()
      sh '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