ipt 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +3 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Changelog.md +6 -0
- data/Gemfile +9 -0
- data/Guardfile +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +37 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/ipt +10 -0
- data/bin/setup +8 -0
- data/ipt.gemspec +36 -0
- data/lib/pt.rb +13 -0
- data/lib/pt/action.rb +280 -0
- data/lib/pt/cli.rb +163 -0
- data/lib/pt/client.rb +133 -0
- data/lib/pt/configuration.rb +65 -0
- data/lib/pt/data_row.rb +83 -0
- data/lib/pt/data_table.rb +107 -0
- data/lib/pt/io.rb +184 -0
- data/lib/pt/version.rb +3 -0
- metadata +235 -0
data/lib/pt/cli.rb
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'highline'
|
3
|
+
require 'tempfile'
|
4
|
+
require 'uri'
|
5
|
+
require 'thor'
|
6
|
+
require 'paint'
|
7
|
+
|
8
|
+
# String patch monkey
|
9
|
+
class String
|
10
|
+
%i[blue yellow green red white black magenta cyan bold].each do |color|
|
11
|
+
define_method(color) do
|
12
|
+
Paint[self, color]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module PT
|
18
|
+
class CLI < Thor
|
19
|
+
include PT::Action
|
20
|
+
include PT::IO
|
21
|
+
attr_reader :project
|
22
|
+
|
23
|
+
TYPE=%w[feature bug chore release]
|
24
|
+
ACTION = %w[show open start finish deliver accept reject done assign estimate tasks comment label edit unstart]
|
25
|
+
|
26
|
+
default_task :mywork
|
27
|
+
|
28
|
+
def initialize(*args)
|
29
|
+
super
|
30
|
+
@io = HighLine.new
|
31
|
+
@config = PT::Configuration.new
|
32
|
+
@client = @config.client || Client.new
|
33
|
+
@project = @client.project
|
34
|
+
end
|
35
|
+
|
36
|
+
%w[unscheduled started finished delivered accepted rejected].each do |state|
|
37
|
+
desc "#{state} <owner>", "show all #{state} stories"
|
38
|
+
define_method(state.to_sym) do |owner = nil|
|
39
|
+
filter = "state:#{state}"
|
40
|
+
filter << " owner:#{owner}" if owner
|
41
|
+
select_story_from_paginated_table(title: "#{state} stories") do |page|
|
42
|
+
@client.get_stories(filter: filter, page: page)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
ACTION.each do |action|
|
48
|
+
desc "#{action} [id]", "#{action} story"
|
49
|
+
define_method(action.to_sym) do |story_id = nil|
|
50
|
+
if story_id
|
51
|
+
if story = @client.project.story(story_id.to_i)
|
52
|
+
title("#{action} '#{story.name}'")
|
53
|
+
send("#{action}_story", story)
|
54
|
+
else
|
55
|
+
message("No matches found for '#{story_id}', please use a valid pivotal story Id")
|
56
|
+
return
|
57
|
+
end
|
58
|
+
else
|
59
|
+
method_name = "get_stories_to_#{action}"
|
60
|
+
story = select_story_from_paginated_table(default_action: action, title: "Stories to #{action}") do |page|
|
61
|
+
if @client.respond_to?(method_name.to_sym)
|
62
|
+
@client.send("get_stories_to_#{action}", page: page)
|
63
|
+
else
|
64
|
+
@client.get_stories(page: page)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
TYPE.each do |type|
|
72
|
+
desc "#{type} <owner>", "show all #{type} stories"
|
73
|
+
define_method(type.to_sym) do |owner = nil|
|
74
|
+
filter = "story_type:#{type} -state:accepted"
|
75
|
+
filter << " owner:#{owner}" if owner
|
76
|
+
select_story_from_paginated_table(title: "#{type} stories") do |page|
|
77
|
+
@client.get_stories(filter: filter, page: page)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
desc 'mywork', 'list all your stories'
|
83
|
+
def mywork
|
84
|
+
select_story_from_paginated_table(title: 'My Work') do |page|
|
85
|
+
@client.get_stories(filter: "owner:#{Settings[:user_name]} -state:accepted", page: page)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
%w[ current backlog ].each do |scope|
|
90
|
+
desc "#{scope}", 'list all stories for #{scope} iteration'
|
91
|
+
define_method(scope.to_sym) do
|
92
|
+
select_story_from_paginated_table(title: "#{scope} iteration") do |page|
|
93
|
+
@client.get_stories_from_iteration(scope: scope, page: page)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
desc "list [owner]", "list all stories from owner"
|
99
|
+
def list(owner = nil)
|
100
|
+
owner = choose_person.initials unless owner
|
101
|
+
select_story_from_paginated_table(title: "stories for #{owner}") do |page|
|
102
|
+
@client.get_stories(filter: "owner:#{owner} -state:accepted", page: page)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
desc "recent", "show stories you've recently shown or commented on with pt"
|
107
|
+
def recent
|
108
|
+
title("Your recent stories from #{project_to_s}")
|
109
|
+
select_story_from_paginated_table(title: "recent stories") do |page|
|
110
|
+
@client.get_stories(filter: Settings[:recent_tasks].join(','), page: page)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
desc 'create [title] --owner <owner> --type <type> -m', "create a new story (and include description ala git commit)"
|
115
|
+
long_desc <<-LONGDESC
|
116
|
+
create story with title [title]
|
117
|
+
|
118
|
+
--owner, -o set owner
|
119
|
+
|
120
|
+
--type , -t set story type
|
121
|
+
|
122
|
+
-m enable add description using vim
|
123
|
+
|
124
|
+
omit all parameters will start interactive mode
|
125
|
+
LONGDESC
|
126
|
+
option :type, aliases: :t
|
127
|
+
option :owner, aliases: :o
|
128
|
+
option :m, type: :boolean
|
129
|
+
def create(title =nil)
|
130
|
+
if title
|
131
|
+
owner_id = if options[:owner] && (owner = @client.find_member(options[:owner]))
|
132
|
+
owner.person.id
|
133
|
+
else
|
134
|
+
nil
|
135
|
+
end
|
136
|
+
description = edit_using_editor if options[:m]
|
137
|
+
params = {
|
138
|
+
name: title,
|
139
|
+
requested_by_id: Settings[:user_id],
|
140
|
+
owner_ids: [owner_id],
|
141
|
+
description: description
|
142
|
+
}
|
143
|
+
params[:story_type] = options[:type] if options[:type]
|
144
|
+
story = @client.create_story(params)
|
145
|
+
congrats("Story with title #{story.name} has been created \n #{story.url}")
|
146
|
+
else
|
147
|
+
create_interactive_story
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
desc "find [query] " ,"looks in your stories by title and presents it"
|
152
|
+
long_desc <<-LONGDESC
|
153
|
+
search using pivotal tracker search query
|
154
|
+
LONGDESC
|
155
|
+
def find(query=nil)
|
156
|
+
query ||= ask("Please type seach query") { |q| q.readline = true }
|
157
|
+
story = select_story_from_paginated_table(title: "Search result for #{query}")do |page|
|
158
|
+
@client.get_stories(filter: query, page: page)
|
159
|
+
end
|
160
|
+
show_story(story)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
data/lib/pt/client.rb
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'tracker_api'
|
3
|
+
|
4
|
+
module PT
|
5
|
+
class Client < SimpleDelegator
|
6
|
+
|
7
|
+
STORY_FIELDS=':default,requested_by(initials),owners(initials),tasks(complete,description),comments(text,file_attachment_ids,person(initials))'
|
8
|
+
|
9
|
+
attr_reader :config, :client, :total_record, :limit
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@client = TrackerApi::Client.new(token: Settings[:pivotal_api_key])
|
13
|
+
self.__setobj__(@client)
|
14
|
+
@limit = Settings[:limit] || 10
|
15
|
+
end
|
16
|
+
|
17
|
+
def total_page
|
18
|
+
@total_record = @client.last_response.env.response_headers["X-Tracker-Pagination-Total"]
|
19
|
+
@total_record ? (@total_record.to_f/limit).ceil : 1
|
20
|
+
end
|
21
|
+
|
22
|
+
def current_page
|
23
|
+
offset = @client.last_response.env.response_headers["X-Tracker-Pagination-Offset"]
|
24
|
+
offset ? ((offset.to_f/limit)+1).to_i.ceil : 1
|
25
|
+
end
|
26
|
+
|
27
|
+
def project
|
28
|
+
@client.project(Settings[:project_id])
|
29
|
+
end
|
30
|
+
|
31
|
+
def get_stories_to_estimate(params={})
|
32
|
+
params[:filter] = "owner:#{Settings[:user_name]} type:feature estimate:-1"
|
33
|
+
get_stories(params)
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_stories_to_unstart(params={})
|
37
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:unstarted"
|
38
|
+
get_stories(params)
|
39
|
+
end
|
40
|
+
|
41
|
+
def get_stories_to_start(params={})
|
42
|
+
params[:filter] = "owner:#{Settings[:user_name]} type:feature,bug state:unscheduled,rejected,unstarted"
|
43
|
+
tasks = get_stories(params)
|
44
|
+
tasks.reject{ |t| (t.story_type == 'feature') && (!t.estimate) }
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_stories_to_finish(params={})
|
48
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:unscheduled,rejected"
|
49
|
+
get_stories(params)
|
50
|
+
end
|
51
|
+
|
52
|
+
def get_stories_to_deliver(params={})
|
53
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:delivered,accepted,rejected"
|
54
|
+
get_stories(params)
|
55
|
+
end
|
56
|
+
|
57
|
+
def get_stories_to_accept(params={})
|
58
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:accepted"
|
59
|
+
get_stories(params)
|
60
|
+
end
|
61
|
+
|
62
|
+
def get_stories_to_reject(params={})
|
63
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:rejected"
|
64
|
+
get_stories(params)
|
65
|
+
end
|
66
|
+
|
67
|
+
def get_stories_to_assign(params={})
|
68
|
+
params[:filter] = "-state:accepted"
|
69
|
+
get_stories(params)
|
70
|
+
end
|
71
|
+
|
72
|
+
def get_stories(params={})
|
73
|
+
limit = @limit || 10
|
74
|
+
page = params[:page] || 0
|
75
|
+
offset = page*limit
|
76
|
+
filter = params[:filter] || '-state=accepted'
|
77
|
+
project.stories limit: limit, fields: STORY_FIELDS, auto_paginate: false, offset: offset, filter: filter
|
78
|
+
end
|
79
|
+
|
80
|
+
def get_stories_from_iteration(params={})
|
81
|
+
page = params[:page] || 0
|
82
|
+
puts "page #{page}"
|
83
|
+
scope = params[:scope] || 'current'
|
84
|
+
project.iterations(scope: scope, fields: ":default,stories(#{STORY_FIELDS})")[page]&.stories || []
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
def get_member(query)
|
89
|
+
member = project.memberships.select{ |m| m.person.name.downcase.start_with?(query.downcase) || m.person.initials.downcase == query.downcase }
|
90
|
+
member.empty? ? nil : member.first
|
91
|
+
end
|
92
|
+
|
93
|
+
def find_member(query)
|
94
|
+
project.memberships.detect do |m|
|
95
|
+
m.person.name.downcase.start_with?(query.downcase) || m.person.initials.downcase == query.downcase
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def get_members
|
100
|
+
project.memberships fields: ':default,person'
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
def mark_task_as(task, state)
|
105
|
+
task.current_state = state
|
106
|
+
task.save
|
107
|
+
end
|
108
|
+
|
109
|
+
def estimate_story(task, points)
|
110
|
+
task.estimate = points
|
111
|
+
task.save
|
112
|
+
end
|
113
|
+
|
114
|
+
def assign_story(story, owner)
|
115
|
+
story.add_owner(owner)
|
116
|
+
story.save
|
117
|
+
end
|
118
|
+
|
119
|
+
def add_label(task, label)
|
120
|
+
task.add_label(label)
|
121
|
+
task.save
|
122
|
+
end
|
123
|
+
|
124
|
+
def comment_task(story, comment)
|
125
|
+
task.create_comment(text: comment)
|
126
|
+
end
|
127
|
+
|
128
|
+
def create_story(args)
|
129
|
+
project.create_story(args)
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'config'
|
2
|
+
module PT
|
3
|
+
class Configuration
|
4
|
+
include PT::IO
|
5
|
+
GLOBAL_CONFIG_PATH = ENV['HOME'] + "/.pt.yml"
|
6
|
+
LOCAL_CONFIG_PATH = Dir.pwd + '/.pt.yml'
|
7
|
+
|
8
|
+
attr_reader :client
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
Config.load_and_set_settings(GLOBAL_CONFIG_PATH, get_local_config_path)
|
12
|
+
unless (Settings[:pivotal_api_key] ||= ENV['PIVOTAL_API_KEY'])
|
13
|
+
message "I can't find info about your Pivotal Tracker account in #{GLOBAL_CONFIG_PATH}."
|
14
|
+
Settings[:pivotal_api_key] = ask "What is your token?"
|
15
|
+
congrats "Thanks!",
|
16
|
+
"Your API id is " + Settings[:pivotal_api_key],
|
17
|
+
"I'm saving it in #{GLOBAL_CONFIG_PATH} so you don't have to log in again."
|
18
|
+
save_config({"pivotal_api_key" => Settings[:pivotal_api_key]}, GLOBAL_CONFIG_PATH)
|
19
|
+
end
|
20
|
+
@client = Client.new
|
21
|
+
Settings[:user_name] ||= @client.me.name || ENV['pivotal_user_name']
|
22
|
+
Settings[:user_id] ||= @client.me.id || ENV['pivotal_user_id']
|
23
|
+
Settings[:user_initials] ||= @client.me.initials || ENV['pivotal_user_initials']
|
24
|
+
|
25
|
+
|
26
|
+
unless (Settings[:project_id] ||= ENV['PIVOTAL_PROJECT_ID'])
|
27
|
+
projects = ProjectTable.new(@client.projects)
|
28
|
+
project = select("Please select the project for the current directory", projects)
|
29
|
+
Settings[:project_id], Settings[:project_name] = project.id, project.name
|
30
|
+
end
|
31
|
+
|
32
|
+
save_config(Settings.to_hash, get_local_config_path())
|
33
|
+
end
|
34
|
+
|
35
|
+
def get_local_config_path
|
36
|
+
# If the local config path does not exist, check to see if we're in a git repo
|
37
|
+
# And if so, try the top level of the checkout
|
38
|
+
if (!File.exist?(LOCAL_CONFIG_PATH) && system('git rev-parse 2> /dev/null'))
|
39
|
+
return `git rev-parse --show-toplevel`.chomp() + '/.pt.yml'
|
40
|
+
else
|
41
|
+
return LOCAL_CONFIG_PATH
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def save_config(config, path)
|
46
|
+
config = stringify(config)
|
47
|
+
File.new(path, 'w') unless File.exists?(path)
|
48
|
+
File.open(path, 'w') {|f| f.write(config.to_yaml) }
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def check_local_config_path
|
54
|
+
if GLOBAL_CONFIG_PATH == get_local_config_path()
|
55
|
+
error("Please execute .pt inside your project directory and not in your home.")
|
56
|
+
exit
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def stringify(hash)
|
61
|
+
hash.inject({}) { |memo, (k,v)| memo[k.to_s] = v; memo }
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
data/lib/pt/data_row.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'iconv' unless "older_ruby?".respond_to?(:force_encoding)
|
2
|
+
|
3
|
+
module PT
|
4
|
+
class DataRow
|
5
|
+
|
6
|
+
attr_accessor :num, :record, :state, :owners
|
7
|
+
|
8
|
+
def initialize(orig, dataset)
|
9
|
+
@record = orig
|
10
|
+
@num = dataset.index(orig) + 1
|
11
|
+
if defined? orig.current_state
|
12
|
+
@state = orig.current_state
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def method_missing(method)
|
17
|
+
str = @record.send(method).to_s
|
18
|
+
str.respond_to?(:force_encoding) ? str.force_encoding('utf-8') : Iconv.iconv('UTF-8', 'UTF-8', str)
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
@record.send(self.to_s_attribute)
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_s_attribute
|
26
|
+
@n.to_s
|
27
|
+
end
|
28
|
+
|
29
|
+
def name
|
30
|
+
_name = @record.name
|
31
|
+
if _name.size > 15
|
32
|
+
_name[0..15] + '...'
|
33
|
+
else
|
34
|
+
_name
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def owners
|
39
|
+
if @record.instance_variable_get(:@owners).present?
|
40
|
+
@record.owners.map{|o| o.initials == Settings[:user_initials] ? o.initials.red : o.initials }.join(',')
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def state
|
45
|
+
state = @record.current_state
|
46
|
+
case state
|
47
|
+
when 'delivered'
|
48
|
+
state.yellow
|
49
|
+
when 'finished'
|
50
|
+
state.blue
|
51
|
+
when 'accepted'
|
52
|
+
state.green
|
53
|
+
when 'rejected'
|
54
|
+
state.red
|
55
|
+
when 'started'
|
56
|
+
state.white
|
57
|
+
else
|
58
|
+
state.black
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def story_type
|
63
|
+
t = @record.story_type
|
64
|
+
case t
|
65
|
+
when 'bug'
|
66
|
+
'🐞'
|
67
|
+
when 'feature'
|
68
|
+
'⭐'
|
69
|
+
when 'release'
|
70
|
+
'🏁'
|
71
|
+
when 'chore'
|
72
|
+
'⚙️'
|
73
|
+
else
|
74
|
+
t
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def estimate
|
79
|
+
@record.estimate.to_i if @record.estimate
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# require 'hirb'
|
2
|
+
# require 'hirb-unicode'
|
3
|
+
require 'terminal-table'
|
4
|
+
|
5
|
+
module PT
|
6
|
+
|
7
|
+
class DataTable
|
8
|
+
|
9
|
+
# extend ::Hirb::Console
|
10
|
+
|
11
|
+
def initialize(dataset, title=nil)
|
12
|
+
@title = title
|
13
|
+
@rows = dataset.map{ |row| DataRow.new(row, dataset) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def print(config={})
|
17
|
+
if @rows.empty?
|
18
|
+
puts "\n#{'-- empty list --'.center(36)}\n"
|
19
|
+
else
|
20
|
+
|
21
|
+
headers = [:num]
|
22
|
+
headers += self.class.headers.present? ? self.class.headers : self.class.fields
|
23
|
+
|
24
|
+
fields = [:num] + self.class.fields
|
25
|
+
rows = []
|
26
|
+
@rows.each_with_index do |row, index|
|
27
|
+
_row = fields.map { |f| row.send(f) }
|
28
|
+
rows << _row
|
29
|
+
end
|
30
|
+
table = Terminal::Table.new(title: @title, headings: headers,
|
31
|
+
rows: rows, style: { all_separators: true })
|
32
|
+
puts table
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def [](pos)
|
37
|
+
pos = pos.to_i
|
38
|
+
(pos < 1 || pos > @rows.length) ? nil : @rows[pos-1].record
|
39
|
+
end
|
40
|
+
|
41
|
+
def length
|
42
|
+
@rows.length
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.fields
|
46
|
+
[]
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.headers
|
50
|
+
[]
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
class ProjectTable < DataTable
|
57
|
+
|
58
|
+
def self.fields
|
59
|
+
[:name]
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
class TasksTable < DataTable
|
66
|
+
|
67
|
+
def self.fields
|
68
|
+
[:name, :owners, :story_type, :estimate, :state]
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.headers
|
72
|
+
[:title, :owners, :type, :pt, :state]
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
class MultiUserTasksTable < DataTable
|
78
|
+
|
79
|
+
def self.fields
|
80
|
+
[:owned_by, :name, :state, :id]
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
class PersonsTable < DataTable
|
86
|
+
|
87
|
+
def self.fields
|
88
|
+
[:initials, :name]
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
class TodoTaskTable < DataTable
|
94
|
+
|
95
|
+
def self.fields
|
96
|
+
[:description]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
class ActionTable < DataTable
|
101
|
+
|
102
|
+
def self.fields
|
103
|
+
[:action]
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|