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