mislav-ticgit 0.3.6

Sign up to get free protection for your applications and to get access to all the features.
data/bin/ti ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # This is a command line client that does all the actual tic commands
4
+ #
5
+ # authors: Scott Chacon <schacon@gmail.com>
6
+ # Mislav Marohnić <mislav.marohnic@gmail.com>
7
+
8
+ require File.dirname(__FILE__) + "/../lib/ticgit"
9
+
10
+ TicGit::CLI::start()
@@ -0,0 +1,403 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # starts a sinatra based web server that provides an interface to
4
+ # your ticgit tickets
5
+ #
6
+ # some of the sinatra code borrowed from sr's git-wiki
7
+ #
8
+ # author: Flurin Egger
9
+ # original author : Scott Chacon (schacon@gmail.com)
10
+ #
11
+
12
+ %w(rubygems sinatra git ticgit haml sass).each do |dependency|
13
+ begin
14
+ require dependency
15
+ rescue LoadError => e
16
+ puts "You need to install #{dependency} before we can proceed"
17
+ end
18
+ end
19
+
20
+ # !! TODO : if ARGV[1] is a path to a git repo, use that
21
+ # otherwise, look in ~/.ticgit
22
+
23
+ $ticgit = TicGit.open('.')
24
+
25
+ # Always load saved searches. (used in navigation)
26
+ before do
27
+ @saved = $ticgit.config['list_options'].keys rescue []
28
+ end
29
+
30
+ # Stylesheets
31
+ get('/_stylesheet.css') do
32
+ header("Content-Type" => "text/css;charset=utf-8")
33
+ sass :stylesheet_all
34
+ end
35
+ get('/_print.css') do
36
+ header("Content-Type" => "text/css;charset=utf-8")
37
+ sass :stylesheet_print
38
+ end
39
+
40
+ # ticket list view
41
+ get '/' do
42
+ @tickets = $ticgit.ticket_list(:order => 'date.desc')
43
+ haml :list, :locals => {:title => "All tickets"}
44
+ end
45
+
46
+ get '/fs/:state' do
47
+ @tickets = $ticgit.ticket_list(:state => params[:state], :order => 'date.desc')
48
+ haml :list, :locals => {:title => "#{params[:state].to_s.capitalize} tickets"}
49
+ end
50
+
51
+ get '/tag/:tag' do
52
+ @tickets = $ticgit.ticket_list(:tag => params[:tag], :order => 'date.desc')
53
+ haml :list, :locals => {:title => "All tickets with tag '#{params[:tag]}'"}
54
+ end
55
+
56
+ get '/sv/:saved_view' do
57
+ @tickets = $ticgit.ticket_list(:saved => params[:saved_view])
58
+ haml :list, :locals => {:title => "All tickets in view '#{params[:saved_view]}'"}
59
+ end
60
+
61
+ # ticket single view
62
+ get '/ticket/:ticket' do
63
+ @ticket = $ticgit.ticket_show(params[:ticket])
64
+ haml :show, :locals => {:title => "Ticket #{@ticket.ticket_id}"}
65
+ end
66
+
67
+
68
+ # add ticket
69
+ get '/t/new' do
70
+ haml :new, :locals => {:title => "Create new ticket"}
71
+ end
72
+
73
+ # add ticket finalize
74
+ post '/t/new' do
75
+ title = params[:title].to_s.strip
76
+ if title.size > 1
77
+ tags = params[:tags].split(',').map { |t| t.strip } rescue nil
78
+ t = $ticgit.ticket_new(title, {:description => params[:description].strip, :tags => tags})
79
+ redirect '/ticket/' + t.ticket_id.to_s
80
+ else
81
+ redirect '/t/new'
82
+ end
83
+ end
84
+
85
+
86
+ # add comment
87
+ post '/a/add_comment/:ticket' do
88
+ t = $ticgit.ticket_comment(params[:comment], params[:ticket])
89
+ redirect '/ticket/' + params[:ticket]
90
+ end
91
+
92
+ # add tag
93
+ post '/a/add_tags/:ticket' do
94
+ t = $ticgit.ticket_tag(params[:tags], params[:ticket])
95
+ redirect '/ticket/' + params[:ticket]
96
+ end
97
+
98
+ # change ticket state
99
+ get '/a/change_state/:ticket/:state' do
100
+ $ticgit.ticket_change(params[:state], params[:ticket])
101
+ redirect '/ticket/' + params[:ticket]
102
+ end
103
+
104
+ use_in_file_templates!
105
+
106
+ __END__
107
+ ## layout
108
+ :plain
109
+ <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
110
+ "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
111
+ %html{:xmlns => "http://www.w3.org/1999/xhtml", "xml:lang" => "en", :lang=>"en"}
112
+ %head
113
+ %title= title
114
+ %link{:rel => 'stylesheet', :href => '/_stylesheet.css', :type => 'text/css', :media => 'all'}
115
+ %link{:rel => 'stylesheet', :href => '/_print.css', :type => 'text/css', :media => 'print'}
116
+ %meta{'http-equiv' => 'Content-Type', :content => 'text/html; charset=utf-8'}
117
+ %body
118
+ #wrapper
119
+ #action
120
+ %a{:href => '/t/new'} New Ticket
121
+ %ul#navigation
122
+ %li
123
+ %a{:href => '/'} All
124
+ %li
125
+ %a{:href => '/fs/open'} Open
126
+ %li
127
+ %a{:href => '/fs/resolved'} Resolved
128
+ %li
129
+ %a{:href => '/fs/hold'} Hold
130
+ %li
131
+ %a{:href => '/fs/invalid'} Invalid
132
+ - if @saved && !@saved.empty?
133
+ %li | Saved:
134
+ %ul.saved
135
+ - @saved.each do |s|
136
+ %li
137
+ %a{:href => "/sv/\#{s}"}= s
138
+
139
+ = yield
140
+
141
+ ## list
142
+ %h1= title
143
+ - if @tickets.empty?
144
+ %p No tickets found.
145
+ - else
146
+ %table.long
147
+ %thead
148
+ %tr
149
+ %th SHA
150
+ %th.ticket Ticket
151
+ %th State
152
+ %th Created&nbsp;at
153
+ %th Created&nbsp;by
154
+ %th Tags
155
+ %tbody
156
+ - c = 'even'
157
+ - @tickets.each do |t|
158
+ %tr{:class => (c == 'even' ? c = 'odd' : c = 'even') }
159
+ %td
160
+ %a{:href => "/ticket/#{t.ticket_id}" }
161
+ %code= t.ticket_id[0,6]
162
+ %td
163
+ %strong
164
+ %a{:href => "/ticket/#{t.ticket_id}" }= t.title
165
+ .content~ t.description
166
+ %td{:class => t.state}= t.state
167
+ %td= t.opened.strftime("%m/%d")
168
+ %td= t.assigned_name
169
+ %td
170
+ - t.tags.each do |tag|
171
+ %a.tag{:href => "/tag/#{tag}"}= tag
172
+
173
+ ## show
174
+ %h1= @ticket.title
175
+ .content~ @ticket.description
176
+ %hr/
177
+ %form{:action => "/a/add_tags/#{@ticket.ticket_id}", :method => 'post'}
178
+ %table.twocol
179
+ %tr
180
+ %th TicId
181
+ %td
182
+ %code= @ticket.ticket_id
183
+ %tr
184
+ %th Assigned
185
+ %td= @ticket.assigned
186
+ %tr
187
+ %th Opened
188
+ %td= @ticket.opened
189
+ %tr
190
+ %th State
191
+ %td{:class => @ticket.state}
192
+ %table{:width => '300'}
193
+ %tr
194
+ %td{:width=>'90%'}= @ticket.state
195
+ - $ticgit.tic_states.select { |s| s != @ticket.state}.each do |st|
196
+ %td{:class => st}
197
+ %a{:href => "/a/change_state/#{@ticket.ticket_id}/#{st}"}= st[0,2]
198
+ %tr
199
+ %th Tags
200
+ %td
201
+ - @ticket.tags.each do |t|
202
+ %a.tag{:href => "/tag/#{t}"}= t
203
+ %div.addtag
204
+ %input{:name => 'tags'}
205
+ %input{:type => 'submit', :value => 'add tag'}
206
+
207
+ %h3 Comments
208
+ %form{:action => "/a/add_comment/#{@ticket.ticket_id}", :method => 'post'}
209
+ %div
210
+ %textarea{:name => 'comment', :cols => 50}
211
+ %br
212
+ %input{:type => 'submit', :value => 'add comment'}
213
+
214
+ %div.comments
215
+ - @ticket.comments.reverse.each do |t|
216
+ %div.comment
217
+ %span.head
218
+ Added
219
+ = t.added.strftime("%m/%d %H:%M")
220
+ by
221
+ = t.user
222
+ %div.comment-text
223
+ = t.comment
224
+ %br
225
+
226
+ ## new
227
+ %h1 Create a New Ticket
228
+ %form{:action => '/t/new', :method => 'post'}
229
+ %table
230
+ %tr
231
+ %th Title
232
+ %td
233
+ %input{:type => 'text', :name => 'title', :size => 30}
234
+ %tr
235
+ %th Description
236
+ %td
237
+ %textarea{:rows => 15, :cols => 30, :name => 'description'}
238
+ %tr
239
+ %th Tags
240
+ %td
241
+ %input{:name => 'tags', :size => 30}
242
+ %small (comma delimited)
243
+ %tr
244
+ %td
245
+ %td
246
+ %input{:type => 'submit', :value => 'Create Ticket'}
247
+
248
+ ## stylesheet_all
249
+ body
250
+ :font
251
+ family: Verdana, Arial, "Bitstream Vera Sans", Helvetica, sans-serif
252
+ color: black
253
+ size: 62.5%
254
+ line-height: 1.2
255
+ background-color: white
256
+ margin: 2em
257
+
258
+ #wrapper
259
+ font-size: 1.2em
260
+ width: 90%
261
+ margin: 0 auto
262
+
263
+ // Autoclearing
264
+ #navigation:after
265
+ content: "."
266
+ visibility: hidden
267
+ clear: both
268
+ display: block
269
+ height: 0px
270
+
271
+ // IE autoclearing
272
+ #navigation
273
+ zoom: 1
274
+
275
+ #navigation
276
+ li
277
+ float: left
278
+ margin-right: 0.5em
279
+ a
280
+ background-color: #e0e0e0
281
+ color: black
282
+ text-decoration: none
283
+ padding: 2px
284
+ margin: 0
285
+ list-style: none
286
+ padding: 5px
287
+ border-bottom: 1px black solid
288
+
289
+ #action
290
+ text-align: right
291
+ float: right
292
+ a
293
+ background: #005
294
+ padding: 5px 10px
295
+ font-weight: bold
296
+ color: #fff
297
+ float: left
298
+
299
+ .addtag
300
+ padding: 5px 0
301
+
302
+ h1
303
+ display: block
304
+ padding-bottom: 5px
305
+
306
+ a
307
+ color: black
308
+ a.exists
309
+ font-weight: bold
310
+ a.unknown
311
+ font-style: italic
312
+
313
+ a.tag
314
+ padding: 2px 5px
315
+ background: #888
316
+ color: #fff
317
+ font-weight: normal
318
+ font-size: 80%
319
+ float: left
320
+ margin: 1px 2px
321
+ text-decoration: none
322
+
323
+ .comments
324
+ margin: 10px 20px
325
+ .comment
326
+ .head
327
+ background: #eee
328
+ padding: 4px
329
+ .comment-text
330
+ padding: 10px
331
+ color: #333
332
+
333
+ table.long
334
+ width: 100%
335
+
336
+ table.twocol
337
+ background: #f2f2f2
338
+ th
339
+ width: 30%
340
+
341
+ table
342
+ font-size: 100%
343
+ border-collapse: collapse
344
+ td,th
345
+ vertical-align: top
346
+ tr.even
347
+ td
348
+ background: #eee
349
+ tr.odd
350
+ td
351
+ background: #fff
352
+
353
+ table
354
+ tr
355
+ td,th
356
+ padding: 3px 5px
357
+ border-bottom: 1px solid #fff
358
+ th
359
+ text-align: left
360
+ vertical-align: top
361
+ th.ticket
362
+ width: 50%
363
+ td.open
364
+ background: #ada
365
+ td.resolved
366
+ background: #abd
367
+ td.hold
368
+ background: #dda
369
+ td.invalid
370
+ background: #aaa
371
+
372
+ strong a
373
+ text-decoration: none
374
+
375
+ table
376
+ thead
377
+ tr
378
+ td,th
379
+ border-bottom: 1px solid #000
380
+
381
+ .submit
382
+ font-size: large
383
+ font-weight: bold
384
+
385
+ .page_title
386
+ font-size: xx-large
387
+
388
+ .edit_link
389
+ color: black
390
+ font-size: 14px
391
+ font-weight: bold
392
+ background-color: #e0e0e0
393
+ font-variant: small-caps
394
+ text-decoration: none
395
+
396
+ ## stylesheet_print
397
+ #navigation, #action
398
+ display: none
399
+
400
+ table
401
+ tr.odd, tr.even
402
+ td
403
+ border-bottom: 1px solid #ddd
@@ -0,0 +1,28 @@
1
+ # Add the directory containing this file to the start of the load path if it
2
+ # isn't there already.
3
+ $:.unshift(File.dirname(__FILE__)) unless
4
+ $:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
5
+
6
+ require 'rubygems'
7
+ # requires git >= 1.0.5
8
+ require 'git'
9
+ require 'ticgit/base'
10
+ require 'ticgit/ticket'
11
+ require 'ticgit/comment'
12
+
13
+ require 'ticgit/cli'
14
+
15
+ # TicGit Library
16
+ #
17
+ # This library implements a git based ticketing system in a git repo
18
+ #
19
+ # Author:: Scott Chacon (mailto:schacon@gmail.com)
20
+ # License:: MIT License
21
+ #
22
+ module TicGit
23
+ # options
24
+ # :logger => Logger.new(STDOUT)
25
+ def self.open(git_dir, options = {})
26
+ Base.new(git_dir, options)
27
+ end
28
+ end
@@ -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
@@ -0,0 +1,280 @@
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
+ def get_editor_message(message_file = nil)
206
+ message_file = Tempfile.new('ticgit_message').path if !message_file
207
+
208
+ editor = ENV["EDITOR"] || 'vim'
209
+ system("#{editor} #{message_file}");
210
+ message = File.read(message_file)
211
+
212
+ # We must have some content before the # --- line
213
+ message, meta = message.split("# ---")
214
+ if message =~ /[^\s]+/m
215
+ return [message,meta]
216
+ else
217
+ return [false,false]
218
+ end
219
+ end
220
+
221
+ def parse_editor_message(message, meta)
222
+ message.strip!
223
+ puts message.inspect
224
+ title, description = message.split(/\r?\n/,2).map { |t| t.strip }
225
+ tags = []
226
+ # Strip comments from meta block
227
+ meta.gsub!(/^\s*#.*$/, "")
228
+ meta.split("\n").each do |line|
229
+ if line[0, 5] == 'tags:'
230
+ tags = line.gsub('tags:', '')
231
+ tags = tags.split(',').map { |t| t.strip }
232
+ end
233
+ end
234
+ [title, description, tags]
235
+ end
236
+
237
+ def just(value, size, side = 'l')
238
+ value = value.to_s
239
+ if value.size > size
240
+ value = value[0, size]
241
+ end
242
+ if side == 'r'
243
+ return value.rjust(size)
244
+ else
245
+ return value.ljust(size)
246
+ end
247
+ end
248
+
249
+ def output_ticket_list(tickets)
250
+ counter = 0
251
+
252
+ puts
253
+ puts [' ', just('#', 4, 'r'),
254
+ just('TicId', 6),
255
+ just('Title', 25),
256
+ just('State', 5),
257
+ just('Date', 5),
258
+ just('Assgn', 8),
259
+ just('Tags', 20) ].join(" ")
260
+
261
+ a = []
262
+ 80.times { a << '-'}
263
+ puts a.join('')
264
+
265
+ tickets.each do |t|
266
+ counter += 1
267
+ tic.current_ticket == t.ticket_name ? add = '*' : add = ' '
268
+ puts [add, just(counter, 4, 'r'),
269
+ t.ticket_id[0,6],
270
+ just(t.title, 25),
271
+ just(t.state, 5),
272
+ t.opened.strftime("%m/%d"),
273
+ just(t.assigned_name, 8),
274
+ just(t.tags.join(','), 20) ].join(" ")
275
+ end
276
+ puts
277
+ end
278
+
279
+ end
280
+ 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
@@ -0,0 +1,224 @@
1
+ module TicGit
2
+ class Ticket
3
+
4
+ attr_reader :base, :opts
5
+ attr_accessor :ticket_id, :ticket_name
6
+ attr_accessor :title, :description, :state, :milestone, :assigned, :opened
7
+ attr_accessor :comments, :tags, :attachments # arrays
8
+
9
+ def initialize(base, options = {})
10
+ options[:user_name] ||= base.git.config('user.name')
11
+ options[:user_email] ||= base.git.config('user.email')
12
+
13
+ @base = base
14
+ @opts = options || {}
15
+
16
+ @description = options.delete(:description)
17
+ @state = 'open' # by default
18
+ @comments = []
19
+ @tags = []
20
+ @attachments = []
21
+ end
22
+
23
+ def self.create(base, title, options = {})
24
+ t = Ticket.new(base, options)
25
+ t.title = title
26
+ t.ticket_name = self.create_ticket_name(title)
27
+ t.save_new
28
+ t
29
+ end
30
+
31
+ def self.open(base, ticket_name, ticket_hash, options = {})
32
+ tid = nil
33
+
34
+ t = Ticket.new(base, options)
35
+ t.ticket_name = ticket_name
36
+
37
+ basic_title, date = self.parse_ticket_name(ticket_name)
38
+
39
+ # t.title will be overridden below if the file TICKET_TITLE exists
40
+ t.title = basic_title
41
+ t.opened = date
42
+
43
+ ticket_hash['files'].each do |fname, value|
44
+ if fname == 'TICKET_ID'
45
+ t.ticket_id = value
46
+ elsif fname == 'TICKET_TITLE'
47
+ t.title = base.git.gblob(value).contents rescue nil
48
+ elsif fname == 'TICKET_DESCRIPTION'
49
+ t.description = base.git.gblob(value).contents rescue nil
50
+ else
51
+ # matching
52
+ data = fname.split('_')
53
+ if data[0] == 'ASSIGNED'
54
+ t.assigned = data[1]
55
+ end
56
+ if data[0] == 'COMMENT'
57
+ t.comments << TicGit::Comment.new(base, fname, value)
58
+ end
59
+ if data[0] == 'TAG'
60
+ t.tags << data[1]
61
+ end
62
+ if data[0] == 'STATE'
63
+ t.state = data[1]
64
+ end
65
+ end
66
+ end
67
+
68
+
69
+ t
70
+ end
71
+
72
+
73
+ def self.parse_ticket_name(name)
74
+ unless name =~ /^([^_]+)_(.+)_([^_]+)$/
75
+ raise "invalid ticket name #{name.inspect}"
76
+ end
77
+ epoch, title, rand = $1, $2.gsub('-', ' '), $3
78
+ return [title, Time.at(epoch.to_i)]
79
+ end
80
+
81
+ # write this ticket to the git database
82
+ def save_new
83
+ base.in_branch do |wd|
84
+ base.logger.info "saving #{ticket_name}"
85
+
86
+ Dir.mkdir(ticket_name)
87
+ Dir.chdir(ticket_name) do
88
+ base.new_file('TICKET_ID', ticket_name)
89
+ base.new_file('ASSIGNED_' + email, email)
90
+ base.new_file('STATE_' + state, state)
91
+ base.new_file('TICKET_TITLE',self.title)
92
+ base.new_file('TICKET_DESCRIPTION',self.description) if self.description
93
+
94
+ # add initial comment
95
+ #COMMENT_080315060503045__schacon_at_gmail
96
+ base.new_file(comment_name(email), opts[:comment]) if opts[:comment]
97
+
98
+ # add initial tags
99
+ if opts[:tags] && opts[:tags].size > 0
100
+ opts[:tags] = opts[:tags].map { |t| t.strip }.compact
101
+ opts[:tags].each do |tag|
102
+ if tag.size > 0
103
+ tag_filename = 'TAG_' + Ticket.clean_string(tag)
104
+ if !File.exists?(tag_filename)
105
+ base.new_file(tag_filename, tag_filename)
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ base.git.add
113
+ base.git.commit("added ticket #{ticket_name}")
114
+ end
115
+ # ticket_id
116
+ end
117
+
118
+ def self.clean_string(string)
119
+ string.downcase.gsub(/[^a-zA-Z0-9_:;,.]+/, '-')
120
+ end
121
+
122
+ def add_comment(comment)
123
+ return false if !comment
124
+ base.in_branch do |wd|
125
+ Dir.chdir(ticket_name) do
126
+ base.new_file(comment_name(email), comment)
127
+ end
128
+ base.git.add
129
+ base.git.commit("added comment to ticket #{ticket_name}")
130
+ end
131
+ end
132
+
133
+ def change_state(new_state)
134
+ return false if !new_state
135
+ return false if new_state == state
136
+
137
+ base.in_branch do |wd|
138
+ Dir.chdir(ticket_name) do
139
+ base.new_file('STATE_' + new_state, new_state)
140
+ end
141
+ base.git.remove(File.join(ticket_name,'STATE_' + state))
142
+ base.git.add
143
+ base.git.commit("added state (#{new_state}) to ticket #{ticket_name}")
144
+ end
145
+ end
146
+
147
+ def change_assigned(new_assigned)
148
+ new_assigned ||= email
149
+ return false if new_assigned == assigned
150
+
151
+ base.in_branch do |wd|
152
+ Dir.chdir(ticket_name) do
153
+ base.new_file('ASSIGNED_' + new_assigned, new_assigned)
154
+ end
155
+ base.git.remove(File.join(ticket_name,'ASSIGNED_' + assigned))
156
+ base.git.add
157
+ base.git.commit("assigned #{new_assigned} to ticket #{ticket_name}")
158
+ end
159
+ end
160
+
161
+ def add_tag(tag)
162
+ return false if !tag
163
+ added = false
164
+ tags = tag.split(',').map { |t| t.strip }
165
+ base.in_branch do |wd|
166
+ Dir.chdir(ticket_name) do
167
+ tags.each do |add_tag|
168
+ if add_tag.size > 0
169
+ tag_filename = 'TAG_' + Ticket.clean_string(add_tag)
170
+ if !File.exists?(tag_filename)
171
+ base.new_file(tag_filename, tag_filename)
172
+ added = true
173
+ end
174
+ end
175
+ end
176
+ end
177
+ if added
178
+ base.git.add
179
+ base.git.commit("added tags (#{tag}) to ticket #{ticket_name}")
180
+ end
181
+ end
182
+ end
183
+
184
+ def remove_tag(tag)
185
+ return false if !tag
186
+ removed = false
187
+ tags = tag.split(',').map { |t| t.strip }
188
+ base.in_branch do |wd|
189
+ tags.each do |add_tag|
190
+ tag_filename = File.join(ticket_name, 'TAG_' + Ticket.clean_string(add_tag))
191
+ if File.exists?(tag_filename)
192
+ base.git.remove(tag_filename)
193
+ removed = true
194
+ end
195
+ end
196
+ if removed
197
+ base.git.commit("removed tags (#{tag}) from ticket #{ticket_name}")
198
+ end
199
+ end
200
+ end
201
+
202
+ def path
203
+ File.join(state, ticket_name)
204
+ end
205
+
206
+ def comment_name(email)
207
+ 'COMMENT_' + Time.now.to_i.to_s + '_' + email
208
+ end
209
+
210
+ def email
211
+ opts[:user_email] || 'anon'
212
+ end
213
+
214
+ def assigned_name
215
+ assigned.split('@').first rescue ''
216
+ end
217
+
218
+ def self.create_ticket_name(title)
219
+ [Time.now.to_i.to_s, Ticket.clean_string(title), rand(999).to_i.to_s].join('_')
220
+ end
221
+
222
+
223
+ end
224
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: mislav-ticgit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.6
5
+ platform: ruby
6
+ authors:
7
+ - Scott Chacon
8
+ - "Mislav Marohni\xC4\x87"
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2008-09-11 00:00:00 -07:00
14
+ default_executable:
15
+ dependencies:
16
+ - !ruby/object:Gem::Dependency
17
+ name: schacon-git
18
+ version_requirement:
19
+ version_requirements: !ruby/object:Gem::Requirement
20
+ requirements:
21
+ - - ~>
22
+ - !ruby/object:Gem::Version
23
+ version: 1.0.5
24
+ version:
25
+ - !ruby/object:Gem::Dependency
26
+ name: wycats-thor
27
+ version_requirement:
28
+ version_requirements: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ~>
31
+ - !ruby/object:Gem::Version
32
+ version: 0.9.5
33
+ version:
34
+ description:
35
+ email: schacon@gmail.com
36
+ executables:
37
+ - ti
38
+ - ticgitweb
39
+ extensions: []
40
+
41
+ extra_rdoc_files: []
42
+
43
+ files:
44
+ - lib/ticgit/base.rb
45
+ - lib/ticgit/cli.rb
46
+ - lib/ticgit/comment.rb
47
+ - lib/ticgit/ticket.rb
48
+ - lib/ticgit.rb
49
+ - bin/ti
50
+ - bin/ticgitweb
51
+ has_rdoc: false
52
+ homepage: http://github.com/schacon/ticgit/wikis
53
+ post_install_message:
54
+ rdoc_options: []
55
+
56
+ require_paths:
57
+ - lib
58
+ required_ruby_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: "0"
63
+ version:
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: "0"
69
+ version:
70
+ requirements: []
71
+
72
+ rubyforge_project:
73
+ rubygems_version: 1.2.0
74
+ signing_key:
75
+ specification_version: 2
76
+ summary: A distributed ticketing system for Git projects.
77
+ test_files: []
78
+