mislav-ticgit 0.3.6

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.
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
+