git-trac 0.0.20071102

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,303 @@
1
+ require 'optparse'
2
+ require 'ostruct'
3
+
4
+ module Git
5
+ module Trac
6
+
7
+ def self.run(argv)
8
+ Runner.new(argv)
9
+ end
10
+
11
+ class Runner
12
+
13
+ attr_reader :options
14
+
15
+ def initialize(argv)
16
+ @argv = argv.dup
17
+ run
18
+ rescue Git::Trac::Error
19
+ $stderr.puts "Error: " + $!.message
20
+ exit 1
21
+ end
22
+
23
+ def abort(message)
24
+ raise Git::Trac::Error, message
25
+ end
26
+
27
+ def general_usage
28
+ $stderr.puts <<-EOS
29
+ Usage: git-trac <command> [options] [arguments]
30
+
31
+ Available commands:
32
+ cleanup Remove old branches for a ticket
33
+ download Download all patches for a ticket to the cwd
34
+ fetch Create brances for all patches of a ticket
35
+ show Show a summary of a ticket
36
+ upload-patch Upload the current diff against trunk to a ticket
37
+ EOS
38
+ exit 1
39
+ end
40
+
41
+ def run
42
+
43
+ command = @argv.shift
44
+ repo = nil
45
+ if command == "--repository"
46
+ repo, command = @argv.shift, @argv.shift
47
+ end
48
+
49
+ if command == "help"
50
+ command = @argv.shift
51
+ @argv.unshift "--help"
52
+ end
53
+ general_usage unless command
54
+
55
+ @repository = Git::Trac::Repository.new(repo)
56
+ unless @repository.url || @argv.first == "--help"
57
+ abort "no URL. Try `git config trac.url http://trac-url`"
58
+ end
59
+
60
+ klass_name = command.capitalize.gsub(/-(.)/) { $1.upcase }
61
+ if self.class.const_defined?(klass_name)
62
+ klass = self.class.const_get(klass_name)
63
+ if klass < Base
64
+ return klass.new(@argv, @repository)
65
+ end
66
+ end
67
+
68
+ general_usage
69
+
70
+ end
71
+
72
+ class Base
73
+
74
+ attr_reader :options
75
+
76
+ def initialize(argv, repo)
77
+ @argv, @repository = argv, repo
78
+ @options = {}
79
+ @opts = OptionParser.new
80
+ @opts.banner = "Usage: git-trac #{command} #{banner_arguments}\n#{"\n" if description}#{description}"
81
+ @opts.separator("")
82
+ add_options(@opts)
83
+ begin
84
+ @opts.parse!(@argv)
85
+ rescue OptionParser::InvalidOption
86
+ abort $!.message
87
+ end
88
+ run
89
+ end
90
+
91
+ def description
92
+ end
93
+
94
+ def get_ticket_number
95
+ if @argv.first =~ /^\d+$/
96
+ Integer(@argv.shift)
97
+ elsif number = @repository.guess_current_ticket_number
98
+ number
99
+ elsif block_given?
100
+ yield
101
+ else
102
+ abort "ticket number required"
103
+ end
104
+ end
105
+
106
+ def require_ticket_number
107
+ @opts.separator("Ticket number is required unless it can be derived from the current branch.\n")
108
+ end
109
+
110
+ def abort(message)
111
+ $stderr.puts "Error: #{message}"
112
+ # $stderr.puts @opts
113
+ exit(1)
114
+ end
115
+
116
+ def add_options(opts)
117
+ end
118
+
119
+ def banner_arguments
120
+ "[options]"
121
+ end
122
+
123
+ def command
124
+ self.class.name[/[^:]*$/].gsub(/(.)([A-Z])/) { $1+"-"+$2 }.downcase
125
+ end
126
+
127
+ end
128
+
129
+ class Download < Base
130
+
131
+ def description
132
+ <<-EOF
133
+ Download all attachments that look like patches to the current working
134
+ directory.
135
+ EOF
136
+ end
137
+
138
+ def banner_arguments
139
+ "[options] [ticket]"
140
+ end
141
+
142
+ def add_options(opts)
143
+ require_ticket_number
144
+ opts.separator("Options:")
145
+ opts.on("--filter PATTERN","only download patches matching PATTERN") do |pattern|
146
+ options[:filter] = pattern
147
+ end
148
+ end
149
+
150
+ def run
151
+ number = get_ticket_number
152
+ @repository.ticket(number).attachments.each do |attach|
153
+ next if options[:filter] && attach.filename !~ /#{options[:filter]}/
154
+ File.open(attach.filename, "w") do |f|
155
+ f.puts attach.body
156
+ end
157
+ end
158
+ end
159
+
160
+ end
161
+
162
+ class Show < Base
163
+
164
+ def banner_arguments
165
+ "[ticket]"
166
+ end
167
+
168
+ def description
169
+ <<-EOF
170
+ Show a crude ticket summary.
171
+ EOF
172
+ end
173
+
174
+ def add_options(opts)
175
+ require_ticket_number
176
+ end
177
+
178
+ def run
179
+ number = get_ticket_number
180
+ csv = @repository.ticket(number).csv
181
+ csv.reject {|k,v| k == "description"}.sort.each do |(k,v)|
182
+ puts "#{k}: #{v}" if v
183
+ end
184
+ puts "description:"
185
+ puts csv["description"]
186
+ end
187
+
188
+ end
189
+
190
+ class Fetch < Base
191
+
192
+ def banner_arguments
193
+ "[ticket]"
194
+ end
195
+
196
+ def description
197
+ <<-EOF
198
+ Download all branches that look like patches. For each patch, find the
199
+ revision of trunk that most recently proceeds the the time of upload, apply
200
+ the patch to it, create a new commit, and add a remote head of the form
201
+ refs/remotes/trac/ticketnumber/file_name_ext. Patches with the same root
202
+ name (filename.diff, filename.2.diff, filename.patch, etc.) will committed in
203
+ parent child relationships based on upload time. This means filename.2.diff
204
+ might cite filename.diff as a parent. For each root name, a branch is created
205
+ pointing to the newest patch. Existing branches will not be overridden, but
206
+ there is an implied `git cleanup <patch>` that runs beforehand which could
207
+ potentially remove conflicted branches first.
208
+ EOF
209
+ end
210
+
211
+ def add_options(opts)
212
+ require_ticket_number
213
+ end
214
+
215
+ def run
216
+ number = get_ticket_number
217
+ @repository.ticket(number).fetch do |attachment, branch|
218
+ if branch
219
+ puts "#{attachment.filename}: #{branch}"
220
+ else
221
+ $stderr.puts "#{attachment.filename} FAILED"
222
+ end
223
+ end
224
+ end
225
+
226
+ end
227
+
228
+ class Cleanup < Base
229
+
230
+ def banner_arguments
231
+ "[options] [ticket] [ticket] ..."
232
+ end
233
+
234
+ def description
235
+ <<-EOF
236
+ Remove remote heads for a given ticket (e.g., trac/12345/work_patch). Also
237
+ removes branches that point to one of these heads. Branches that have been
238
+ committed to will not be removed. The default is to target tickets that have
239
+ been closed, but you can also specify ticket numbers explicitly or use --all.
240
+ EOF
241
+ end
242
+
243
+ def add_options(opts)
244
+ opts.separator("Options:")
245
+ opts.on("-a","--all", "cleanup all tickets") { options[:all] = true }
246
+ end
247
+
248
+ def run
249
+ if options[:all]
250
+ @repository.working_tickets.each do |t|
251
+ t.cleanup
252
+ end
253
+ elsif @argv.any?
254
+ begin
255
+ @argv.each do |a|
256
+ @repository.ticket(Integer(a)).cleanup
257
+ end
258
+ rescue TypeError
259
+ abort "invalid ticket number"
260
+ end
261
+ else
262
+ @repository.working_tickets.each do |t|
263
+ t.cleanup unless t.open?
264
+ end
265
+ end
266
+ end
267
+ end
268
+
269
+ class UploadPatch < Base
270
+
271
+ def banner_arguments
272
+ "[options] [ticket]"
273
+ end
274
+
275
+ def description
276
+ <<-EOF
277
+ Do a `git diff` against trunk (or another branch) and upload the result as an
278
+ attachment to a ticket. This command is experimental to take care when using
279
+ it against a production trac server.
280
+ EOF
281
+ end
282
+
283
+ def add_options(opts)
284
+ require_ticket_number
285
+ opts.on("--branch BRANCH", "git diff BRANCH (default trunk)") do |b|
286
+ options[:branch] = b
287
+ end
288
+ end
289
+
290
+ def run
291
+ number = get_ticket_number
292
+ if uri = @repository.ticket(number).upload_patch(options)
293
+ puts uri
294
+ else
295
+ exit 1
296
+ end
297
+ end
298
+ end
299
+
300
+ end
301
+
302
+ end
303
+ end
@@ -0,0 +1,183 @@
1
+ require 'hpricot'
2
+ require 'fileutils'
3
+
4
+ module Git
5
+ module Trac
6
+
7
+ class Ticket
8
+
9
+ attr_reader :repository, :number
10
+
11
+ def initialize(repo, number)
12
+ @repository, @number = repo, Integer(number)
13
+ end
14
+
15
+ def inspect
16
+ "#<#{self.class.inspect} #{url}>"
17
+ end
18
+
19
+ def url(format = nil)
20
+ query = "?format=#{format}" if format
21
+ "#{@repository.url}/ticket/#{@number}#{query}"
22
+ end
23
+
24
+ def attachment_url
25
+ "#{@repository.url}/attachment/ticket/#{@number}"
26
+ end
27
+
28
+ def csv
29
+ require 'net/http'
30
+ require 'uri'
31
+ body = Net::HTTP.get_response(URI.parse(url(:tab))).body
32
+ headers, values = body.split(/\r?\n/).map do |line|
33
+ line.split("\t").map do |column|
34
+ column.gsub(/\\(.)/) do
35
+ case $1
36
+ when "r" then ""
37
+ when "n" then "\n"
38
+ when "t" then "\t"
39
+ else $1
40
+ end
41
+ end
42
+ end
43
+ end
44
+ return headers.zip(values).inject({}) {|h,(k,v)| h[k] = v; h}
45
+ end
46
+
47
+ attr_reader :number
48
+
49
+ def attachments(&block)
50
+ entries = repository.agent.get(attachment_url).at("dl.attachments")
51
+ require 'git/trac/attachment'
52
+ Attachment.from_hpricot(self,entries)
53
+ end
54
+
55
+ def trac_dir
56
+ "#{repository.git_dir}/refs/remotes/trac/#{number}"
57
+ end
58
+
59
+ class Form
60
+ def initialize(mech_form)
61
+ @mech_form = mech_form
62
+ end
63
+
64
+ def submit(*args)
65
+ @mech_form.submit
66
+ end
67
+
68
+ def method_missing(method,*args,&block)
69
+ if method.to_s[-1] == "=" && @mech_form.fields.name(method.to_s[0..-2]).any?
70
+ @mech_form.fields.name(method.to_s[0..-2]).send(:value=,*args,&block)
71
+ elsif @mech_form.fields.name(method.to_s).any?
72
+ @mech_form.fields.name(method.to_s).send(:value,*args,&block)
73
+ else
74
+ super(method,*args,&block)
75
+ end
76
+ end
77
+ end
78
+
79
+ def form
80
+ repository.agent.get(url).forms.last
81
+ end
82
+
83
+ def comment!(body)
84
+ form = form()
85
+ form.fields.name("comment").value = body
86
+ form.submit
87
+ end
88
+
89
+ def upload_attachment(description, filename, body)
90
+ form = repository.agent.get("#{attachment_url}?action-new").forms.last
91
+ author = form.fields.name("author")
92
+ if author.any? && author.value == "anonymous"
93
+ author.value = repository.options[:user].to_s
94
+ end
95
+ form.fields.name("description").value = description.to_s
96
+ attachment = form.file_uploads.name("attachment").first
97
+ attachment.file_name = filename
98
+ attachment.file_data = body
99
+ form.submit.instance_variable_get(:@uri)
100
+ end
101
+
102
+ def upload_patch(options = {})
103
+ filename = options[:filename] || "#{File.basename(repository.current_checkout)}.patch"
104
+ # diff = repository.exec("git-diff","#{options[:branch] || "trunk"}...HEAD")
105
+ diff = repository.exec("git-diff", options[:branch] || "trunk")
106
+ return false if diff.empty?
107
+ # Don't upload the exact same patch that was pulled down
108
+ return false if repository.generated_commits[repository.rev_parse("HEAD")] == number
109
+ upload_attachment(options[:description], filename, diff)
110
+ end
111
+
112
+ def open?
113
+ c = csv
114
+ %w(new reopened).include?(c["status"])
115
+ end
116
+
117
+ def cleanup
118
+ revs = []
119
+ repository.each_ref("refs/remotes/trac/#{number}") do |object, ref|
120
+ revs << object
121
+ repository.exec("git-update-ref","-d",ref,object)
122
+ end
123
+ unless revs.empty?
124
+ repository.each_ref("refs/heads") do |object, ref|
125
+ if revs.include?(object) && repository.current_checkout != File.basename(ref)
126
+ repository.exec("git-branch","-D",File.basename(ref))
127
+ end
128
+ end
129
+ true
130
+ end
131
+ end
132
+
133
+ def fetch(options = {})
134
+ cleanup
135
+ seen = {}
136
+ repository.with_index("tracindex#{$$}") do
137
+ FileUtils.mkdir_p(trac_dir)
138
+ attachments.map do |attachment|
139
+ parent = repository.exec("git-rev-list","--max-count=1","--before=#{attachment.timestamp}",options[:branch] || 'trunk').chomp
140
+ repository.exec("git-read-tree",parent)
141
+ unless attachment.apply!
142
+ yield attachment, false if block_given?
143
+ next
144
+ end
145
+ tree = repository.exec("git-write-tree").chomp
146
+ ENV["GIT_AUTHOR_NAME"] = ENV["GIT_COMMITTER_NAME"] = attachment.username
147
+ ENV["GIT_AUTHOR_EMAIL"] = ENV["GIT_COMMITTER_EMAIL"] = attachment.email
148
+ ENV["GIT_AUTHOR_DATE"] = ENV["GIT_COMMITTER_DATE"] = attachment.timestamp
149
+ if repeated = seen[attachment.name]
150
+ if repeated.last == parent
151
+ parents = " -p #{repeated.first}"
152
+ else
153
+ parents = " -p #{repeated.first} -p #{parent}"
154
+ end
155
+ else
156
+ parents = " -p #{parent}"
157
+ end
158
+ commit = repository.popen3("git-commit-tree #{tree}#{parents}") do |i,o,e|
159
+ i.puts attachment.description || attachment.filename
160
+ i.close
161
+ o.read.chomp
162
+ end
163
+ File.open(attachment.tag_path,"w") do |f|
164
+ f.puts commit
165
+ end
166
+ yield attachment, attachment.tag_name if block_given?
167
+ seen[attachment.name] = [commit, parent]
168
+ commit
169
+ end
170
+ end
171
+ seen.each do |k,v|
172
+ if !File.exists?(path = "#{repository.git_dir}/refs/heads/#{k}")
173
+ File.open(path, "w") {|f| f.puts v.first}
174
+ end
175
+ end
176
+ ensure
177
+ %w(GIT_AUTHOR_NAME GIT_COMMITTER_NAME GIT_AUTHOR_EMAIL GIT_COMMITTER_EMAIL GIT_AUTHOR_DATE GIT_COMMITTER_DATE).each {|e| ENV[e] = nil}
178
+ end
179
+
180
+ end
181
+
182
+ end
183
+ end