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,11 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ # This is a command line client that does all the actual tic commands
4
+ #
5
+ # author : Scott Chacon (schacon@gmail.com)
6
+ #
7
+
8
+ require 'rubygems'
9
+ require 'ticgit'
10
+ #require File.dirname(__FILE__) + '/../lib/ticgit'
11
+ TicGit::CLI.execute
@@ -0,0 +1,308 @@
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 : Scott Chacon (schacon@gmail.com)
9
+ #
10
+
11
+ %w(rubygems sinatra git ticgit haml).each do |dependency|
12
+ begin
13
+ require dependency
14
+ rescue LoadError => e
15
+ puts "You need to install #{dependency} before we can proceed"
16
+ end
17
+ end
18
+
19
+ # !! TODO : if ARGV[1] is a path to a git repo, use that
20
+ # otherwise, look in ~/.ticgit
21
+
22
+ $ticgit = TicGit.open('.')
23
+
24
+ get('/_stylesheet.css') { Sass::Engine.new(File.read(__FILE__).gsub(/.*__END__/m, '')).render }
25
+
26
+ # ticket list view
27
+ get '/' do
28
+ @tickets = $ticgit.ticket_list(:order => 'date.desc')
29
+ haml(list('all'))
30
+ end
31
+
32
+ get '/fs/:state' do
33
+ @tickets = $ticgit.ticket_list(:state => params[:state], :order => 'date.desc')
34
+ haml(list(params[:state]))
35
+ end
36
+
37
+ get '/tag/:tag' do
38
+ @tickets = $ticgit.ticket_list(:tag => params[:tag], :order => 'date.desc')
39
+ haml(list(params[:tag]))
40
+ end
41
+
42
+ get '/sv/:saved_view' do
43
+ @tickets = $ticgit.ticket_list(:saved => params[:saved_view])
44
+ haml(list(params[:saved_view]))
45
+ end
46
+
47
+ # ticket single view
48
+ get '/ticket/:ticket' do
49
+ @ticket = $ticgit.ticket_show(params[:ticket])
50
+ haml(show)
51
+ end
52
+
53
+
54
+ # add ticket
55
+ get '/t/new' do
56
+ haml(new_ticket)
57
+ end
58
+
59
+ # add ticket finalize
60
+ post '/t/new' do
61
+ title = params[:title].to_s.strip
62
+ if title.size > 1
63
+ tags = params[:tags].split(',').map { |t| t.strip } rescue nil
64
+ t = $ticgit.ticket_new(title, {:comment => params[:comment].strip, :tags => tags})
65
+ redirect '/ticket/' + t.ticket_id.to_s
66
+ else
67
+ redirect '/t/new'
68
+ end
69
+ end
70
+
71
+
72
+ # add comment
73
+ post '/a/add_comment/:ticket' do
74
+ t = $ticgit.ticket_comment(params[:comment], params[:ticket])
75
+ redirect '/ticket/' + params[:ticket]
76
+ end
77
+
78
+ # add tag
79
+ post '/a/add_tags/:ticket' do
80
+ t = $ticgit.ticket_tag(params[:tags], params[:ticket])
81
+ redirect '/ticket/' + params[:ticket]
82
+ end
83
+
84
+ # change ticket state
85
+ get '/a/change_state/:ticket/:state' do
86
+ $ticgit.ticket_change(params[:state], params[:ticket])
87
+ redirect '/ticket/' + params[:ticket]
88
+ end
89
+
90
+
91
+ def layout(title, content)
92
+ @saved = $ticgit.config['list_options'].keys rescue []
93
+ %Q(
94
+ %html
95
+ %head
96
+ %title #{title}
97
+ %link{:rel => 'stylesheet', :href => '/_stylesheet.css', :type => 'text/css', :media => 'screen'}
98
+ %meta{'http-equiv' => 'Content-Type', :content => 'text/html; charset=utf-8'}
99
+
100
+ %body
101
+ #navigation
102
+ %a{:href => '/'} All
103
+ %a{:href => '/fs/open'} Open
104
+ %a{:href => '/fs/resolved'} Resolved
105
+ %a{:href => '/fs/hold'} Hold
106
+ %a{:href => '/fs/invalid'} Invalid
107
+ - if !@saved.empty?
108
+ | Saved:
109
+ - @saved.each do |s|
110
+ %a{:href => "/sv/\#{s}"}= s
111
+ #action
112
+ %a{:href => '/t/new'} New Ticket
113
+
114
+ #{content}
115
+ )
116
+ end
117
+
118
+ def new_ticket
119
+ layout('New Ticket', %q{
120
+ %h1 Create a New Ticket
121
+ %form{:action => '/t/new', :method => 'POST'}
122
+ %table
123
+ %tr
124
+ %th Title
125
+ %td
126
+ %input{:type => 'text', :name => 'title', :size => 30}
127
+ %tr
128
+ %th Tags
129
+ %td
130
+ %input{:name => 'tags', :size => 30}
131
+ %small (comma delimited)
132
+ %tr
133
+ %th Comment
134
+ %td
135
+ %textarea{:name => 'comment', :rows => 15, :cols => 30}
136
+ %tr
137
+ %td
138
+ %td
139
+ %input{:type => 'submit', :value => 'Create Ticket'}
140
+ })
141
+ end
142
+
143
+ def list(title = 'all')
144
+ @title = title
145
+ layout(title + ' tickets', %q{
146
+ %h1= "#{@title} tickets"
147
+ - if @tickets.empty?
148
+ %p No tickets found.
149
+ - else
150
+ %table.long
151
+ - c = 'even'
152
+ - @tickets.each do |t|
153
+ %tr{:class => (c == 'even' ? c = 'odd' : c = 'even') }
154
+ %td
155
+ %a{:href => "/ticket/#{t.ticket_id}" }
156
+ %code= t.ticket_id[0,6]
157
+ %td= t.title
158
+ %td{:class => t.state}= t.state
159
+ %td= t.opened.strftime("%m/%d")
160
+ %td= t.assigned_name
161
+ %td
162
+ - t.tags.each do |tag|
163
+ %a{:href => "/tag/#{tag}"}= tag
164
+ })
165
+ end
166
+
167
+ def show
168
+ layout('ticket', %q{
169
+ %center
170
+ %h1= @ticket.title
171
+
172
+ %form{:action => "/a/add_tags/#{@ticket.ticket_id}", :method => 'POST'}
173
+ %table
174
+ %tr
175
+ %th TicId
176
+ %td
177
+ %code= @ticket.ticket_id
178
+ %tr
179
+ %th Assigned
180
+ %td= @ticket.assigned
181
+ %tr
182
+ %th Opened
183
+ %td= @ticket.opened
184
+ %tr
185
+ %th State
186
+ %td{:class => @ticket.state}
187
+ %table{:width => '300'}
188
+ %tr
189
+ %td{:width=>'90%'}= @ticket.state
190
+ - $ticgit.tic_states.select { |s| s != @ticket.state}.each do |st|
191
+ %td{:class => st}
192
+ %a{:href => "/a/change_state/#{@ticket.ticket_id}/#{st}"}= st[0,2]
193
+ %tr
194
+ %th Tags
195
+ %td
196
+ - @ticket.tags.each do |t|
197
+ %a{:href => "/tag/#{t}"}= t
198
+ %div.addtag
199
+ %input{:name => 'tags'}
200
+ %input{:type => 'submit', :value => 'add tag'}
201
+
202
+ %h3 Comments
203
+ %form{:action => "/a/add_comment/#{@ticket.ticket_id}", :method => 'POST'}
204
+ %div
205
+ %textarea{:name => 'comment', :cols => 50}
206
+ %br
207
+ %input{:type => 'submit', :value => 'add comment'}
208
+
209
+ %div.comments
210
+ - @ticket.comments.reverse.each do |t|
211
+ %div.comment
212
+ %span.head
213
+ Added
214
+ = t.added.strftime("%m/%d %H:%M")
215
+ by
216
+ = t.user
217
+ %div.comment-text
218
+ = t.comment
219
+ %br
220
+ })
221
+ end
222
+
223
+ __END__
224
+ body
225
+ :font
226
+ family: Verdana, Arial, "Bitstream Vera Sans", Helvetica, sans-serif
227
+ color: black
228
+ line-height: 160%
229
+ background-color: white
230
+ margin: 2em
231
+
232
+ #navigation
233
+ a
234
+ background-color: #e0e0e0
235
+ color: black
236
+ text-decoration: none
237
+ padding: 2px
238
+ padding: 5px
239
+ border-bottom: 1px black solid
240
+
241
+ #action
242
+ text-align: right
243
+
244
+ .addtag
245
+ padding: 5px 0
246
+
247
+ h1
248
+ display: block
249
+ padding-bottom: 5px
250
+
251
+ a
252
+ color: black
253
+ a.exists
254
+ font-weight: bold
255
+ a.unknown
256
+ font-style: italic
257
+
258
+ .comments
259
+ margin: 10px 20px
260
+ .comment
261
+ .head
262
+ background: #eee
263
+ padding: 4px
264
+ .comment-text
265
+ padding: 10px
266
+ color: #333
267
+
268
+ table.long
269
+ width: 100%
270
+
271
+ table
272
+ tr.even
273
+ td
274
+ background: #eee
275
+ tr.odd
276
+ td
277
+ background: #fff
278
+
279
+ table
280
+ tr
281
+ th
282
+ text-align: left
283
+ padding: 3px
284
+ vertical-align: top
285
+ td.open
286
+ background: #ada
287
+ td.resolved
288
+ background: #abd
289
+ td.hold
290
+ background: #dda
291
+ td.invalid
292
+ background: #aaa
293
+
294
+ .submit
295
+ font-size: large
296
+ font-weight: bold
297
+
298
+ .page_title
299
+ font-size: xx-large
300
+
301
+ .edit_link
302
+ color: black
303
+ font-size: 14px
304
+ font-weight: bold
305
+ background-color: #e0e0e0
306
+ font-variant: small-caps
307
+ text-decoration: none
308
+
@@ -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,301 @@
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
+ case field
113
+ when 'assigned'
114
+ ts = ts.sort { |a, b| a.assigned <=> b.assigned }
115
+ when 'state'
116
+ ts = ts.sort { |a, b| a.state <=> b.state }
117
+ when 'date'
118
+ ts = ts.sort { |a, b| a.opened <=> b.opened }
119
+ end
120
+ ts = ts.reverse if type == 'desc'
121
+ else
122
+ # default list
123
+ ts = ts.sort { |a, b| a.opened <=> b.opened }
124
+ end
125
+
126
+ if options.size == 0
127
+ # default list
128
+ options[:state] = 'open'
129
+ end
130
+
131
+ # :tag, :state, :assigned
132
+ if t = options[:tag]
133
+ ts = ts.select { |tag| tag.tags.include?(t) }
134
+ end
135
+ if s = options[:state]
136
+ ts = ts.select { |tag| tag.state =~ /#{s}/ }
137
+ end
138
+ if a = options[:assigned]
139
+ ts = ts.select { |tag| tag.assigned =~ /#{a}/ }
140
+ end
141
+
142
+ if save = options[:save]
143
+ options.delete(:save)
144
+ @config['list_options'][save] = options
145
+ end
146
+
147
+ @last_tickets = ts.map { |t| t.ticket_name }
148
+ # :save
149
+
150
+ save_state
151
+ ts
152
+ end
153
+
154
+ # returns single Ticket
155
+ def ticket_show(ticket_id = nil)
156
+ # ticket_id can be index of last_tickets, partial sha or nil => last ticket
157
+ if t = ticket_revparse(ticket_id)
158
+ return TicGit::Ticket.open(self, t, @tickets[t])
159
+ end
160
+ end
161
+
162
+ # returns recent ticgit activity
163
+ # uses the git logs for this
164
+ def ticket_recent(ticket_id = nil)
165
+ if ticket_id
166
+ t = ticket_revparse(ticket_id)
167
+ return git.log.object('ticgit').path(t)
168
+ else
169
+ return git.log.object('ticgit')
170
+ end
171
+ end
172
+
173
+ def ticket_revparse(ticket_id)
174
+ if ticket_id
175
+ if /^[0-9]*$/ =~ ticket_id
176
+ if t = @last_tickets[ticket_id.to_i - 1]
177
+ return t
178
+ end
179
+ else
180
+ # partial or full sha
181
+ if ch = @tickets.select { |name, t| t['files'].assoc('TICKET_ID')[1] =~ /^#{ticket_id}/ }
182
+ return ch.first[0]
183
+ end
184
+ end
185
+ elsif(@current_ticket)
186
+ return @current_ticket
187
+ end
188
+ end
189
+
190
+ def ticket_tag(tag, ticket_id = nil, options = {})
191
+ if t = ticket_revparse(ticket_id)
192
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
193
+ if options[:remove]
194
+ ticket.remove_tag(tag)
195
+ else
196
+ ticket.add_tag(tag)
197
+ end
198
+ reset_ticgit
199
+ end
200
+ end
201
+
202
+ def ticket_change(new_state, ticket_id = nil)
203
+ if t = ticket_revparse(ticket_id)
204
+ if tic_states.include?(new_state)
205
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
206
+ ticket.change_state(new_state)
207
+ reset_ticgit
208
+ end
209
+ end
210
+ end
211
+
212
+ def ticket_assign(new_assigned = nil, ticket_id = nil)
213
+ if t = ticket_revparse(ticket_id)
214
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
215
+ ticket.change_assigned(new_assigned)
216
+ reset_ticgit
217
+ end
218
+ end
219
+
220
+ def ticket_checkout(ticket_id)
221
+ if t = ticket_revparse(ticket_id)
222
+ ticket = TicGit::Ticket.open(self, t, @tickets[t])
223
+ @current_ticket = ticket.ticket_name
224
+ save_state
225
+ end
226
+ end
227
+
228
+ def comment_add(ticket_id, comment, options = {})
229
+ end
230
+
231
+ def comment_list(ticket_id)
232
+ end
233
+
234
+ def tic_states
235
+ ['open', 'resolved', 'invalid', 'hold']
236
+ end
237
+
238
+ def load_tickets
239
+ @tickets = {}
240
+
241
+ bs = git.lib.branches_all.map { |b| b[0] }
242
+ init_ticgit_branch(bs.include?('ticgit')) if !(bs.include?('ticgit') && File.directory?(@tic_working))
243
+
244
+ tree = git.lib.full_tree('ticgit')
245
+ tree.each do |t|
246
+ data, file = t.split("\t")
247
+ mode, type, sha = data.split(" ")
248
+ tic = file.split('/')
249
+ if tic.size == 2 # directory depth
250
+ ticket, info = tic
251
+ @tickets[ticket] ||= { 'files' => [] }
252
+ @tickets[ticket]['files'] << [info, sha]
253
+ end
254
+ end
255
+ end
256
+
257
+ def init_ticgit_branch(ticgit_branch = false)
258
+ @logger.info 'creating ticgit repo branch'
259
+
260
+ in_branch(ticgit_branch) do
261
+ new_file('.hold', 'hold')
262
+ if !ticgit_branch
263
+ git.add
264
+ git.commit('creating the ticgit branch')
265
+ end
266
+ end
267
+ end
268
+
269
+ # temporarlily switches to ticgit branch for tic work
270
+ def in_branch(branch_exists = true)
271
+ needs_checkout = false
272
+ if !File.directory?(@tic_working)
273
+ FileUtils.mkdir_p(@tic_working)
274
+ needs_checkout = true
275
+ end
276
+ if !File.exists?('.hold')
277
+ needs_checkout = true
278
+ end
279
+
280
+ old_current = git.lib.branch_current
281
+ begin
282
+ git.lib.change_head_branch('ticgit')
283
+ git.with_index(@tic_index) do
284
+ git.with_working(@tic_working) do |wd|
285
+ git.lib.checkout('ticgit') if needs_checkout && branch_exists
286
+ yield wd
287
+ end
288
+ end
289
+ ensure
290
+ git.lib.change_head_branch(old_current)
291
+ end
292
+ end
293
+
294
+ def new_file(name, contents)
295
+ File.open(name, 'w') do |f|
296
+ f.puts contents
297
+ end
298
+ end
299
+
300
+ end
301
+ end
@@ -0,0 +1,396 @@
1
+ require 'ticgit'
2
+ require 'optparse'
3
+
4
+ # used Cap as a model for this - thanks Jamis
5
+
6
+ module TicGit
7
+ class CLI
8
+ # The array of (unparsed) command-line options
9
+ attr_reader :action, :options, :args, :tic
10
+
11
+ def self.execute
12
+ parse(ARGV).execute!
13
+ end
14
+
15
+ def self.parse(args)
16
+ cli = new(args)
17
+ cli.parse_options!
18
+ cli
19
+ end
20
+
21
+ def initialize(args)
22
+ @args = args.dup
23
+ @tic = TicGit.open('.', :keep_state => true)
24
+ $stdout.sync = true # so that Net::SSH prompts show up
25
+ rescue NoRepoFound
26
+ puts "No repo found"
27
+ exit
28
+ end
29
+
30
+ def execute!
31
+ case action
32
+ when 'list':
33
+ handle_ticket_list
34
+ when 'state'
35
+ handle_ticket_state
36
+ when 'assign'
37
+ handle_ticket_assign
38
+ when 'show'
39
+ handle_ticket_show
40
+ when 'new'
41
+ handle_ticket_new
42
+ when 'checkout', 'co'
43
+ handle_ticket_checkout
44
+ when 'comment'
45
+ handle_ticket_comment
46
+ when 'tag'
47
+ handle_ticket_tag
48
+ when 'recent'
49
+ handle_ticket_recent
50
+ when 'milestone'
51
+ handle_ticket_milestone
52
+ else
53
+ puts 'not a command'
54
+ end
55
+ end
56
+
57
+ # tic milestone
58
+ # tic milestone migration1 (list tickets)
59
+ # tic milestone -n migration1 3/4/08 (new milestone)
60
+ # tic milestone -a {1} (add ticket to milestone)
61
+ # tic milestone -d migration1 (delete)
62
+ def parse_ticket_milestone
63
+ @options = {}
64
+ OptionParser.new do |opts|
65
+ opts.banner = "Usage: ti milestone [milestone_name] [options] [date]"
66
+ opts.on("-n MILESTONE", "--new MILESTONE", "Add a new milestone to this project") do |v|
67
+ @options[:new] = v
68
+ end
69
+ opts.on("-a TICKET", "--new TICKET", "Add a ticket to this milestone") do |v|
70
+ @options[:add] = v
71
+ end
72
+ opts.on("-d MILESTONE", "--delete MILESTONE", "Remove a milestone") do |v|
73
+ @options[:remove] = v
74
+ end
75
+ end.parse!
76
+ end
77
+
78
+ def handle_ticket_recent
79
+ tic.ticket_recent(ARGV[1]).each do |commit|
80
+ puts commit.sha[0, 7] + " " + commit.date.strftime("%m/%d %H:%M") + "\t" + commit.message
81
+ end
82
+ end
83
+
84
+ def parse_ticket_tag
85
+ @options = {}
86
+ OptionParser.new do |opts|
87
+ opts.banner = "Usage: ti tag [tic_id] [options] [tag_name] "
88
+ opts.on("-d", "Remove this tag from the ticket") do |v|
89
+ @options[:remove] = v
90
+ end
91
+ end.parse!
92
+ end
93
+
94
+ def handle_ticket_tag
95
+ parse_ticket_tag
96
+
97
+ if options[:remove]
98
+ puts 'remove'
99
+ end
100
+
101
+ tid = nil
102
+ if ARGV.size > 2
103
+ tid = ARGV[1].chomp
104
+ tic.ticket_tag(ARGV[2].chomp, tid, options)
105
+ elsif ARGV.size > 1
106
+ tic.ticket_tag(ARGV[1], nil, options)
107
+ else
108
+ puts 'You need to at least specify one tag to add'
109
+ end
110
+ end
111
+
112
+ def parse_ticket_comment
113
+ @options = {}
114
+ OptionParser.new do |opts|
115
+ opts.banner = "Usage: ti comment [tic_id] [options]"
116
+ opts.on("-m MESSAGE", "--message MESSAGE", "Message you would like to add as a comment") do |v|
117
+ @options[:message] = v
118
+ end
119
+ opts.on("-f FILE", "--file FILE", "A file that contains the comment you would like to add") do |v|
120
+ raise ArgumentError, "Only 1 of -f/--file and -m/--message can be specified" if @options[:message]
121
+ raise ArgumentError, "File #{v} doesn't exist" unless File.file?(v)
122
+ raise ArgumentError, "File #{v} must be <= 2048 bytes" unless File.size(v) <= 2048
123
+ @options[:file] = v
124
+ end
125
+ end.parse!
126
+ end
127
+
128
+ def handle_ticket_comment
129
+ parse_ticket_comment
130
+
131
+ tid = nil
132
+ tid = ARGV[1].chomp if ARGV[1]
133
+
134
+ if(m = options[:message])
135
+ tic.ticket_comment(m, tid)
136
+ elsif(f = options[:file])
137
+ tic.ticket_comment(File.read(options[:file]), tid)
138
+ else
139
+ if message = get_editor_message
140
+ tic.ticket_comment(message.join(''), tid)
141
+ end
142
+ end
143
+ end
144
+
145
+
146
+ def handle_ticket_checkout
147
+ tid = ARGV[1].chomp
148
+ tic.ticket_checkout(tid)
149
+ end
150
+
151
+ def handle_ticket_state
152
+ if ARGV.size > 2
153
+ tid = ARGV[1].chomp
154
+ new_state = ARGV[2].chomp
155
+ if valid_state(new_state)
156
+ tic.ticket_change(new_state, tid)
157
+ else
158
+ puts 'Invalid State - please choose from : ' + tic.tic_states.join(", ")
159
+ end
160
+ elsif ARGV.size > 1
161
+ # new state
162
+ new_state = ARGV[1].chomp
163
+ if valid_state(new_state)
164
+ tic.ticket_change(new_state)
165
+ else
166
+ puts 'Invalid State - please choose from : ' + tic.tic_states.join(", ")
167
+ end
168
+ else
169
+ puts 'You need to at least specify a new state for the current ticket'
170
+ end
171
+ end
172
+
173
+ def valid_state(state)
174
+ tic.tic_states.include?(state)
175
+ end
176
+
177
+ def parse_ticket_assign
178
+ @options = {}
179
+ OptionParser.new do |opts|
180
+ opts.banner = "Usage: ti assign [options] [ticket_id]"
181
+ opts.on("-u USER", "--user USER", "Assign the ticket to this user") do |v|
182
+ @options[:user] = v
183
+ end
184
+ opts.on("-c TICKET", "--checkout TICKET", "Checkout this ticket") do |v|
185
+ @options[:checkout] = v
186
+ end
187
+ end.parse!
188
+ end
189
+
190
+ # Assigns a ticket to someone
191
+ #
192
+ # Usage:
193
+ # ti assign (assign checked out ticket to current user)
194
+ # ti assign {1} (assign ticket to current user)
195
+ # ti assign -c {1} (assign ticket to current user and checkout the ticket)
196
+ # ti assign -u {name} (assign ticket to specified user)
197
+ def handle_ticket_assign
198
+ parse_ticket_assign
199
+
200
+ tic.ticket_checkout(options[:checkout]) if options[:checkout]
201
+
202
+ tic_id = ARGV.size > 1 ? ARGV[1].chomp : nil
203
+ tic.ticket_assign(options[:user], tic_id)
204
+ end
205
+
206
+ ## LIST TICKETS ##
207
+ def parse_ticket_list
208
+ @options = {}
209
+ OptionParser.new do |opts|
210
+ opts.banner = "Usage: ti list [options]"
211
+ opts.on("-o ORDER", "--order ORDER", "Field to order by - one of : assigned,state,date") do |v|
212
+ @options[:order] = v
213
+ end
214
+ opts.on("-t TAG", "--tag TAG", "List only tickets with specific tag") do |v|
215
+ @options[:tag] = v
216
+ end
217
+ opts.on("-s STATE", "--state STATE", "List only tickets in a specific state") do |v|
218
+ @options[:state] = v
219
+ end
220
+ opts.on("-a ASSIGNED", "--assigned ASSIGNED", "List only tickets assigned to someone") do |v|
221
+ @options[:assigned] = v
222
+ end
223
+ opts.on("-S SAVENAME", "--saveas SAVENAME", "Save this list as a saved name") do |v|
224
+ @options[:save] = v
225
+ end
226
+ opts.on("-l", "--list", "Show the saved queries") do |v|
227
+ @options[:list] = true
228
+ end
229
+ end.parse!
230
+ end
231
+
232
+ def handle_ticket_list
233
+ parse_ticket_list
234
+
235
+ options[:saved] = ARGV[1] if ARGV[1]
236
+
237
+ if tickets = tic.ticket_list(options)
238
+ counter = 0
239
+
240
+ puts
241
+ puts [' ', just('#', 4, 'r'),
242
+ just('TicId', 6),
243
+ just('Title', 25),
244
+ just('State', 5),
245
+ just('Date', 5),
246
+ just('Assgn', 8),
247
+ just('Tags', 20) ].join(" ")
248
+
249
+ a = []
250
+ 80.times { a << '-'}
251
+ puts a.join('')
252
+
253
+ tickets.each do |t|
254
+ counter += 1
255
+ tic.current_ticket == t.ticket_name ? add = '*' : add = ' '
256
+ puts [add, just(counter, 4, 'r'),
257
+ t.ticket_id[0,6],
258
+ just(t.title, 25),
259
+ just(t.state, 5),
260
+ t.opened.strftime("%m/%d"),
261
+ just(t.assigned_name, 8),
262
+ just(t.tags.join(','), 20) ].join(" ")
263
+ end
264
+ puts
265
+ end
266
+
267
+ end
268
+
269
+ ## SHOW TICKETS ##
270
+
271
+ def handle_ticket_show
272
+ if t = @tic.ticket_show(ARGV[1])
273
+ ticket_show(t)
274
+ end
275
+ end
276
+
277
+ def ticket_show(t)
278
+ days_ago = ((Time.now - t.opened) / (60 * 60 * 24)).round.to_s
279
+ puts
280
+ puts just('Title', 10) + ': ' + t.title
281
+ puts just('TicId', 10) + ': ' + t.ticket_id
282
+ puts
283
+ puts just('Assigned', 10) + ': ' + t.assigned.to_s
284
+ puts just('Opened', 10) + ': ' + t.opened.to_s + ' (' + days_ago + ' days)'
285
+ puts just('State', 10) + ': ' + t.state.upcase
286
+ if !t.tags.empty?
287
+ puts just('Tags', 10) + ': ' + t.tags.join(', ')
288
+ end
289
+ puts
290
+ if !t.comments.empty?
291
+ puts 'Comments (' + t.comments.size.to_s + '):'
292
+ t.comments.reverse.each do |c|
293
+ puts ' * Added ' + c.added.strftime("%m/%d %H:%M") + ' by ' + c.user
294
+
295
+ wrapped = c.comment.split("\n").collect do |line|
296
+ line.length > 80 ? line.gsub(/(.{1,80})(\s+|$)/, "\\1\n").strip : line
297
+ end * "\n"
298
+
299
+ wrapped = wrapped.split("\n").map { |line| "\t" + line }
300
+ if wrapped.size > 6
301
+ puts wrapped[0, 6].join("\n")
302
+ puts "\t** more... **"
303
+ else
304
+ puts wrapped.join("\n")
305
+ end
306
+ puts
307
+ end
308
+ end
309
+ end
310
+
311
+ ## NEW TICKETS ##
312
+
313
+ def parse_ticket_new
314
+ @options = {}
315
+ OptionParser.new do |opts|
316
+ opts.banner = "Usage: ti new [options]"
317
+ opts.on("-t TITLE", "--title TITLE", "Title to use for the name of the new ticket") do |v|
318
+ @options[:title] = v
319
+ end
320
+ end.parse!
321
+ end
322
+
323
+ def handle_ticket_new
324
+ parse_ticket_new
325
+ if(t = options[:title])
326
+ ticket_show(@tic.ticket_new(t, options))
327
+ else
328
+ # interactive
329
+ message_file = Tempfile.new('ticgit_message').path
330
+ File.open(message_file, 'w') do |f|
331
+ f.puts "\n# ---"
332
+ f.puts "tags:"
333
+ f.puts "# first line will be the title of the tic, the rest will be the first comment"
334
+ f.puts "# if you would like to add initial tags, put them on the 'tags:' line, comma delim"
335
+ end
336
+ if message = get_editor_message(message_file)
337
+ title = message.shift
338
+ if title && title.chomp.length > 0
339
+ title = title.chomp
340
+ if message.last[0, 5] == 'tags:'
341
+ tags = message.pop
342
+ tags = tags.gsub('tags:', '')
343
+ tags = tags.split(',').map { |t| t.strip }
344
+ end
345
+ if message.size > 0
346
+ comment = message.join("")
347
+ end
348
+ ticket_show(@tic.ticket_new(title, :comment => comment, :tags => tags))
349
+ else
350
+ puts "You need to at least enter a title"
351
+ end
352
+ else
353
+ puts "It seems you wrote nothing"
354
+ end
355
+ end
356
+ end
357
+
358
+ def get_editor_message(message_file = nil)
359
+ message_file = Tempfile.new('ticgit_message').path if !message_file
360
+
361
+ editor = ENV["EDITOR"] || 'vim'
362
+ system("#{editor} #{message_file}");
363
+ message = File.readlines(message_file)
364
+ message = message.select { |line| line[0, 1] != '#' } # removing comments
365
+ if message.empty?
366
+ return false
367
+ else
368
+ return message
369
+ end
370
+ end
371
+
372
+ def parse_options! #:nodoc:
373
+ if args.empty?
374
+ warn "Please specify at least one action to execute."
375
+ puts " list state show new checkout comment tag assign "
376
+ exit
377
+ end
378
+
379
+ @action = args.first
380
+ end
381
+
382
+
383
+ def just(value, size, side = 'l')
384
+ value = value.to_s
385
+ if value.size > size
386
+ value = value[0, size]
387
+ end
388
+ if side == 'r'
389
+ return value.rjust(size)
390
+ else
391
+ return value.ljust(size)
392
+ end
393
+ end
394
+
395
+ end
396
+ 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,214 @@
1
+ module TicGit
2
+ class Ticket
3
+
4
+ attr_reader :base, :opts
5
+ attr_accessor :ticket_id, :ticket_name
6
+ attr_accessor :title, :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
+ @state = 'open' # by default
17
+ @comments = []
18
+ @tags = []
19
+ @attachments = []
20
+ end
21
+
22
+ def self.create(base, title, options = {})
23
+ t = Ticket.new(base, options)
24
+ t.title = title
25
+ t.ticket_name = self.create_ticket_name(title)
26
+ t.save_new
27
+ t
28
+ end
29
+
30
+ def self.open(base, ticket_name, ticket_hash, options = {})
31
+ tid = nil
32
+
33
+ t = Ticket.new(base, options)
34
+ t.ticket_name = ticket_name
35
+
36
+ title, date = self.parse_ticket_name(ticket_name)
37
+
38
+ t.title = title
39
+ t.opened = date
40
+
41
+ ticket_hash['files'].each do |fname, value|
42
+ if fname == 'TICKET_ID'
43
+ tid = value
44
+ else
45
+ # matching
46
+ data = fname.split('_')
47
+ if data[0] == 'ASSIGNED'
48
+ t.assigned = data[1]
49
+ end
50
+ if data[0] == 'COMMENT'
51
+ t.comments << TicGit::Comment.new(base, fname, value)
52
+ end
53
+ if data[0] == 'TAG'
54
+ t.tags << data[1]
55
+ end
56
+ if data[0] == 'STATE'
57
+ t.state = data[1]
58
+ end
59
+ end
60
+ end
61
+
62
+ t.ticket_id = tid
63
+ t
64
+ end
65
+
66
+
67
+ def self.parse_ticket_name(name)
68
+ epoch, title, rand = name.split('_')
69
+ title = title.gsub('-', ' ')
70
+ return [title, Time.at(epoch.to_i)]
71
+ end
72
+
73
+ # write this ticket to the git database
74
+ def save_new
75
+ base.in_branch do |wd|
76
+ base.logger.info "saving #{ticket_name}"
77
+
78
+ Dir.mkdir(ticket_name)
79
+ Dir.chdir(ticket_name) do
80
+ base.new_file('TICKET_ID', ticket_name)
81
+ base.new_file('ASSIGNED_' + email, email)
82
+ base.new_file('STATE_' + state, state)
83
+
84
+ # add initial comment
85
+ #COMMENT_080315060503045__schacon_at_gmail
86
+ base.new_file(comment_name(email), opts[:comment]) if opts[:comment]
87
+
88
+ # add initial tags
89
+ if opts[:tags] && opts[:tags].size > 0
90
+ opts[:tags] = opts[:tags].map { |t| t.strip }.compact
91
+ opts[:tags].each do |tag|
92
+ if tag.size > 0
93
+ tag_filename = 'TAG_' + Ticket.clean_string(tag)
94
+ if !File.exists?(tag_filename)
95
+ base.new_file(tag_filename, tag_filename)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+
102
+ base.git.add
103
+ base.git.commit("added ticket #{ticket_name}")
104
+ end
105
+ # ticket_id
106
+ end
107
+
108
+ def self.clean_string(string)
109
+ string.downcase.gsub(/[^a-z0-9]+/i, '-')
110
+ end
111
+
112
+ def add_comment(comment)
113
+ return false if !comment
114
+ base.in_branch do |wd|
115
+ Dir.chdir(ticket_name) do
116
+ base.new_file(comment_name(email), comment)
117
+ end
118
+ base.git.add
119
+ base.git.commit("added comment to ticket #{ticket_name}")
120
+ end
121
+ end
122
+
123
+ def change_state(new_state)
124
+ return false if !new_state
125
+ return false if new_state == state
126
+
127
+ base.in_branch do |wd|
128
+ Dir.chdir(ticket_name) do
129
+ base.new_file('STATE_' + new_state, new_state)
130
+ end
131
+ base.git.remove(File.join(ticket_name,'STATE_' + state))
132
+ base.git.add
133
+ base.git.commit("added state (#{new_state}) to ticket #{ticket_name}")
134
+ end
135
+ end
136
+
137
+ def change_assigned(new_assigned)
138
+ new_assigned ||= email
139
+ return false if new_assigned == assigned
140
+
141
+ base.in_branch do |wd|
142
+ Dir.chdir(ticket_name) do
143
+ base.new_file('ASSIGNED_' + new_assigned, new_assigned)
144
+ end
145
+ base.git.remove(File.join(ticket_name,'ASSIGNED_' + assigned))
146
+ base.git.add
147
+ base.git.commit("assigned #{new_assigned} to ticket #{ticket_name}")
148
+ end
149
+ end
150
+
151
+ def add_tag(tag)
152
+ return false if !tag
153
+ added = false
154
+ tags = tag.split(',').map { |t| t.strip }
155
+ base.in_branch do |wd|
156
+ Dir.chdir(ticket_name) do
157
+ tags.each do |add_tag|
158
+ if add_tag.size > 0
159
+ tag_filename = 'TAG_' + Ticket.clean_string(add_tag)
160
+ if !File.exists?(tag_filename)
161
+ base.new_file(tag_filename, tag_filename)
162
+ added = true
163
+ end
164
+ end
165
+ end
166
+ end
167
+ if added
168
+ base.git.add
169
+ base.git.commit("added tags (#{tag}) to ticket #{ticket_name}")
170
+ end
171
+ end
172
+ end
173
+
174
+ def remove_tag(tag)
175
+ return false if !tag
176
+ removed = false
177
+ tags = tag.split(',').map { |t| t.strip }
178
+ base.in_branch do |wd|
179
+ tags.each do |add_tag|
180
+ tag_filename = File.join(ticket_name, 'TAG_' + Ticket.clean_string(add_tag))
181
+ if File.exists?(tag_filename)
182
+ base.git.remove(tag_filename)
183
+ removed = true
184
+ end
185
+ end
186
+ if removed
187
+ base.git.commit("removed tags (#{tag}) from ticket #{ticket_name}")
188
+ end
189
+ end
190
+ end
191
+
192
+ def path
193
+ File.join(state, ticket_name)
194
+ end
195
+
196
+ def comment_name(email)
197
+ 'COMMENT_' + Time.now.to_i.to_s + '_' + email
198
+ end
199
+
200
+ def email
201
+ opts[:user_email] || 'anon'
202
+ end
203
+
204
+ def assigned_name
205
+ assigned.split('@').first rescue ''
206
+ end
207
+
208
+ def self.create_ticket_name(title)
209
+ [Time.now.to_i.to_s, Ticket.clean_string(title), rand(999).to_i.to_s].join('_')
210
+ end
211
+
212
+
213
+ end
214
+ end
metadata ADDED
@@ -0,0 +1,63 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ticgit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.6
5
+ platform: ruby
6
+ authors:
7
+ - Scott Chacon
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2008-05-10 00:00:00 -07:00
13
+ default_executable: ti
14
+ dependencies: []
15
+
16
+ description:
17
+ email: schacon@gmail.com
18
+ executables:
19
+ - ti
20
+ - ticgitweb
21
+ extensions: []
22
+
23
+ extra_rdoc_files: []
24
+
25
+ files:
26
+ - lib/ticgit/base.rb
27
+ - lib/ticgit/cli.rb
28
+ - lib/ticgit/comment.rb
29
+ - lib/ticgit/ticket.rb
30
+ - lib/ticgit.rb
31
+ - bin/ti
32
+ - bin/ticgitweb
33
+ has_rdoc: true
34
+ homepage: http://github.com/schacon/ticgit
35
+ licenses: []
36
+
37
+ post_install_message:
38
+ rdoc_options: []
39
+
40
+ require_paths:
41
+ - lib
42
+ - bin
43
+ required_ruby_version: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: "0"
48
+ version:
49
+ required_rubygems_version: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: "0"
54
+ version:
55
+ requirements: []
56
+
57
+ rubyforge_project:
58
+ rubygems_version: 1.3.5
59
+ signing_key:
60
+ specification_version: 2
61
+ summary: A distributed ticketing system for Git projects.
62
+ test_files: []
63
+