flurin-ticgit 0.3.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,308 @@
1
+ require 'logger'
2
+ require 'fileutils'
3
+ require 'yaml'
4
+
5
+ module TicGit
6
+ class NoRepoFound < StandardError;end
7
+ class Base
8
+
9
+ attr_reader :git, :logger
10
+ attr_reader :tic_working, :tic_index
11
+ attr_reader :tickets, :last_tickets, :current_ticket # saved in state
12
+ attr_reader :config
13
+ attr_reader :state, :config_file
14
+
15
+ def initialize(git_dir, opts = {})
16
+ @git = Git.open(find_repo(git_dir))
17
+ @logger = opts[:logger] || Logger.new(STDOUT)
18
+
19
+ proj = Ticket.clean_string(@git.dir.path)
20
+
21
+ @tic_dir = opts[:tic_dir] || '~/.ticgit'
22
+ @tic_working = opts[:working_directory] || File.expand_path(File.join(@tic_dir, proj, 'working'))
23
+ @tic_index = opts[:index_file] || File.expand_path(File.join(@tic_dir, proj, 'index'))
24
+
25
+ # load config file
26
+ @config_file = File.expand_path(File.join(@tic_dir, proj, 'config.yml'))
27
+ if File.exists?(config_file)
28
+ @config = YAML.load(File.read(config_file))
29
+ else
30
+ @config = {}
31
+ end
32
+
33
+ @state = File.expand_path(File.join(@tic_dir, proj, 'state'))
34
+
35
+ if File.exists?(@state)
36
+ load_state
37
+ else
38
+ reset_ticgit
39
+ end
40
+ end
41
+
42
+ def find_repo(dir)
43
+ full = File.expand_path(dir)
44
+ ENV["GIT_WORKING_DIR"] || loop do
45
+ return full if File.directory?(File.join(full, ".git"))
46
+ raise NoRepoFound if full == full=File.dirname(full)
47
+ end
48
+ end
49
+
50
+ def save_state
51
+ # marshal dump the internals
52
+ File.open(@state, 'w') { |f| Marshal.dump([@tickets, @last_tickets, @current_ticket], f) } rescue nil
53
+ # save config file
54
+ File.open(@config_file, 'w') { |f| f.write(config.to_yaml) }
55
+ end
56
+
57
+ def load_state
58
+ # read in the internals
59
+ if(File.exists?(@state))
60
+ @tickets, @last_tickets, @current_ticket = File.open(@state) { |f| Marshal.load(f) } rescue nil
61
+ end
62
+ end
63
+
64
+ # returns new Ticket
65
+ def ticket_new(title, options = {})
66
+ t = TicGit::Ticket.create(self, title, options)
67
+ reset_ticgit
68
+ TicGit::Ticket.open(self, t.ticket_name, @tickets[t.ticket_name])
69
+ end
70
+
71
+ def reset_ticgit
72
+ load_tickets
73
+ save_state
74
+ end
75
+
76
+ # returns new Ticket
77
+ def ticket_comment(comment, ticket_id = nil)
78
+ if t = ticket_revparse(ticket_id)
79
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
80
+ ticket.add_comment(comment)
81
+ reset_ticgit
82
+ end
83
+ end
84
+
85
+ # returns array of Tickets
86
+ def ticket_list(options = {})
87
+ ts = []
88
+ @last_tickets = []
89
+ @config['list_options'] ||= {}
90
+
91
+ @tickets.to_a.each do |name, t|
92
+ ts << TicGit::Ticket.open(self, name, t)
93
+ end
94
+
95
+ if name = options[:saved]
96
+ if c = config['list_options'][name]
97
+ options = c.merge(options)
98
+ end
99
+ end
100
+
101
+ if options[:list]
102
+ # TODO : this is a hack and i need to fix it
103
+ config['list_options'].each do |name, opts|
104
+ puts name + "\t" + opts.inspect
105
+ end
106
+ return false
107
+ end
108
+
109
+ # SORTING
110
+ if field = options[:order]
111
+ field, type = field.split('.')
112
+ field = "opened" if field == "date" || field.nil?
113
+ ts = sort_list_by_keys(ts,[field,:ticket_id])
114
+ ts = ts.reverse if type == 'desc'
115
+ else
116
+ # default list
117
+ ts = ts.sort { |a, b| a.opened <=> b.opened }
118
+ end
119
+
120
+ if options.size == 0
121
+ # default list
122
+ options[:state] = 'open'
123
+ end
124
+
125
+ # :tag, :state, :assigned
126
+ if t = options[:tag]
127
+ ts = ts.select { |tag| tag.tags.include?(t) }
128
+ end
129
+ if s = options[:state]
130
+ ts = ts.select { |tag| tag.state =~ /#{s}/ }
131
+ end
132
+ if a = options[:assigned]
133
+ ts = ts.select { |tag| tag.assigned =~ /#{a}/ }
134
+ end
135
+
136
+ if save = options[:save]
137
+ options.delete(:save)
138
+ @config['list_options'][save] = options
139
+ end
140
+
141
+ @last_tickets = ts.map { |t| t.ticket_name }
142
+ # :save
143
+
144
+ save_state
145
+ ts
146
+ end
147
+
148
+
149
+ def sort_list_by_keys(list,keys)
150
+ return list if keys.find{|k| !list.first.respond_to?(k) }
151
+ list.sort do |a,b|
152
+ value,i = 0,0
153
+ while (keys.size > i && value == 0) do
154
+ value = a.send(keys[i]) <=> b.send(keys[i])
155
+ i += 1
156
+ end
157
+ value
158
+ end
159
+ end
160
+
161
+ # returns single Ticket
162
+ def ticket_show(ticket_id = nil)
163
+ # ticket_id can be index of last_tickets, partial sha or nil => last ticket
164
+ if t = ticket_revparse(ticket_id)
165
+ return TicGit::Ticket.open(self, t, @tickets[t])
166
+ end
167
+ end
168
+
169
+ # returns recent ticgit activity
170
+ # uses the git logs for this
171
+ def ticket_recent(ticket_id = nil)
172
+ if ticket_id
173
+ t = ticket_revparse(ticket_id)
174
+ return git.log.object('ticgit').path(t)
175
+ else
176
+ return git.log.object('ticgit')
177
+ end
178
+ end
179
+
180
+ def ticket_revparse(ticket_id)
181
+ if ticket_id
182
+ if /^[0-9]*$/ =~ ticket_id
183
+ if t = @last_tickets[ticket_id.to_i - 1]
184
+ return t
185
+ end
186
+ else
187
+ # partial or full sha
188
+ if ch = @tickets.select { |name, t| t['files'].assoc('TICKET_ID')[1] =~ /^#{ticket_id}/ }
189
+ return ch.first[0]
190
+ end
191
+ end
192
+ elsif(@current_ticket)
193
+ return @current_ticket
194
+ end
195
+ end
196
+
197
+ def ticket_tag(tag, ticket_id = nil, options = {})
198
+ if t = ticket_revparse(ticket_id)
199
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
200
+ if options[:remove]
201
+ ticket.remove_tag(tag)
202
+ else
203
+ ticket.add_tag(tag)
204
+ end
205
+ reset_ticgit
206
+ end
207
+ end
208
+
209
+ def ticket_change(new_state, ticket_id = nil)
210
+ if t = ticket_revparse(ticket_id)
211
+ if tic_states.include?(new_state)
212
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
213
+ ticket.change_state(new_state)
214
+ reset_ticgit
215
+ end
216
+ end
217
+ end
218
+
219
+ def ticket_assign(new_assigned = nil, ticket_id = nil)
220
+ if t = ticket_revparse(ticket_id)
221
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
222
+ ticket.change_assigned(new_assigned)
223
+ reset_ticgit
224
+ end
225
+ end
226
+
227
+ def ticket_checkout(ticket_id)
228
+ if t = ticket_revparse(ticket_id)
229
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
230
+ @current_ticket = ticket.ticket_name
231
+ save_state
232
+ end
233
+ end
234
+
235
+ def comment_add(ticket_id, comment, options = {})
236
+ end
237
+
238
+ def comment_list(ticket_id)
239
+ end
240
+
241
+ def tic_states
242
+ ['open', 'resolved', 'invalid', 'hold']
243
+ end
244
+
245
+ def load_tickets
246
+ @tickets = {}
247
+
248
+ bs = git.lib.branches_all.map { |b| b[0] }
249
+ init_ticgit_branch(bs.include?('ticgit')) if !(bs.include?('ticgit') && File.directory?(@tic_working))
250
+
251
+ tree = git.lib.full_tree('ticgit')
252
+ tree.each do |t|
253
+ data, file = t.split("\t")
254
+ mode, type, sha = data.split(" ")
255
+ tic = file.split('/')
256
+ if tic.size == 2 # directory depth
257
+ ticket, info = tic
258
+ @tickets[ticket] ||= { 'files' => [] }
259
+ @tickets[ticket]['files'] << [info, sha]
260
+ end
261
+ end
262
+ end
263
+
264
+ def init_ticgit_branch(ticgit_branch = false)
265
+ @logger.info 'creating ticgit repo branch'
266
+
267
+ in_branch(ticgit_branch) do
268
+ new_file('.hold', 'hold')
269
+ if !ticgit_branch
270
+ git.add
271
+ git.commit('creating the ticgit branch')
272
+ end
273
+ end
274
+ end
275
+
276
+ # temporarlily switches to ticgit branch for tic work
277
+ def in_branch(branch_exists = true)
278
+ needs_checkout = false
279
+ if !File.directory?(@tic_working)
280
+ FileUtils.mkdir_p(@tic_working)
281
+ needs_checkout = true
282
+ end
283
+ if !File.exists?('.hold')
284
+ needs_checkout = true
285
+ end
286
+
287
+ old_current = git.lib.branch_current
288
+ begin
289
+ git.lib.change_head_branch('ticgit')
290
+ git.with_index(@tic_index) do
291
+ git.with_working(@tic_working) do |wd|
292
+ git.lib.checkout('ticgit') if needs_checkout && branch_exists
293
+ yield wd
294
+ end
295
+ end
296
+ ensure
297
+ git.lib.change_head_branch(old_current)
298
+ end
299
+ end
300
+
301
+ def new_file(name, contents)
302
+ File.open(name, 'w') do |f|
303
+ f.puts contents
304
+ end
305
+ end
306
+
307
+ end
308
+ end
data/lib/ticgit/cli.rb ADDED
@@ -0,0 +1,338 @@
1
+ require 'ticgit'
2
+ require 'thor'
3
+
4
+ module TicGit
5
+ class CLI < Thor
6
+ attr_reader :tic
7
+
8
+ def initialize(opts = {}, *args)
9
+ @tic = TicGit.open('.', :keep_state => true)
10
+ $stdout.sync = true # so that Net::SSH prompts show up
11
+ rescue NoRepoFound
12
+ puts "No repo found"
13
+ exit
14
+ end
15
+
16
+ # tic milestone
17
+ # tic milestone migration1 (list tickets)
18
+ # tic milestone -n migration1 3/4/08 (new milestone)
19
+ # tic milestone -a {1} (add ticket to milestone)
20
+ # tic milestone -d migration1 (delete)
21
+ desc "milestone [<name>]", %(Add a new milestone to this project)
22
+ method_options :new => :optional, :add => :optional, :delete => :optional
23
+ def milestone(name = nil)
24
+ raise NotImplementedError
25
+ end
26
+
27
+ desc "recent [<ticket-id>]", %(Recent ticgit activity)
28
+ def recent(ticket_id = nil)
29
+ tic.ticket_recent(ticket_id).each do |commit|
30
+ puts commit.sha[0, 7] + " " + commit.date.strftime("%m/%d %H:%M") + "\t" + commit.message
31
+ end
32
+ end
33
+
34
+ desc "tag [<ticket-id>] [<tag1,tag2...>]", %(Add or remove ticket tags)
35
+ method_options %w(--remove -d) => :boolean
36
+ def tag(*args)
37
+ puts 'remove' if options[:remove]
38
+
39
+ tid = args.size > 1 && args.shift
40
+ tags = args.first
41
+
42
+ if tags
43
+ tic.ticket_tag(tags, tid, options)
44
+ else
45
+ puts 'You need to at least specify one tag to add'
46
+ end
47
+ end
48
+
49
+ desc "comment [<ticket-id>]", %(Comment on a ticket)
50
+ method_options :message => :optional, :file => :optional
51
+ def comment(ticket_id = nil)
52
+ if options[:file]
53
+ raise ArgumentError, "Only 1 of -f/--file and -m/--message can be specified" if options[:message]
54
+ file = options[:file]
55
+ raise ArgumentError, "File #{file} doesn't exist" unless File.file?(file)
56
+ raise ArgumentError, "File #{file} must be <= 2048 bytes" unless File.size(file) <= 2048
57
+ tic.ticket_comment(File.read(file), ticket_id)
58
+ elsif m = options[:message]
59
+ tic.ticket_comment(m, ticket_id)
60
+ else
61
+ message, meta = get_editor_message
62
+ if message
63
+ tic.ticket_comment(message.join(''), ticket_id)
64
+ end
65
+ end
66
+ end
67
+
68
+ desc "checkout <ticket-id>", %(Checkout a ticket)
69
+ def checkout(ticket_id)
70
+ tic.ticket_checkout(ticket_id)
71
+ end
72
+
73
+ desc "state [<ticket-id>] <state>", %(Change state of a ticket)
74
+ def state(id_or_state, state = nil)
75
+ if state.nil?
76
+ state = id_or_state
77
+ ticket_id = nil
78
+ else
79
+ ticket_id = id_or_state
80
+ end
81
+
82
+ if valid_state(state)
83
+ tic.ticket_change(state, ticket_id)
84
+ else
85
+ puts 'Invalid State - please choose from : ' + tic.tic_states.join(", ")
86
+ end
87
+ end
88
+
89
+ # Assigns a ticket to someone
90
+ #
91
+ # Usage:
92
+ # ti assign (assign checked out ticket to current user)
93
+ # ti assign {1} (assign ticket to current user)
94
+ # ti assign -c {1} (assign ticket to current user and checkout the ticket)
95
+ # ti assign -u {name} (assign ticket to specified user)
96
+ desc "assign [<ticket-id>]", %(Assign ticket to user)
97
+ method_options :user => :optional, :checkout => :optional
98
+ def assign(ticket_id = nil)
99
+ tic.ticket_checkout(options[:checkout]) if options[:checkout]
100
+ tic.ticket_assign(options[:user], ticket_id)
101
+ end
102
+
103
+ # "-o ORDER", "--order ORDER", "Field to order by - one of : assigned,state,date"
104
+ # "-t TAG", "--tag TAG", "List only tickets with specific tag"
105
+ # "-s STATE", "--state STATE", "List only tickets in a specific state"
106
+ # "-a ASSIGNED", "--assigned ASSIGNED", "List only tickets assigned to someone"
107
+ # "-S SAVENAME", "--saveas SAVENAME", "Save this list as a saved name"
108
+ # "-l", "--list", "Show the saved queries"
109
+ desc "list [<saved-query>]", %(Show existing tickets)
110
+ method_options :order => :optional, :tag => :optional, :state => :optional,
111
+ :assigned => :optional, :list => :optional, %w(--save-as -S) => :optional
112
+
113
+ def list(saved_query = nil)
114
+ opts = options.dup
115
+ opts[:saved] = saved_query if saved_query
116
+
117
+ if tickets = tic.ticket_list(opts)
118
+ output_ticket_list(tickets)
119
+ end
120
+ end
121
+
122
+ ## SHOW TICKETS ##
123
+
124
+ desc 'show <ticket-id>', %(Show a single ticket)
125
+ def show(ticket_id = nil)
126
+ if t = @tic.ticket_show(ticket_id)
127
+ ticket_show(t)
128
+ end
129
+ end
130
+
131
+ desc 'new', %(Create a new ticket)
132
+ method_options :title => :optional
133
+ def new
134
+ if title = options[:title]
135
+ ticket_show(@tic.ticket_new(title, options))
136
+ else
137
+ # interactive
138
+ message_file = Tempfile.new('ticgit_message').path
139
+ File.open(message_file, 'w') do |f|
140
+ f.puts "\n# ---"
141
+ f.puts "tags:"
142
+ f.puts "# The first line will be the title of the ticket,"
143
+ f.puts "# the rest will be the description. If you would like to add initial tags,"
144
+ f.puts "# put them on the 'tags:' line, comma delimited"
145
+ end
146
+
147
+ message, meta = get_editor_message(message_file)
148
+ if message
149
+ title, description, tags = parse_editor_message(message,meta)
150
+ if title and !title.empty?
151
+ ticket_show(@tic.ticket_new(title, :description => description, :tags => tags))
152
+ else
153
+ puts "You need to at least enter a title"
154
+ end
155
+ else
156
+ puts "It seems you wrote nothing"
157
+ end
158
+ end
159
+ end
160
+
161
+ protected
162
+
163
+ def valid_state(state)
164
+ tic.tic_states.include?(state)
165
+ end
166
+
167
+ def ticket_show(t)
168
+ days_ago = ((Time.now - t.opened) / (60 * 60 * 24)).round.to_s
169
+ puts
170
+ puts just('Title', 10) + ': ' + t.title
171
+ puts just('TicId', 10) + ': ' + t.ticket_id
172
+ if t.description
173
+ desc = t.description.split("\n").map{|l| " "*12 + l.strip}.join("\n").lstrip
174
+ puts just('Descr.',10) + ': ' + desc
175
+ end
176
+ puts
177
+ puts just('Assigned', 10) + ': ' + t.assigned.to_s
178
+ puts just('Opened', 10) + ': ' + t.opened.to_s + ' (' + days_ago + ' days)'
179
+ puts just('State', 10) + ': ' + t.state.upcase
180
+ if !t.tags.empty?
181
+ puts just('Tags', 10) + ': ' + t.tags.join(', ')
182
+ end
183
+ puts
184
+ if !t.comments.empty?
185
+ puts 'Comments (' + t.comments.size.to_s + '):'
186
+ t.comments.reverse.each do |c|
187
+ puts ' * Added ' + c.added.strftime("%m/%d %H:%M") + ' by ' + c.user
188
+
189
+ wrapped = c.comment.split("\n").collect do |line|
190
+ line.length > 80 ? line.gsub(/(.{1,80})(\s+|$)/, "\\1\n").strip : line
191
+ end * "\n"
192
+
193
+ wrapped = wrapped.split("\n").map { |line| "\t" + line }
194
+ if wrapped.size > 6
195
+ puts wrapped[0, 6].join("\n")
196
+ puts "\t** more... **"
197
+ else
198
+ puts wrapped.join("\n")
199
+ end
200
+ puts
201
+ end
202
+ end
203
+ end
204
+
205
+
206
+ ## NEW TICKETS ##
207
+
208
+ def parse_ticket_new
209
+ @options = {}
210
+ OptionParser.new do |opts|
211
+ opts.banner = "Usage: ti new [options]"
212
+ opts.on("-t TITLE", "--title TITLE", "Title to use for the name of the new ticket") do |v|
213
+ @options[:title] = v
214
+ end
215
+ end.parse!
216
+ end
217
+
218
+ def handle_ticket_new
219
+ parse_ticket_new
220
+ if(t = options[:title])
221
+ ticket_show(@tic.ticket_new(t, options))
222
+ else
223
+ # interactive
224
+ message_file = Tempfile.new('ticgit_message').path
225
+ File.open(message_file, 'w') do |f|
226
+ f.puts "\n# ---"
227
+ f.puts "tags:"
228
+ f.puts "# The first line will be the title of the ticket, "
229
+ f.puts "# the rest will be the description if you would like to add initial tags,"
230
+ f.puts "# put them on the 'tags:' line, comma delimited"
231
+ end
232
+ message,meta = get_editor_message(message_file)
233
+ if message
234
+ title,description,tags = parse_editor_message(message,meta)
235
+ if title != ""
236
+ ticket_show(@tic.ticket_new(title, :description => description, :tags => tags))
237
+ else
238
+ puts "You need to at least enter a title"
239
+ end
240
+ else
241
+ puts "It seems you wrote nothing"
242
+ end
243
+ end
244
+ end
245
+
246
+ def parse_editor_message(message,meta)
247
+ message.strip!
248
+ puts message.inspect
249
+ title,description = message.split(/\r?\n/,2).map{|t| t.strip }
250
+ tags = []
251
+ # Strip comments from meta block
252
+ meta.gsub!(/^\s*#.*$/,"")
253
+ meta.split("\n").each do |line|
254
+ if line[0, 5] == 'tags:'
255
+ tags = line.gsub('tags:', '')
256
+ tags = tags.split(',').map { |t| t.strip }
257
+ end
258
+ end
259
+ [title,description,tags]
260
+ end
261
+
262
+
263
+ def get_editor_message(message_file = nil)
264
+ message_file = Tempfile.new('ticgit_message').path if !message_file
265
+
266
+ editor = ENV["EDITOR"] || 'vim'
267
+ system("#{editor} #{message_file}");
268
+ message = File.read(message_file)
269
+
270
+ # We must have some content before the # --- line
271
+ message, meta = message.split("# ---")
272
+ if message =~ /[^\s]+/m
273
+ return [message,meta]
274
+ else
275
+ return [false,false]
276
+ end
277
+ end
278
+
279
+ def parse_editor_message(message, meta)
280
+ message.strip!
281
+ puts message.inspect
282
+ title, description = message.split(/\r?\n/,2).map { |t| t.strip }
283
+ tags = []
284
+ # Strip comments from meta block
285
+ meta.gsub!(/^\s*#.*$/, "")
286
+ meta.split("\n").each do |line|
287
+ if line[0, 5] == 'tags:'
288
+ tags = line.gsub('tags:', '')
289
+ tags = tags.split(',').map { |t| t.strip }
290
+ end
291
+ end
292
+ [title, description, tags]
293
+ end
294
+
295
+ def just(value, size, side = 'l')
296
+ value = value.to_s
297
+ if value.size > size
298
+ value = value[0, size]
299
+ end
300
+ if side == 'r'
301
+ return value.rjust(size)
302
+ else
303
+ return value.ljust(size)
304
+ end
305
+ end
306
+
307
+ def output_ticket_list(tickets)
308
+ counter = 0
309
+
310
+ puts
311
+ puts [' ', just('#', 4, 'r'),
312
+ just('TicId', 6),
313
+ just('Title', 25),
314
+ just('State', 5),
315
+ just('Date', 5),
316
+ just('Assgn', 8),
317
+ just('Tags', 20) ].join(" ")
318
+
319
+ a = []
320
+ 80.times { a << '-'}
321
+ puts a.join('')
322
+
323
+ tickets.each do |t|
324
+ counter += 1
325
+ tic.current_ticket == t.ticket_name ? add = '*' : add = ' '
326
+ puts [add, just(counter, 4, 'r'),
327
+ t.ticket_id[0,6],
328
+ just(t.title, 25),
329
+ just(t.state, 5),
330
+ t.opened.strftime("%m/%d"),
331
+ just(t.assigned_name, 8),
332
+ just(t.tags.join(','), 20) ].join(" ")
333
+ end
334
+ puts
335
+ end
336
+
337
+ end
338
+ end
@@ -0,0 +1,17 @@
1
+ module TicGit
2
+ class Comment
3
+
4
+ attr_reader :base, :user, :added, :comment
5
+
6
+ def initialize(base, file_name, sha)
7
+ @base = base
8
+ @comment = base.git.gblob(sha).contents rescue nil
9
+
10
+ type, date, user = file_name.split('_')
11
+
12
+ @added = Time.at(date.to_i)
13
+ @user = user
14
+ end
15
+
16
+ end
17
+ end