pyrat-integrity-basecamp 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
data/README.markdown ADDED
@@ -0,0 +1,50 @@
1
+ Integrity
2
+ =========
3
+
4
+ [Integrity][] is your friendly automated Continuous Integration server.
5
+
6
+ Integrity Basecamp Notifier
7
+ ===========================
8
+
9
+ This lets Integrity alert Basecamp after each build is made.
10
+
11
+ Setup Instructions
12
+ ==================
13
+
14
+ Just install this gem via `sudo gem install -s http://gems.github.com
15
+ pyrat-integrity-basecamp` and then in your Rackup (ie, `config.ru`) file:
16
+
17
+ require "rubygems"
18
+ require "notifier/basecamp"
19
+
20
+ Now you can set up your projects to alert Basecamp after
21
+ each build (just edit the project and the config options should be
22
+ there)
23
+
24
+ License
25
+ =======
26
+
27
+ (The MIT License)
28
+
29
+ Copyright (c) 2009 Alastair Brunton
30
+
31
+ Permission is hereby granted, free of charge, to any person obtaining
32
+ a copy of this software and associated documentation files (the
33
+ 'Software'), to deal in the Software without restriction, including
34
+ without limitation the rights to use, copy, modify, merge, publish,
35
+ distribute, sublicense, and/or sell copies of the Software, and to
36
+ permit persons to whom the Software is furnished to do so, subject to
37
+ the following conditions:
38
+
39
+ The above copyright notice and this permission notice shall be
40
+ included in all copies or substantial portions of the Software.
41
+
42
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
43
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
44
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
45
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
46
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
47
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
48
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
49
+
50
+ [Integrity]: http://integrityapp.com
@@ -0,0 +1,491 @@
1
+ # the following are all standard ruby libraries
2
+ require 'net/https'
3
+ require 'yaml'
4
+ require 'date'
5
+ require 'time'
6
+
7
+ begin
8
+ require 'rubygems'
9
+ require 'xmlsimple'
10
+ rescue LoadError
11
+ begin
12
+ require 'rubygems'
13
+ require_gem 'xml-simple'
14
+ rescue LoadError
15
+ abort <<-ERROR
16
+ The 'xml-simple' library could not be loaded. If you have RubyGems installed
17
+ you can install xml-simple by doing "gem install xml-simple".
18
+ ERROR
19
+ end
20
+ end
21
+
22
+ # An interface to the Basecamp web-services API. Usage is straightforward:
23
+ #
24
+ # session = Basecamp.new('your.basecamp.com', 'username', 'password')
25
+ # puts "projects: #{session.projects.length}"
26
+ class Basecamp
27
+
28
+ # A wrapper to encapsulate the data returned by Basecamp, for easier access.
29
+ class Record #:nodoc:
30
+ attr_reader :type
31
+
32
+ def initialize(type, hash)
33
+ @type = type
34
+ @hash = hash
35
+ end
36
+
37
+ def [](name)
38
+ name = dashify(name)
39
+ case @hash[name]
40
+ when Hash then
41
+ @hash[name] = (@hash[name].keys.length == 1 && Array === @hash[name].values.first) ?
42
+ @hash[name].values.first.map { |v| Record.new(@hash[name].keys.first, v) } :
43
+ Record.new(name, @hash[name])
44
+ else @hash[name]
45
+ end
46
+ end
47
+
48
+ def id
49
+ @hash["id"]
50
+ end
51
+
52
+ def attributes
53
+ @hash.keys
54
+ end
55
+
56
+ def respond_to?(sym)
57
+ super || @hash.has_key?(dashify(sym))
58
+ end
59
+
60
+ def method_missing(sym, *args)
61
+ if args.empty? && !block_given? && respond_to?(sym)
62
+ self[sym]
63
+ else
64
+ super
65
+ end
66
+ end
67
+
68
+ def to_s
69
+ "\#<Record(#{@type}) #{@hash.inspect[1..-2]}>"
70
+ end
71
+
72
+ def inspect
73
+ to_s
74
+ end
75
+
76
+ private
77
+
78
+ def dashify(name)
79
+ name.to_s.tr("_", "-")
80
+ end
81
+ end
82
+
83
+ # A wrapper to represent a file that should be uploaded. This is used so that
84
+ # the form/multi-part encoder knows when to encode a field as a file, versus
85
+ # when to encode it as a simple field.
86
+ class FileUpload
87
+ attr_reader :filename, :content
88
+
89
+ def initialize(filename, content)
90
+ @filename = filename
91
+ @content = content
92
+ end
93
+ end
94
+
95
+ attr_accessor :use_xml
96
+
97
+ # Connects
98
+ def initialize(url, user_name, password, use_ssl = false)
99
+ @use_xml = false
100
+ @user_name, @password = user_name, password
101
+ connect!(url, use_ssl)
102
+ end
103
+
104
+ # Return the list of all accessible projects.
105
+ def projects
106
+ records "project", "/project/list"
107
+ end
108
+
109
+ # Returns the list of message categories for the given project
110
+ def message_categories(project_id)
111
+ records "post-category", "/projects/#{project_id}/post_categories"
112
+ end
113
+
114
+ # Returns the list of file categories for the given project
115
+ def file_categories(project_id)
116
+ records "attachment-category", "/projects/#{project_id}/attachment_categories"
117
+ end
118
+
119
+ # Return information for the company with the given id
120
+ def company(id)
121
+ record "/contacts/company/#{id}"
122
+ end
123
+
124
+ # Return an array of the people in the given company. If the project-id is
125
+ # given, only people who have access to the given project will be returned.
126
+ def people(company_id, project_id=nil)
127
+ url = project_id ? "/projects/#{project_id}" : ""
128
+ url << "/contacts/people/#{company_id}"
129
+ records "person", url
130
+ end
131
+
132
+ # Return information about the person with the given id
133
+ def person(id)
134
+ record "/contacts/person/#{id}"
135
+ end
136
+
137
+ # Return information about the message(s) with the given id(s). The API
138
+ # limits you to requesting 25 messages at a time, so if you need to get more
139
+ # than that, you'll need to do it in multiple requests.
140
+ def message(*ids)
141
+ result = records("post", "/msg/get/#{ids.join(",")}")
142
+ result.length == 1 ? result.first : result
143
+ end
144
+
145
+ # Returns a summary of all messages in the given project (and category, if
146
+ # specified). The summary is simply the title and category of the message,
147
+ # as well as the number of attachments (if any).
148
+ def message_list(project_id, category_id=nil)
149
+ url = "/projects/#{project_id}/msg"
150
+ url << "/cat/#{category_id}" if category_id
151
+ url << "/archive"
152
+
153
+ records "post", url
154
+ end
155
+
156
+ # Create a new message in the given project. The +message+ parameter should
157
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
158
+ # should be notified of the post.
159
+ #
160
+ # If you want to add attachments to the message, the +attachments+ parameter
161
+ # should be an array of hashes, where each has has a :name key (optional),
162
+ # and a :file key (required). The :file key must refer to a Basecamp::FileUpload
163
+ # instance.
164
+ #
165
+ # msg = session.post_message(158141,
166
+ # { :title => "Requirements",
167
+ # :body => "Here are the requirements documents you asked for.",
168
+ # :category_id => 2301121 },
169
+ # [john.id, martha.id],
170
+ # [ { :name => "Primary Requirements",
171
+ # :file => Basecamp::FileUpload.new('primary.doc", File.read('primary.doc')) },
172
+ # { :file => Basecamp::FileUpload.new('other.doc', File.read('other.doc')) } ])
173
+ def post_message(project_id, message, notify=[], attachments=[])
174
+ prepare_attachments(attachments)
175
+ record "/projects/#{project_id}/msg/create",
176
+ :post => message,
177
+ :notify => notify,
178
+ :attachments => attachments
179
+ end
180
+
181
+ # Edit the message with the given id. The +message+ parameter should
182
+ # be a hash. The +email_to+ parameter must be an array of person-id's that
183
+ # should be notified of the post.
184
+ #
185
+ # The +attachments+ parameter, if used, should be the same as described for
186
+ # #post_message.
187
+ def update_message(id, message, notify=[], attachments=[])
188
+ prepare_attachments(attachments)
189
+ record "/msg/update/#{id}",
190
+ :post => message,
191
+ :notify => notify,
192
+ :attachments => attachments
193
+ end
194
+
195
+ # Deletes the message with the given id, and returns it.
196
+ def delete_message(id)
197
+ record "/msg/delete/#{id}"
198
+ end
199
+
200
+ # Return a list of the comments for the specified message.
201
+ def comments(post_id)
202
+ records "comment", "/msg/comments/#{post_id}"
203
+ end
204
+
205
+ # Retrieve a specific comment
206
+ def comment(id)
207
+ record "/msg/comment/#{id}"
208
+ end
209
+
210
+ # Add a new comment to a message. +comment+ must be a hash describing the
211
+ # comment. You can add attachments to the comment, too, by giving them in
212
+ # an array. See the #post_message method for a description of how to do that.
213
+ def create_comment(post_id, comment, attachments=[])
214
+ prepare_attachments(attachments)
215
+ record "/msg/create_comment", :comment => comment.merge(:post_id => post_id),
216
+ :attachments => attachments
217
+ end
218
+
219
+ # Update the given comment. Attachments follow the same format as #post_message.
220
+ def update_comment(id, comment, attachments=[])
221
+ prepare_attachments(attachments)
222
+ record "/msg/update_comment", :comment_id => id,
223
+ :comment => comment, :attachments => attachments
224
+ end
225
+
226
+ # Deletes (and returns) the given comment.
227
+ def delete_comment(id)
228
+ record "/msg/delete_comment/#{id}"
229
+ end
230
+
231
+ # =========================================================================
232
+ # TODO LISTS AND ITEMS
233
+ # =========================================================================
234
+
235
+ # Marks the given item completed.
236
+ def complete_item(id)
237
+ record "/todos/complete_item/#{id}"
238
+ end
239
+
240
+ # Marks the given item uncompleted.
241
+ def uncomplete_item(id)
242
+ record "/todos/uncomplete_item/#{id}"
243
+ end
244
+
245
+ # Creates a new to-do item.
246
+ def create_item(list_id, content, responsible_party=nil, notify=true)
247
+ record "/todos/create_item/#{list_id}",
248
+ :content => content, :responsible_party => responsible_party,
249
+ :notify => notify
250
+ end
251
+
252
+ # Creates a new list using the given hash of list metadata.
253
+ def create_list(project_id, list)
254
+ record "/projects/#{project_id}/todos/create_list", list
255
+ end
256
+
257
+ # Deletes the given item from it's parent list.
258
+ def delete_item(id)
259
+ record "/todos/delete_item/#{id}"
260
+ end
261
+
262
+ # Deletes the given list and all of its items.
263
+ def delete_list(id)
264
+ record "/todos/delete_list/#{id}"
265
+ end
266
+
267
+ # Retrieves the specified list, and all of its items.
268
+ def get_list(id)
269
+ record "/todos/list/#{id}"
270
+ end
271
+
272
+ # Return all lists for a project. If complete is true, only completed lists
273
+ # are returned. If complete is false, only uncompleted lists are returned.
274
+ def lists(project_id, complete=nil)
275
+ records "todo-list", "/projects/#{project_id}/todos/lists", :complete => complete
276
+ end
277
+
278
+ # Repositions an item to be at the given position in its list
279
+ def move_item(id, to)
280
+ record "/todos/move_item/#{id}", :to => to
281
+ end
282
+
283
+ # Repositions a list to be at the given position in its project
284
+ def move_list(id, to)
285
+ record "/todos/move_list/#{id}", :to => to
286
+ end
287
+
288
+ # Updates the given item
289
+ def update_item(id, content, responsible_party=nil, notify=true)
290
+ record "/todos/update_item/#{id}",
291
+ :item => { :content => content }, :responsible_party => responsible_party,
292
+ :notify => notify
293
+ end
294
+
295
+ # Updates the given list's metadata
296
+ def update_list(id, list)
297
+ record "/todos/update_list/#{id}", :list => list
298
+ end
299
+
300
+ # =========================================================================
301
+ # MILESTONES
302
+ # =========================================================================
303
+
304
+ # Complete the milestone with the given id
305
+ def complete_milestone(id)
306
+ record "/milestones/complete/#{id}"
307
+ end
308
+
309
+ # Create a new milestone for the given project. +data+ must be hash of the
310
+ # values to set, including +title+, +deadline+, +responsible_party+, and
311
+ # +notify+.
312
+ def create_milestone(project_id, data)
313
+ create_milestones(project_id, [data]).first
314
+ end
315
+
316
+ # As #create_milestone, but can create multiple milestones in a single
317
+ # request. The +milestones+ parameter must be an array of milestone values as
318
+ # descrbed in #create_milestone.
319
+ def create_milestones(project_id, milestones)
320
+ records "milestone", "/projects/#{project_id}/milestones/create", :milestone => milestones
321
+ end
322
+
323
+ # Destroys the milestone with the given id.
324
+ def delete_milestone(id)
325
+ record "/milestones/delete/#{id}"
326
+ end
327
+
328
+ # Returns a list of all milestones for the given project, optionally filtered
329
+ # by whether they are completed, late, or upcoming.
330
+ def milestones(project_id, find="all")
331
+ records "milestone", "/projects/#{project_id}/milestones/list", :find => find
332
+ end
333
+
334
+ # Uncomplete the milestone with the given id
335
+ def uncomplete_milestone(id)
336
+ record "/milestones/uncomplete/#{id}"
337
+ end
338
+
339
+ # Updates an existing milestone.
340
+ def update_milestone(id, data, move=false, move_off_weekends=false)
341
+ record "/milestones/update/#{id}", :milestone => data,
342
+ :move_upcoming_milestones => move,
343
+ :move_upcoming_milestones_off_weekends => move_off_weekends
344
+ end
345
+
346
+ # Make a raw web-service request to Basecamp. This will return a Hash of
347
+ # Arrays of the response, and may seem a little odd to the uninitiated.
348
+ def request(path, parameters = {}, second_try = false)
349
+ response = post(path, convert_body(parameters), "Content-Type" => content_type)
350
+
351
+ if response.code.to_i / 100 == 2
352
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true,
353
+ 'contentkey' => '__content__', 'forcecontent' => true)
354
+ typecast_value(result)
355
+ elsif response.code == "302" && !second_try
356
+ connect!(@url, !@use_ssl)
357
+ request(path, parameters, true)
358
+ else
359
+ raise "#{response.message} (#{response.code})"
360
+ end
361
+ end
362
+
363
+ # A convenience method for wrapping the result of a query in a Record
364
+ # object. This assumes that the result is a singleton, not a collection.
365
+ def record(path, parameters={})
366
+ result = request(path, parameters)
367
+ (result && !result.empty?) ? Record.new(result.keys.first, result.values.first) : nil
368
+ end
369
+
370
+ # A convenience method for wrapping the result of a query in Record
371
+ # objects. This assumes that the result is a collection--any singleton
372
+ # result will be wrapped in an array.
373
+ def records(node, path, parameters={})
374
+ result = request(path, parameters).values.first or return []
375
+ result = result[node] or return []
376
+ result = [result] unless Array === result
377
+ result.map { |row| Record.new(node, row) }
378
+ end
379
+
380
+ private
381
+
382
+ def connect!(url, use_ssl)
383
+ @use_ssl = use_ssl
384
+ @url = url
385
+ @connection = Net::HTTP.new(url, use_ssl ? 443 : 80)
386
+ @connection.use_ssl = @use_ssl
387
+ @connection.verify_mode = OpenSSL::SSL::VERIFY_NONE if @use_ssl
388
+ end
389
+
390
+ def convert_body(body)
391
+ body = use_xml ? body.to_xml : body.to_yaml
392
+ end
393
+
394
+ def content_type
395
+ use_xml ? "application/xml" : "application/x-yaml"
396
+ end
397
+
398
+ def post(path, body, header={})
399
+ request = Net::HTTP::Post.new(path, header.merge('Accept' => 'application/xml'))
400
+ request.basic_auth(@user_name, @password)
401
+ @connection.request(request, body)
402
+ end
403
+
404
+ def store_file(contents)
405
+ response = post("/upload", contents, 'Content-Type' => 'application/octet-stream',
406
+ 'Accept' => 'application/xml')
407
+
408
+ if response.code == "200"
409
+ result = XmlSimple.xml_in(response.body, 'keeproot' => true, 'forcearray' => false)
410
+ return result["upload"]["id"]
411
+ else
412
+ raise "Could not store file: #{response.message} (#{response.code})"
413
+ end
414
+ end
415
+
416
+ def typecast_value(value)
417
+ case value
418
+ when Hash
419
+ if value.has_key?("__content__")
420
+ content = translate_entities(value["__content__"]).strip
421
+ case value["type"]
422
+ when "integer" then content.to_i
423
+ when "boolean" then content == "true"
424
+ when "datetime" then Time.parse(content)
425
+ when "date" then Date.parse(content)
426
+ else content
427
+ end
428
+ # a special case to work-around a bug in XmlSimple. When you have an empty
429
+ # tag that has an attribute, XmlSimple will not add the __content__ key
430
+ # to the returned hash. Thus, we check for the presense of the 'type'
431
+ # attribute to look for empty, typed tags, and simply return nil for
432
+ # their value.
433
+ elsif value.keys == %w(type)
434
+ nil
435
+ elsif value["nil"] == "true"
436
+ nil
437
+ # another special case, introduced by the latest rails, where an array
438
+ # type now exists. This is parsed by XmlSimple as a two-key hash, where
439
+ # one key is 'type' and the other is the actual array value.
440
+ elsif value.keys.length == 2 && value["type"] == "array"
441
+ value.delete("type")
442
+ typecast_value(value)
443
+ else
444
+ value.empty? ? nil : value.inject({}) do |h,(k,v)|
445
+ h[k] = typecast_value(v)
446
+ h
447
+ end
448
+ end
449
+ when Array
450
+ value.map! { |i| typecast_value(i) }
451
+ case value.length
452
+ when 0 then nil
453
+ when 1 then value.first
454
+ else value
455
+ end
456
+ else
457
+ raise "can't typecast #{value.inspect}"
458
+ end
459
+ end
460
+
461
+ def translate_entities(value)
462
+ value.gsub(/&lt;/, "<").
463
+ gsub(/&gt;/, ">").
464
+ gsub(/&quot;/, '"').
465
+ gsub(/&apos;/, "'").
466
+ gsub(/&amp;/, "&")
467
+ end
468
+
469
+ def prepare_attachments(list)
470
+ (list || []).each do |data|
471
+ upload = data[:file]
472
+ id = store_file(upload.content)
473
+ data[:file] = { :file => id,
474
+ :content_type => "application/octet-stream",
475
+ :original_filename => upload.filename }
476
+ end
477
+ end
478
+ end
479
+
480
+ # A minor hack to let Xml-Simple serialize symbolic keys in hashes
481
+ class Symbol
482
+ def [](*args)
483
+ to_s[*args]
484
+ end
485
+ end
486
+
487
+ class Hash
488
+ def to_xml
489
+ XmlSimple.xml_out({:request => self}, 'keeproot' => true, 'noattr' => true)
490
+ end
491
+ end
@@ -0,0 +1,41 @@
1
+ require 'rubygems'
2
+ require 'integrity'
3
+ require 'basecamp-api'
4
+
5
+ module Integrity
6
+ class Notifier
7
+ class Basecamp < Notifier::Base
8
+ attr_reader :config
9
+
10
+ def self.to_haml
11
+ File.read File.dirname(__FILE__) / "config.haml"
12
+ end
13
+
14
+
15
+ def deliver!
16
+ @basecamp = Basecamp.new(config['domain'], options['user'], options['pass'], true)
17
+ message = {:title => short_message,
18
+ :body => full_message,
19
+ :category_id => config['category_id']}
20
+ @basecamp.post_message(config['project_id'], message)
21
+ end
22
+
23
+ private
24
+
25
+ def short_message
26
+ "Build #{build.short_commit_identifier} of #{build.project.name} #{build.successful? ? "was successful" : "failed"}"
27
+ end
28
+
29
+ def full_message
30
+ <<-EOM
31
+ Commit Message: #{build.commit_message}
32
+ Commit Date: #{build.commited_at}
33
+ Commit Author: #{build.commit_author.name}
34
+
35
+ #{stripped_build_output}
36
+ EOM
37
+ end
38
+
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,24 @@
1
+ :username => 'robot', :domain => 'ifo.projectpath.com',
2
+ # :password => 'robot', :project_id => 828898,
3
+ # :category_id => 15509
4
+
5
+
6
+ %p.normal
7
+ %label{ :for => "basecamp_notifier_account" } Subdomain
8
+ %input.text#basecamp_notifier_account{ :name => "notifiers[Basecamp][account]", :type => "text", :value => config["account"] }
9
+
10
+ %p.normal
11
+ %label{ :for => "basecamp_notifier_room" } Project Id
12
+ %input.text#basecamp_notifier_project{ :name => "notifiers[Basecamp][project_id]", :type => "text", :value => config["project_id"] }
13
+
14
+ %p.normal
15
+ %label{ :for => "basecamp_notifier_room" } Category Id
16
+ %input.text#basecamp_notifier_project{ :name => "notifiers[Basecamp][category_id]", :type => "text", :value => config["category_id"] }
17
+
18
+ %p.normal
19
+ %label{ :for => "basecamp_notifier_user" } User
20
+ %input.text#basecamp_notifier_user{ :name => "notifiers[Basecamp][user]", :value => config["user"], :type => "text" }
21
+
22
+ %p.normal
23
+ %label{ :for => "basecamp_notifier_pass" } Password
24
+ %input.text#basecamp_notifier_pass{ :name => "notifiers[Basecamp][pass]", :value => config["pass"], :type => "text" }
metadata ADDED
@@ -0,0 +1,64 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: pyrat-integrity-basecamp
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ platform: ruby
6
+ authors:
7
+ - Alastair Brunton
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-22 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: foca-integrity
17
+ version_requirement:
18
+ version_requirements: !ruby/object:Gem::Requirement
19
+ requirements:
20
+ - - ">="
21
+ - !ruby/object:Gem::Version
22
+ version: "0"
23
+ version:
24
+ description: Easily let Integrity alert Basecamp after each build
25
+ email: info@simplyexcited.co.uk
26
+ executables: []
27
+
28
+ extensions: []
29
+
30
+ extra_rdoc_files: []
31
+
32
+ files:
33
+ - README.markdown
34
+ - lib/notifier/config.haml
35
+ - lib/notifier/basecamp.rb
36
+ - lib/notifier/basecamp-api.rb
37
+ has_rdoc: false
38
+ homepage: http://integrityapp.com
39
+ post_install_message:
40
+ rdoc_options: []
41
+
42
+ require_paths:
43
+ - lib
44
+ required_ruby_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: "0"
49
+ version:
50
+ required_rubygems_version: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: "0"
55
+ version:
56
+ requirements: []
57
+
58
+ rubyforge_project:
59
+ rubygems_version: 1.2.0
60
+ signing_key:
61
+ specification_version: 2
62
+ summary: Basecamp notifier for the Integrity continuous integration server
63
+ test_files: []
64
+