flurin-ticgit 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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