TicGit-ng 1.0.0

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/ticgitweb ADDED
@@ -0,0 +1,313 @@
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
+ # Add the library from the source tree to the front of the load path.
12
+ # This allows ticgitweb to run without first installing a ticgit gem,
13
+ # which is important when testing multiple branches of development.
14
+ $LOAD_PATH.unshift File.join(File.dirname(__FILE__), '..', 'lib')
15
+
16
+ %w(rubygems sinatra git ticgit haml sass).each do |dependency|
17
+ begin
18
+ require dependency
19
+ rescue LoadError => e
20
+ puts "You need to install #{dependency} before we can proceed"
21
+ end
22
+ end
23
+
24
+ # !! TODO : if ARGV[1] is a path to a git repo, use that
25
+ # otherwise, look in ~/.ticgit
26
+
27
+ $ticgit = TicGit.open('.')
28
+
29
+ get('/_stylesheet.css') { Sass::Engine.new(File.read(__FILE__).gsub(/.*__END__/m, '')).render }
30
+
31
+ # ticket list view
32
+ get '/' do
33
+ @tickets = $ticgit.ticket_list(:order => 'date.desc')
34
+ haml(list('all'))
35
+ end
36
+
37
+ get '/fs/:state' do
38
+ @tickets = $ticgit.ticket_list(:state => params[:state], :order => 'date.desc')
39
+ haml(list(params[:state]))
40
+ end
41
+
42
+ get '/tag/:tag' do
43
+ @tickets = $ticgit.ticket_list(:tag => params[:tag], :order => 'date.desc')
44
+ haml(list(params[:tag]))
45
+ end
46
+
47
+ get '/sv/:saved_view' do
48
+ @tickets = $ticgit.ticket_list(:saved => params[:saved_view])
49
+ haml(list(params[:saved_view]))
50
+ end
51
+
52
+ # ticket single view
53
+ get '/ticket/:ticket' do
54
+ @ticket = $ticgit.ticket_show(params[:ticket])
55
+ haml(show)
56
+ end
57
+
58
+
59
+ # add ticket
60
+ get '/t/new' do
61
+ haml(new_ticket)
62
+ end
63
+
64
+ # add ticket finalize
65
+ post '/t/new' do
66
+ title = params[:title].to_s.strip
67
+ if title.size > 1
68
+ tags = params[:tags].split(',').map { |t| t.strip } rescue nil
69
+ t = $ticgit.ticket_new(title, {:comment => params[:comment].strip, :tags => tags})
70
+ redirect '/ticket/' + t.ticket_id.to_s
71
+ else
72
+ redirect '/t/new'
73
+ end
74
+ end
75
+
76
+
77
+ # add comment
78
+ post '/a/add_comment/:ticket' do
79
+ t = $ticgit.ticket_comment(params[:comment], params[:ticket])
80
+ redirect '/ticket/' + params[:ticket]
81
+ end
82
+
83
+ # add tag
84
+ post '/a/add_tags/:ticket' do
85
+ t = $ticgit.ticket_tag(params[:tags], params[:ticket])
86
+ redirect '/ticket/' + params[:ticket]
87
+ end
88
+
89
+ # change ticket state
90
+ get '/a/change_state/:ticket/:state' do
91
+ $ticgit.ticket_change(params[:state], params[:ticket])
92
+ redirect '/ticket/' + params[:ticket]
93
+ end
94
+
95
+
96
+ def layout(title, content)
97
+ @saved = $ticgit.config['list_options'].keys rescue []
98
+ %Q(
99
+ %html
100
+ %head
101
+ %title #{title}
102
+ %link{:rel => 'stylesheet', :href => '/_stylesheet.css', :type => 'text/css', :media => 'screen'}
103
+ %meta{'http-equiv' => 'Content-Type', :content => 'text/html; charset=utf-8'}
104
+
105
+ %body
106
+ #navigation
107
+ %a{:href => '/'} All
108
+ %a{:href => '/fs/open'} Open
109
+ %a{:href => '/fs/resolved'} Resolved
110
+ %a{:href => '/fs/hold'} Hold
111
+ %a{:href => '/fs/invalid'} Invalid
112
+ - if !@saved.empty?
113
+ | Saved:
114
+ - @saved.each do |s|
115
+ %a{:href => "/sv/\#{s}"}= s
116
+ #action
117
+ %a{:href => '/t/new'} New Ticket
118
+
119
+ #{content}
120
+ )
121
+ end
122
+
123
+ def new_ticket
124
+ layout('New Ticket', %q{
125
+ %h1 Create a New Ticket
126
+ %form{:action => '/t/new', :method => 'POST'}
127
+ %table
128
+ %tr
129
+ %th Title
130
+ %td
131
+ %input{:type => 'text', :name => 'title', :size => 30}
132
+ %tr
133
+ %th Tags
134
+ %td
135
+ %input{:name => 'tags', :size => 30}
136
+ %small (comma delimited)
137
+ %tr
138
+ %th Comment
139
+ %td
140
+ %textarea{:name => 'comment', :rows => 15, :cols => 30}
141
+ %tr
142
+ %td
143
+ %td
144
+ %input{:type => 'submit', :value => 'Create Ticket'}
145
+ })
146
+ end
147
+
148
+ def list(title = 'all')
149
+ @title = title
150
+ layout(title + ' tickets', %q{
151
+ %h1= "#{@title} tickets"
152
+ - if @tickets.empty?
153
+ %p No tickets found.
154
+ - else
155
+ %table.long
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&= t.title
163
+ %td{:class => t.state}= t.state
164
+ %td= t.opened.strftime("%m/%d")
165
+ %td= t.assigned_name
166
+ %td
167
+ - t.tags.each do |tag|
168
+ %a{:href => "/tag/#{tag}"}= tag
169
+ })
170
+ end
171
+
172
+ def show
173
+ layout('ticket', %q{
174
+ %center
175
+ %h1&= @ticket.title
176
+
177
+ %form{:action => "/a/add_tags/#{@ticket.ticket_id}", :method => 'POST'}
178
+ %table
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{: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
+ end
227
+
228
+ __END__
229
+ body
230
+ :font
231
+ family: Verdana, Arial, "Bitstream Vera Sans", Helvetica, sans-serif
232
+ color: black
233
+ line-height: 160%
234
+ background-color: white
235
+ margin: 2em
236
+
237
+ #navigation
238
+ a
239
+ background-color: #e0e0e0
240
+ color: black
241
+ text-decoration: none
242
+ padding: 2px
243
+ padding: 5px
244
+ border-bottom: 1px black solid
245
+
246
+ #action
247
+ text-align: right
248
+
249
+ .addtag
250
+ padding: 5px 0
251
+
252
+ h1
253
+ display: block
254
+ padding-bottom: 5px
255
+
256
+ a
257
+ color: black
258
+ a.exists
259
+ font-weight: bold
260
+ a.unknown
261
+ font-style: italic
262
+
263
+ .comments
264
+ margin: 10px 20px
265
+ .comment
266
+ .head
267
+ background: #eee
268
+ padding: 4px
269
+ .comment-text
270
+ padding: 10px
271
+ color: #333
272
+
273
+ table.long
274
+ width: 100%
275
+
276
+ table
277
+ tr.even
278
+ td
279
+ background: #eee
280
+ tr.odd
281
+ td
282
+ background: #fff
283
+
284
+ table
285
+ tr
286
+ th
287
+ text-align: left
288
+ padding: 3px
289
+ vertical-align: top
290
+ td.open
291
+ background: #ada
292
+ td.resolved
293
+ background: #abd
294
+ td.hold
295
+ background: #dda
296
+ td.invalid
297
+ background: #aaa
298
+
299
+ .submit
300
+ font-size: large
301
+ font-weight: bold
302
+
303
+ .page_title
304
+ font-size: xx-large
305
+
306
+ .edit_link
307
+ color: black
308
+ font-size: 14px
309
+ font-weight: bold
310
+ background-color: #e0e0e0
311
+ font-variant: small-caps
312
+ text-decoration: none
313
+
@@ -0,0 +1,365 @@
1
+ module TicGitNG
2
+ class NoRepoFound < StandardError;end
3
+ class Base
4
+
5
+ attr_reader :git, :logger
6
+ attr_reader :tic_working, :tic_index
7
+ attr_reader :last_tickets, :current_ticket # saved in state
8
+ attr_reader :config
9
+ attr_reader :state, :config_file
10
+
11
+ def initialize(git_dir, opts = {})
12
+ @git = Git.open(find_repo(git_dir))
13
+ @logger = opts[:logger] || Logger.new(STDOUT)
14
+ @last_tickets = []
15
+
16
+ proj = Ticket.clean_string(@git.dir.path)
17
+
18
+ @tic_dir = opts[:tic_dir] || '~/.ticgit-ng'
19
+ @tic_working = opts[:working_directory] || File.expand_path(File.join(@tic_dir, proj, 'working'))
20
+ @tic_index = opts[:index_file] || File.expand_path(File.join(@tic_dir, proj, 'index'))
21
+
22
+ # load config file
23
+ @config_file = File.expand_path(File.join(@tic_dir, proj, 'config.yml'))
24
+ if File.exists?(config_file)
25
+ @config = YAML.load(File.read(config_file))
26
+ else
27
+ @config = {}
28
+ end
29
+
30
+ @state = File.expand_path(File.join(@tic_dir, proj, 'state'))
31
+
32
+ if File.file?(@state)
33
+ load_state
34
+ else
35
+ reset_ticgitng
36
+ end
37
+ end
38
+
39
+ def find_repo(dir)
40
+ full = File.expand_path(dir)
41
+ ENV["GIT_WORKING_DIR"] || loop do
42
+ return full if File.directory?(File.join(full, ".git"))
43
+ raise NoRepoFound if full == full=File.dirname(full)
44
+ end
45
+ end
46
+
47
+ # marshal dump the internals
48
+ # save config file
49
+ def save_state
50
+ state_list = [@last_tickets, @current_ticket]
51
+ File.open(@state, 'w+'){|io| Marshal.dump(state_list, io) }
52
+ File.open(@config_file, 'w+'){|io| io.write(config.to_yaml) }
53
+ end
54
+
55
+ # read in the internals
56
+ def load_state
57
+ state_list = File.open(@state){|io| Marshal.load(io) }
58
+ garbage_data=nil
59
+ if state_list.length == 2
60
+ @last_tickets, @current_ticket = state_list
61
+ else
62
+ #This was left behind so that people can continue load_state-ing
63
+ #without having to delete their ~/.ticgit directory when
64
+ #updating to this version (rename to ticgit-ng)
65
+ garbage_data, @last_tickets, @current_ticket = state_list
66
+ end
67
+ end
68
+
69
+ # returns new Ticket
70
+ def ticket_new(title, options = {})
71
+ t = TicGitNG::Ticket.create(self, title, options)
72
+ reset_ticgitng
73
+ TicGitNG::Ticket.open(self, t.ticket_name, tickets[t.ticket_name])
74
+ end
75
+
76
+ #This is a legacy function from back when ticgit-ng needed to have its
77
+ #cache reset in order to avoid cache corruption.
78
+ def reset_ticgitng
79
+ tickets
80
+ save_state
81
+ end
82
+
83
+ # returns new Ticket
84
+ def ticket_comment(comment, ticket_id = nil)
85
+ if t = ticket_revparse(ticket_id)
86
+ ticket = TicGitNG::Ticket.open(self, t, tickets[t])
87
+ ticket.add_comment(comment)
88
+ reset_ticgitng
89
+ end
90
+ end
91
+
92
+ # returns array of Tickets
93
+ def ticket_list(options = {})
94
+ reset_ticgitng
95
+ ts = []
96
+ @last_tickets = []
97
+ @config['list_options'] ||= {}
98
+
99
+ tickets.to_a.each do |name, t|
100
+ ts << TicGitNG::Ticket.open(self, name, t)
101
+ end
102
+
103
+ if name = options[:saved]
104
+ if c = config['list_options'][name]
105
+ options = c.merge(options)
106
+ end
107
+ end
108
+
109
+ if options[:list]
110
+ # TODO : this is a hack and i need to fix it
111
+ config['list_options'].each do |name, opts|
112
+ puts name + "\t" + opts.inspect
113
+ end
114
+ return false
115
+ end
116
+
117
+ if options.size == 0
118
+ # default list
119
+ options[:state] = 'open'
120
+ end
121
+
122
+ # :tag, :state, :assigned
123
+ if t = options[:tags]
124
+ t = {false => Set.new, true => Set.new}.merge t.classify { |x| x[0,1] != "-" }
125
+ t[false].map! { |x| x[1..-1] }
126
+ ts = ts.reject { |tic| t[true].intersection(tic.tags).empty? } unless t[true].empty?
127
+ ts = ts.select { |tic| t[false].intersection(tic.tags).empty? } unless t[false].empty?
128
+ end
129
+ if s = options[:states]
130
+ s = {false => Set.new, true => Set.new}.merge s.classify { |x| x[0,1] != "-" }
131
+ s[true].map! { |x| Regexp.new(x, Regexp::IGNORECASE) }
132
+ s[false].map! { |x| Regexp.new(x[1..-1], Regexp::IGNORECASE) }
133
+ ts = ts.select { |tic| s[true].any? { |st| tic.state =~ st } } unless s[true].empty?
134
+ ts = ts.reject { |tic| s[false].any? { |st| tic.state =~ st } } unless s[false].empty?
135
+ end
136
+ if a = options[:assigned]
137
+ ts = ts.select { |tic| tic.assigned =~ Regexp.new(a, Regexp::IGNORECASE) }
138
+ end
139
+
140
+ # SORTING
141
+ if field = options[:order]
142
+ field, type = field.split('.')
143
+
144
+ case field
145
+ when 'assigned'; ts = ts.sort_by{|a| a.assigned }
146
+ when 'state'; ts = ts.sort_by{|a| a.state }
147
+ when 'date'; ts = ts.sort_by{|a| a.opened }
148
+ when 'title'; ts = ts.sort_by{|a| a.title }
149
+ end
150
+
151
+ ts = ts.reverse if type == 'desc'
152
+ else
153
+ # default list
154
+ ts = ts.sort_by{|a| a.opened }
155
+ end
156
+
157
+ if options.size == 0
158
+ # default list
159
+ options[:state] = 'open'
160
+ end
161
+
162
+ # :tag, :state, :assigned
163
+ if t = options[:tag]
164
+ ts = ts.select { |tag| tag.tags.include?(t) }
165
+ end
166
+ if s = options[:state]
167
+ ts = ts.select { |tag| tag.state =~ /#{s}/ }
168
+ end
169
+ if a = options[:assigned]
170
+ ts = ts.select { |tag| tag.assigned =~ /#{a}/ }
171
+ end
172
+
173
+ if save = options[:save]
174
+ options.delete(:save)
175
+ @config['list_options'][save] = options
176
+ end
177
+
178
+ @last_tickets = ts.map{|t| t.ticket_name }
179
+ # :save
180
+
181
+ save_state
182
+ ts
183
+ end
184
+
185
+ # returns single Ticket
186
+ def ticket_show(ticket_id = nil)
187
+ # ticket_id can be index of last_tickets, partial sha or nil => last ticket
188
+ reset_ticgitng
189
+ if t = ticket_revparse(ticket_id)
190
+ return TicGitNG::Ticket.open(self, t, tickets[t])
191
+ end
192
+ end
193
+
194
+ # returns recent ticgit-ng activity
195
+ # uses the git logs for this
196
+ def ticket_recent(ticket_id = nil)
197
+ if ticket_id
198
+ t = ticket_revparse(ticket_id)
199
+ return git.log.object('ticgit-ng').path(t)
200
+ else
201
+ return git.log.object('ticgit-ng')
202
+ end
203
+ end
204
+
205
+ def ticket_revparse(ticket_id)
206
+ if ticket_id
207
+ ticket_id = ticket_id.strip
208
+
209
+ if /^[0-9]*$/ =~ ticket_id
210
+ if t = @last_tickets[ticket_id.to_i - 1]
211
+ return t
212
+ end
213
+ else # partial or full sha
214
+ regex = /^#{Regexp.escape(ticket_id)}/
215
+ ch = tickets.select{|name, t|
216
+ t['files'].assoc('TICKET_ID')[1] =~ regex }
217
+ ch.first[0] if ch.first
218
+ end
219
+ elsif(@current_ticket)
220
+ return @current_ticket
221
+ end
222
+ end
223
+
224
+ def ticket_tag(tag, ticket_id = nil, options = OpenStruct.new)
225
+ if t = ticket_revparse(ticket_id)
226
+ ticket = TicGitNG::Ticket.open(self, t, tickets[t])
227
+ if options.remove
228
+ ticket.remove_tag(tag)
229
+ else
230
+ ticket.add_tag(tag)
231
+ end
232
+ reset_ticgitng
233
+ end
234
+ end
235
+
236
+ def ticket_change(new_state, ticket_id = nil)
237
+ if t = ticket_revparse(ticket_id)
238
+ if tic_states.include?(new_state)
239
+ ticket = TicGitNG::Ticket.open(self, t, tickets[t])
240
+ ticket.change_state(new_state)
241
+ reset_ticgitng
242
+ end
243
+ end
244
+ end
245
+
246
+ def ticket_assign(new_assigned = nil, ticket_id = nil)
247
+ if t = ticket_revparse(ticket_id)
248
+ ticket = TicGitNG::Ticket.open(self, t, tickets[t])
249
+ ticket.change_assigned(new_assigned)
250
+ reset_ticgitng
251
+ end
252
+ end
253
+
254
+ def ticket_points(new_points = nil, ticket_id = nil)
255
+ if t = ticket_revparse(ticket_id)
256
+ ticket = TicGitNG::Ticket.open(self, t, tickets[t])
257
+ ticket.change_points(new_points)
258
+ reset_ticgitng
259
+ end
260
+ end
261
+
262
+ def ticket_checkout(ticket_id)
263
+ if t = ticket_revparse(ticket_id)
264
+ ticket = TicGitNG::Ticket.open(self, t, tickets[t])
265
+ @current_ticket = ticket.ticket_name
266
+ save_state
267
+ end
268
+ end
269
+
270
+ def comment_add(ticket_id, comment, options = {})
271
+ end
272
+
273
+ def comment_list(ticket_id)
274
+ end
275
+
276
+ def tic_states
277
+ ['open', 'resolved', 'invalid', 'hold']
278
+ end
279
+
280
+ def sync_tickets(repo='origin', push=true, verbose=true )
281
+ in_branch(false) do
282
+ repo_g=git.remote(repo)
283
+ git.pull(repo_g, repo+'/ticgit-ng')
284
+ git.push(repo_g, 'ticgit-ng:ticgit-ng') if push
285
+ puts "Tickets synchronized." if verbose
286
+ end
287
+ end
288
+
289
+ def tickets
290
+ read_tickets
291
+ end
292
+
293
+ def read_tickets
294
+ tickets = {}
295
+
296
+ bs = git.lib.branches_all.map{|b| b.first }
297
+
298
+ unless bs.include?('ticgit-ng') && File.directory?(@tic_working)
299
+ init_ticgitng_branch(bs.include?('ticgit-ng'))
300
+ end
301
+
302
+ tree = git.lib.full_tree('ticgit-ng')
303
+ tree.each do |t|
304
+ data, file = t.split("\t")
305
+ mode, type, sha = data.split(" ")
306
+ tic = file.split('/')
307
+ if tic.size == 2 # directory depth
308
+ ticket, info = tic
309
+ tickets[ticket] ||= { 'files' => [] }
310
+ tickets[ticket]['files'] << [info, sha]
311
+ end
312
+ end
313
+ tickets
314
+ end
315
+
316
+ def init_ticgitng_branch(ticgitng_branch = false)
317
+ @logger.info 'creating ticgit-ng repo branch'
318
+
319
+ in_branch(ticgitng_branch) do
320
+ #The .hold file seems to have little to no purpose aside from helping
321
+ #figure out if the branch should be checked out or not. It is created
322
+ #when the ticgit branch is created, and seems to exist for the lifetime
323
+ #of the ticgit branch. The purpose seems to be, to be able to tell if
324
+ #the ticgit branch is already checked out and not check it out again if
325
+ #it is. This might be superfluous after switching to grit.
326
+ new_file('.hold', 'hold')
327
+
328
+ unless ticgitng_branch
329
+ git.add
330
+ git.commit('creating the ticgit-ng branch')
331
+ end
332
+ end
333
+ end
334
+
335
+ # temporarlily switches to ticgit branch for tic work
336
+ def in_branch(branch_exists = true)
337
+ needs_checkout = false
338
+
339
+ unless File.directory?(@tic_working)
340
+ FileUtils.mkdir_p(@tic_working)
341
+ needs_checkout = true
342
+ end
343
+
344
+ needs_checkout = true unless File.file?('.hold')
345
+
346
+ old_current = git.lib.branch_current
347
+ begin
348
+ git.lib.change_head_branch('ticgit-ng')
349
+ git.with_index(@tic_index) do
350
+ git.with_working(@tic_working) do |wd|
351
+ git.lib.checkout('ticgit-ng') if needs_checkout && branch_exists
352
+ yield wd
353
+ end
354
+ end
355
+ ensure
356
+ git.lib.change_head_branch(old_current)
357
+ end
358
+ end
359
+
360
+ def new_file(name, contents)
361
+ File.open(name, 'w+'){|f| f.puts(contents) }
362
+ end
363
+
364
+ end
365
+ end