#!/usr/bin/env ruby require 'rubygems' gem 'pivotal-tracker', '~> 0.4.0' require 'pivotal-tracker' require 'thor' require 'rainbow' require 'tempfile' require 'yaml' require 'chronic' CACHE_TIME = 60 GIT_PIVOTAL_PROJECT_ID_KEY = "pivotal.project-id" CACHE_PATH = '~/.tracker-cache' LAST_STORY_PATH = File.expand_path("#{CACHE_PATH}/last-story") module PivotalTracker class Story def move(story) Client.connection["/projects/#{project_id}/stories/#{id}/moves"].post(:move => { :move => :before, :target => story.id}) end end end class Tracker < Thor method_options :first => :boolean method_options :project => :string method_options :description => :string method_options :api_token => :string method_options %w{labels -l} => :string method_options %w{skip_description --nd} => :boolean method_options :external => :boolean def initialize(*args) super @options = options.dup if File.file?(path = File.expand_path('~/.trackerrc')) @options.merge!(YAML.load_file(path)) end api_token = %x{git config --get pivotal.api-token}.strip api_token = @options[:api_token] if api_token.empty? PivotalTracker::Client.token = api_token @stdin_content = nil if !$stdin.tty? @stdin_content = $stdin.read $stdin.close end end started_color = 'f3f3d1' COLORS = { :feature => 'ffff44', :chore => '999999', :bug => 'ff2233', :release => '0044cc', :id => '22cc33', :label => '33ee55', :project => 'dd2211', :story => 'ffffff', :comment => 'ddcc11', :description => '2255ee', :started => started_color, :finished => started_color, :delivered => started_color, :unscheduled => 'e4eff7', :score => '2255cc' } desc "catchup", "Catch up on the latest changes to all your projects" method_options :limit => "30" def catchup(since = 'yesterday') PivotalTracker::Activity.all(nil, :limit => options[:limit].to_i, :occurred_since_date => Chronic.parse(since)).each do |activity| story = activity.description.dup story.gsub!(%r{^#{activity.author} }, '') story.gsub!(%r{"(.*)"}, '\1'.foreground(COLORS[:comment])) time = activity.occurred_at.strftime('%Y-%m-%d %H:%M') project = all_projects.find { |p| p.id == activity.project_id } puts "[#{time.foreground(COLORS[:id])}] #{activity.author.foreground(COLORS[:label])} #{story} in #{project.name.foreground(COLORS[:project])} (#{activity.stories.first.id.to_s.foreground(COLORS[:id])})" end end desc "feature NAME ", "Create a new feature in the current project" def feature(name, estimate = nil) params = { :name => name, :story_type => :feature, :current_state => :unstarted } params[:estimate] = estimate if estimate create_story(params) clear_cache! end desc "chore NAME", "Create a new chore in the current project" def chore(name) create_story(:name => name, :story_type => :chore, :current_state => :unstarted) clear_cache! end desc "release NAME", "Create a new release in the current project" def release(name) create_story(:name => name, :story_type => :release, :current_state => :unstarted) clear_cache! end desc "bug NAME", "Create a new bug in the current project" def bug(name) create_story(:name => name, :story_type => :bug, :current_state => :unstarted) clear_cache! end desc "start NAME-OR-ID", "Start a story" def start(name_or_id = nil) with_story(name_or_id) do |story| story.update(:current_state => :started) puts "Story #{story.name} started" clear_cache! end end desc "deliver NAME-OR-ID", "Deliver a feature or bug" def deliver(name_or_id = nil) with_story(name_or_id) do |story| story.update(:current_state => :delivered) puts "Story #{story.name} delivered" clear_cache! end end desc "accept NAME-OR-ID", "Accept a feature or bug" def accept(name_or_id) with_story(name_or_id) do |story| story.update(:current_state => :accepted) puts "Story #{story.name} accepted" clear_cache! end end desc "finish NAME-OR-ID ", "Finish a story and add an optional comment" def finish(name_or_id, comment = nil) with_story(name_or_id) do |story| comment = external(:comment) if !comment && @options[:external] story.update(:current_state => :finished) PivotalTracker::Note.new(:owner => story, :text => comment).create if comment && !comment.empty? puts "Story #{story.name} finished" clear_cache! end end desc "labels NAME-OR-ID ", "Update the labels on a story" def labels(name_or_id, label_list) with_story(name_or_id) do |story| story.update(:labels => label_list) puts "Labels for #{story.name.foreground(COLORS[:story])} changed to #{label_list.foreground(COLORS[:label])}" clear_cache! end end desc "score NAME-OR-ID ", "Update the score on a story" def score(name_or_id, score) with_story(name_or_id) do |story| story.update(:estimate => score) puts "Score for #{story.name.foreground(COLORS[:feature])} changed to #{score.foreground(COLORS[:score])}" clear_cache! end end desc "comment NAME-OR-ID ", "Add a comment to a story" def comment(name_or_id, comment = nil) with_story(name_or_id) do |story| if !comment if @stdin_content comment = @stdin_content else comment = external(:comment, story_text_history(story)) if @options[:external] end end if comment && !comment.empty? PivotalTracker::Note.new(:owner => story, :text => comment).create puts "Comment #{comment.foreground(COLORS[:comment])} added to #{story.name}" clear_cache! else puts "No comment provided!" end end end desc "show ", "Show a single story with description and comments" def show(name_or_id = nil) with_story(name_or_id) do |story| comments = [] wrap_text(story_chrono_history(story)) { |line| comments << line.foreground(COLORS[:comment]) } output = <<-ANSI #{story.name.foreground(COLORS[:story])} #{"(#{story.id})".foreground(COLORS[:id])} #{comments.join("\n")} ANSI file = Tempfile.new('tracker') file.puts output file.close system %{bash -c 'less -fr #{file.path}'} end end desc "list ", "List all stories" def list(filter = nil) search_filter = filter ? search_value(filter) : nil non_accepted_stories.each do |story| if !search_filter || story.name[search_filter] case story.current_state when 'unstarted' change_section(:backlog) when 'unscheduled' change_section(:icebox) else change_section(:current) end print_story(story) end end end desc "delete NAME-OR-ID", "Delete a story" def delete(name_or_id) with_story(name_or_id) do |story| story.delete puts "Story #{story.name} deleted" clear_cache! end end desc "export-for PROJECT", "Get the bash export for the specified project" def export_for(project_search) if project(project_search) puts "export TRACKER_PROJECT=#{project.id}" end end desc "set-git-project PROJECT", "Set pivotal.project-id to the requested project" def set_git_project(project_search) if project(project_search) system %{git config --local #{GIT_PIVOTAL_PROJECT_ID_KEY} #{project.id}} puts "#{project.name} set for this git repo" end end desc "project", "Get the current project as determined by current config" def project if project puts "#{project.id} #{project.name}" end end default_task :list no_tasks do def story_text_history(story) description_and_notes(story).reverse.join("\n") end def story_chrono_history(story) description_and_notes(story).join("\n") end def description_and_notes(story) ([ story.description ] + PivotalTracker::Note.all(story).collect(&:text)) end def create_story(params) if @options[:external] && !@options[:skip_description] params[:description] ||= external(:story_description) else params[:description] ||= @options[:description] end params[:labels] ||= @options[:labels] story = project.stories.create(params) story.move(first_in_icebox) if @options[:first] && first_in_icebox puts "#{story.story_type.capitalize} #{story.name.foreground(COLORS[story.story_type.to_sym])} created in #{project.name.foreground(COLORS[:project])}" story end def external(type, initial_comments = nil) file = Tempfile.new("tracker") file.puts file.puts "# Write your #{type} here, lines starting with # are ignored" if initial_comments wrap_text(initial_comments) { |line| file.puts "# #{line}" } file.puts "#" end file.close system %{bash -c "$EDITOR #{file.path}"} lines = File.readlines(file.path).find_all { |line| line[0..0] != '#' } lines.empty? ? nil : lines.join.strip end def wrap_text(text, columns = 78) text.each_line do |line| line = line.gsub(/ +/, ' ') line_done = false while line.length > 78 && !line_done index = 78 done = false while !done && !line_done if line[index..index] == " " yield line[0..index - 1] line = line[(index + 1)..-1] done = true else index -= 1 line_done = (index == 0) end end end yield line end end def stories_cache FileUtils.mkdir_p File.expand_path(CACHE_PATH) target = File.expand_path("#{CACHE_PATH}/#{project.id}") if !File.file?(target) || (File.mtime(target) + CACHE_TIME < Time.now) stories = project.stories.all File.open(target, 'wb') { |fh| Marshal.dump(stories, fh) } else stories = Marshal.load(File.read(target)) end stories end def clear_cache! FileUtils.rm_f File.expand_path("#{CACHE_PATH}/#{project.id}") end def non_accepted_stories stories_cache.reject { |story| story.current_state == 'accepted' } end def with_story(name_or_id = nil) if !name_or_id && File.file?(LAST_STORY_PATH) name_or_id = File.read(LAST_STORY_PATH).to_i end if story = find_story(name_or_id) yield story FileUtils.mkdir_p File.expand_path(CACHE_PATH) File.open(LAST_STORY_PATH, 'wb') { |fh| fh.print story.id } else puts "Story #{name_or_id} not found." end end def project(search = nil) return @project if @project search ||= @options[:project] || ENV['TRACKER_PROJECT'] || %x{git config --local --get #{GIT_PIVOTAL_PROJECT_ID_KEY}}.strip search = nil if search.empty? if search if search[%r{^\d+$}] project = PivotalTracker::Project.find(search) else project = all_projects.find { |proj| proj.name[search_value(search)] } end if !project || !project.kind_of?(PivotalTracker::Project) puts "Could not find project #{search.to_s.foreground(COLORS[:project])}. Valid projects:" all_projects.each do |project| print "(#{project.id})".foreground(COLORS[:id]) print " #{project.name.foreground(COLORS[:project])}" puts end exit 1 end else puts "No project found! Set one via export-for or git-set-project." exit 1 end @project = project end def search_value(value) search = Regexp.new(value, Regexp::IGNORECASE) search = Regexp.new(value[1..-2]) if value[0..0] == '/' search end def all_projects @all_projects ||= PivotalTracker::Project.all end def find_story(name_or_id) if !(story = project.stories.find(name_or_id.to_i)) search = search_value(name_or_id) story = stories_cache.find { |story| story.name[search] } end story end def print_story(story) @alternate = false if @alternate == nil prefix = case story.story_type when 'feature' "*FEATURE*".foreground(COLORS[story.story_type.to_sym]) when 'chore' "[CHORE] ".foreground(COLORS[story.story_type.to_sym]) when 'bug' "%BUG% ".foreground(COLORS[story.story_type.to_sym]).bright when 'release' "#RELEASE#".foreground(COLORS[story.story_type.to_sym]).bright end suffix = case story.story_type when 'feature' if story.estimate.to_i >= 0 " #{"|" * story.estimate.to_i} #{story.estimate}".foreground(COLORS[:score]) else '' end else '' end max_story_name = width - 9 - 2 - 2 - 9 - 1 - suffix.gsub(/\033[^m]+m/, '').length story_name = story.name[0..max_story_name] story_id = "(#{story.id.to_s.rjust(9)})" left = "#{prefix} #{story_id.foreground(COLORS[:id])} #{story_name.foreground(COLORS[story.current_state.to_sym] || COLORS[:story])} " if !(%w{unscheduled unstarted}).include?(story.current_state) left += "<#{story.current_state.foreground(COLORS[:project])}> " end left += "#{story.labels.foreground(COLORS[:label])} " if story.labels left += "@".foreground(COLORS[:description]) if story.description && !story.description.empty? right = "#{suffix}" existing_length = (width - left.gsub(/\033[^m]+m/, '').length - right.gsub(/\033[^m]+m/, '').length) center = " " * [ existing_length, 0 ].max puts [ left, center, right ].join @alternate = !@alternate end def width @width ||= (%x{stty size}.strip.split.last.to_i - 2) end def change_section(which) if @current_section != which string = ("-- #{which.to_s.upcase} " + ("-" * width))[0..width] puts string.foreground('7777cc') @current_section = which end end def first_in_icebox project.stories.all(:current_state => 'unstarted').first end end end Tracker.start