mingle_events 0.0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (41) hide show
  1. data/Gemfile +4 -0
  2. data/LICENSE.txt +202 -0
  3. data/README.textile +117 -0
  4. data/lib/mingle_events.rb +26 -0
  5. data/lib/mingle_events/feed.rb +5 -0
  6. data/lib/mingle_events/feed/author.rb +28 -0
  7. data/lib/mingle_events/feed/category.rb +75 -0
  8. data/lib/mingle_events/feed/element_support.rb +19 -0
  9. data/lib/mingle_events/feed/entry.rb +96 -0
  10. data/lib/mingle_events/feed/page.rb +61 -0
  11. data/lib/mingle_events/http_error.rb +26 -0
  12. data/lib/mingle_events/mingle_basic_auth_access.rb +71 -0
  13. data/lib/mingle_events/mingle_oauth_access.rb +40 -0
  14. data/lib/mingle_events/poller.rb +32 -0
  15. data/lib/mingle_events/processors.rb +10 -0
  16. data/lib/mingle_events/processors/author_filter.rb +62 -0
  17. data/lib/mingle_events/processors/card_data.rb +102 -0
  18. data/lib/mingle_events/processors/card_type_filter.rb +28 -0
  19. data/lib/mingle_events/processors/category_filter.rb +19 -0
  20. data/lib/mingle_events/processors/custom_property_filter.rb +30 -0
  21. data/lib/mingle_events/processors/http_post_publisher.rb +20 -0
  22. data/lib/mingle_events/processors/pipeline.rb +20 -0
  23. data/lib/mingle_events/processors/puts_publisher.rb +17 -0
  24. data/lib/mingle_events/project_custom_properties.rb +33 -0
  25. data/lib/mingle_events/project_event_fetcher.rb +96 -0
  26. data/test/mingle_events/feed/author_test.rb +39 -0
  27. data/test/mingle_events/feed/category_test.rb +20 -0
  28. data/test/mingle_events/feed/entry_test.rb +140 -0
  29. data/test/mingle_events/feed/page_test.rb +82 -0
  30. data/test/mingle_events/poller_test.rb +47 -0
  31. data/test/mingle_events/processors/author_filter_test.rb +80 -0
  32. data/test/mingle_events/processors/card_data_test.rb +210 -0
  33. data/test/mingle_events/processors/card_type_filter_test.rb +51 -0
  34. data/test/mingle_events/processors/category_filter_test.rb +27 -0
  35. data/test/mingle_events/processors/custom_property_filter_test.rb +51 -0
  36. data/test/mingle_events/processors/pipeline_test.rb +32 -0
  37. data/test/mingle_events/project_custom_properties_test.rb +39 -0
  38. data/test/mingle_events/project_event_fetcher_test.rb +122 -0
  39. data/test/test_helper.rb +163 -0
  40. data/test/web_hook_server/web_hook_server.rb +6 -0
  41. metadata +140 -0
@@ -0,0 +1,19 @@
1
+ module MingleEvents
2
+ module Feed
3
+
4
+ # Provides some helpers for pulling values from Nokogiri Elems
5
+ module ElementSupport
6
+
7
+ def element_text(parent_element, element_name, optional = false)
8
+ element = parent_element.at(".//#{element_name}")
9
+ if optional && element.nil?
10
+ nil
11
+ else
12
+ element.inner_text
13
+ end
14
+ end
15
+
16
+ end
17
+
18
+ end
19
+ end
@@ -0,0 +1,96 @@
1
+ module MingleEvents
2
+ module Feed
3
+
4
+ # A Ruby wrapper around an Atom entry, particularly an Atom entry
5
+ # representing an event in Mingle.
6
+ class Entry
7
+
8
+ # Construct with the wrapped Nokogiri Elem for the entry
9
+ def initialize(entry_element)
10
+ @entry_element = entry_element
11
+ end
12
+
13
+ # The raw entry XML from the Atom feed
14
+ def raw_xml
15
+ @raw_xml ||= @entry_element.to_s
16
+ end
17
+
18
+ # The Atom entry's id value. This is the one true identifier for the entry,
19
+ # and therefore the event.
20
+ def entry_id
21
+ @entry_id ||= @entry_element.at('id').inner_text
22
+ end
23
+ alias :event_id :entry_id
24
+
25
+ # The Atom entry's title
26
+ def title
27
+ @title ||= @entry_element.at('title').inner_text
28
+ end
29
+
30
+ # The time at which entry was created, i.e., the event was triggered
31
+ def updated
32
+ @updated ||= Time.parse(@entry_element.at('updated').inner_text)
33
+ end
34
+
35
+ # The user who created the entry (triggered the event), i.e., changed project data in Mingle
36
+ def author
37
+ @author ||= Author.new(@entry_element.at('author'))
38
+ end
39
+
40
+ # The set of Atom categoies describing the entry
41
+ def categories
42
+ @categories ||= @entry_element.search('category').map do |category_element|
43
+ Category.new(category_element.attribute('term').text, category_element.attribute('scheme').text)
44
+ end
45
+ end
46
+
47
+ # Whether the entry/event was sourced by a Mingle card
48
+ def card?
49
+ categories.any?{|c| c == Category::CARD}
50
+ end
51
+
52
+ # The number of the card that sourced this entry/event. If the entry is not a card event
53
+ # an error will be thrown. The source of this data is perhaps not so robust and we'll need
54
+ # to revisit this in the next release of Mingle.
55
+ def card_number
56
+ raise "You cannot get the card number for an event that is not sourced by a card!" unless card?
57
+ @card_number ||= parse_card_number
58
+ end
59
+
60
+ # The version number of the card or page that was created by this event. (For now, only
61
+ # working with cards.)
62
+ def version
63
+ @version ||= CGI.parse(URI.parse(card_version_resource_uri).query)['version'].first.to_i
64
+ end
65
+
66
+ # The resource URI for the card version that was created by this event. Throws error if not card event.
67
+ def card_version_resource_uri
68
+ raise "You cannot get card version data for an event that is not sourced by a card!" unless card?
69
+ @card_version_resource_uri ||= parse_card_version_resource_uri
70
+ end
71
+
72
+ def to_s
73
+ "Entry[entry_id=#{entry_id}, updated=#{updated}]"
74
+ end
75
+
76
+ private
77
+
78
+ def parse_card_number
79
+ card_number_element = @entry_element.at(
80
+ "link[@rel='http://www.thoughtworks-studios.com/ns/mingle#event-source'][@type='application/vnd.mingle+xml']"
81
+ )
82
+ # TODO: improve this bit of parsing :)
83
+ card_number_element.attribute('href').text.split('/').last.split('.')[0..-2].join.to_i
84
+ end
85
+
86
+ def parse_card_version_resource_uri
87
+ card_number_element = @entry_element.at(
88
+ "link[@rel='http://www.thoughtworks-studios.com/ns/mingle#version'][@type='application/vnd.mingle+xml']"
89
+ )
90
+ card_number_element.attribute('href').text
91
+ end
92
+
93
+ end
94
+
95
+ end
96
+ end
@@ -0,0 +1,61 @@
1
+ module MingleEvents
2
+ module Feed
3
+
4
+ # A page of Atom events. I.e., *not* a Mingle page. Most users of this library
5
+ # will not use this class to access the Mingle Atom feed, but will use the
6
+ # ProjectFeed class which handles paging transparently.
7
+ class Page
8
+
9
+ attr_accessor :url
10
+
11
+ def initialize(url, mingle_access)
12
+ @url = url
13
+ @mingle_access = mingle_access
14
+ end
15
+
16
+ def entries
17
+ @entries ||= page_as_document.search('feed/entry').map do |entry_element|
18
+ Entry.new(entry_element)
19
+ end
20
+ end
21
+
22
+ def next
23
+ next_url_element = page_as_document.at("feed/link[@rel='next']")
24
+ if next_url_element.nil?
25
+ nil
26
+ else
27
+ Page.new(next_url_element.attribute('href').text, @mingle_access)
28
+ end
29
+ end
30
+
31
+ def previous
32
+ previous_url_element = page_as_document.at("feed/link[@rel='previous']")
33
+ if previous_url_element.nil?
34
+ nil
35
+ else
36
+ Page.new(previous_url_element.attribute('href').text, @mingle_access)
37
+ end
38
+ end
39
+
40
+ def archived?
41
+ URI.parse(url).query && url == page_as_document.at("feed/link[@rel='self']").attribute('href').text
42
+ end
43
+
44
+ def closest_archived_page
45
+ if archived?
46
+ self
47
+ else
48
+ self.next
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def page_as_document
55
+ @page_as_document ||= Nokogiri::XML(@mingle_access.fetch_page(@url))
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+ end
@@ -0,0 +1,26 @@
1
+ module MingleEvents
2
+
3
+ class HttpError < StandardError
4
+
5
+ attr_reader :response, :requested_location, :additional_context
6
+
7
+ def initialize(response, requested_location, additional_context = nil)
8
+ super(%{
9
+ Unable to retrieve 200 response from URI: <#{requested_location}>!
10
+ HTTP Code: #{response.code}
11
+ Body: #{response.body}
12
+ #{additional_context.nil? ? "" : additional_context}
13
+ })
14
+ @response = response
15
+ @requested_location = requested_location
16
+ @additional_context = additional_context
17
+ end
18
+
19
+ def not_found?
20
+ # has to be a better way to do this!!
21
+ response.class == Net::HTTPNotFound
22
+ end
23
+
24
+ end
25
+
26
+ end
@@ -0,0 +1,71 @@
1
+ module MingleEvents
2
+
3
+ # Supports fetching of Mingle resources using HTTP basic auth.
4
+ # Please only use this class to access resources over HTTPS so
5
+ # as not to send credentials over plain-text connections.
6
+ class MingleBasicAuthAccess
7
+
8
+ attr_reader :base_url
9
+
10
+ BASIC_AUTH_HTTP_WARNING = %{
11
+ WARNING!!!
12
+ It looks like you are using basic authentication over a plain-text HTTP connection.
13
+ We HIGHLY recommend AGAINST this practice. You should only use basic authentication over
14
+ a secure HTTPS connection. Instructions for enabling HTTPS/SSL in Mingle can be found at
15
+ <http://www.thoughtworks-studios.com/mingle/3.3/help/advanced_mingle_configuration.html>
16
+ WARNING!!
17
+ }
18
+
19
+ def initialize(base_url, username, password)
20
+ @base_url = base_url
21
+ @username = username
22
+ @password = password
23
+ end
24
+
25
+ # Fetch the content at location via HTTP. Throws error if non-200 response.
26
+ def fetch_page(location)
27
+ MingleEvents.log.info("About to fetch #{location}...")
28
+ rsp = fetch_page_response(location)
29
+ case rsp
30
+ when Net::HTTPSuccess
31
+ rsp.body
32
+ when Net::HTTPUnauthorized
33
+ raise HttpError.new(rsp, location, %{
34
+ If you think you are passing correct credentials, please check
35
+ that you have enabled Mingle for basic authentication.
36
+ See <http://www.thoughtworks-studios.com/mingle/3.3/help/configuring_mingle_authentication.html>.})
37
+ else
38
+ raise HttpError.new(rsp, location)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def fetch_page_response(location)
45
+ location = @base_url + location if location[0..0] == '/'
46
+
47
+ uri = URI.parse(location)
48
+ http = Net::HTTP.new(uri.host, uri.port)
49
+
50
+ if uri.scheme == 'https'
51
+ http.use_ssl = true
52
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
53
+ else
54
+ puts BASIC_AUTH_HTTP_WARNING
55
+ end
56
+
57
+ path = uri.path
58
+ path += "?#{uri.query}" if uri.query
59
+ puts "Fetching page at #{path}..."
60
+
61
+ start = Time.now
62
+ req = Net::HTTP::Get.new(path)
63
+ req.basic_auth(@username, @password)
64
+ rsp = http.request(req)
65
+ puts "...#{Time.now - start}"
66
+
67
+ rsp
68
+ end
69
+
70
+ end
71
+ end
@@ -0,0 +1,40 @@
1
+ module MingleEvents
2
+
3
+ # Client for Mingle's experimental OAuth 2.0 support in 3.0
4
+ #--
5
+ # TODO: Update error handling and support of fetching response
6
+ # objects to that of MingleBasicAuthAccess
7
+ class MingleOauthAccess
8
+
9
+ def initialize(base_url, token)
10
+ @base_url = base_url
11
+ @token = token
12
+ end
13
+
14
+ def fetch_page(location)
15
+ location = @base_url + location if location[0..0] == '/'
16
+
17
+ uri = URI.parse(location)
18
+ http = Net::HTTP.new(uri.host, uri.port)
19
+ http.use_ssl = true
20
+ http.verify_mode = OpenSSL::SSL::VERIFY_NONE
21
+ headers = {
22
+ 'Authorization' => %{Token token="#{@token}"}
23
+ }
24
+
25
+ path = uri.path
26
+ path += "?#{uri.query}" if uri.query
27
+ puts "Fetching page at #{path}..."
28
+
29
+ start = Time.now
30
+ response = http.get(path, headers)
31
+ puts "...#{Time.now - start}"
32
+
33
+ # todo: what's the right way to raise on non 200 ?
34
+ # raise StandardError.new(response.body) unless response == Net::HTTPSuccess
35
+
36
+ response.body
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ module MingleEvents
2
+ class Poller
3
+
4
+ # Manages a full sweep of event processing across each processing pipeline
5
+ # configured for specified mingle projects. processors_by_project_identifier should
6
+ # be a hash where the keys are mingle project identifiers and the values are
7
+ # lists of event processors.
8
+ def initialize(mingle_access, processors_by_project_identifier)
9
+ @mingle_access = mingle_access
10
+ @processors_by_project_identifier = processors_by_project_identifier
11
+ end
12
+
13
+ # Run a single poll for each project configured with processor(s) and
14
+ # broadcast each event to each processor.
15
+ def run_once(options={})
16
+ MingleEvents.log.info("MingleEvents::Poller about to poll once...")
17
+ @processors_by_project_identifier.each do |project_identifier, processors|
18
+ fetcher = ProjectEventFetcher.new(project_identifier, @mingle_access)
19
+ fetcher.reset if options[:clean]
20
+ info_file_for_new_event = fetcher.fetch_latest
21
+ while info_file_for_new_event
22
+ entry_info = YAML.load(File.new(info_file_for_new_event))
23
+ entry = Feed::Entry.new(Nokogiri::XML(entry_info[:entry_xml]).at('/entry'))
24
+ MingleEvents.log.info("About to process event #{entry.entry_id}...")
25
+ processors.each{|p| p.process_events([entry])}
26
+ info_file_for_new_event = entry_info[:next_entry_file_path]
27
+ end
28
+ end
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,10 @@
1
+ require 'cgi'
2
+
3
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'card_data'))
4
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'category_filter'))
5
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'card_type_filter'))
6
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'http_post_publisher'))
7
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'pipeline'))
8
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'puts_publisher'))
9
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'author_filter'))
10
+ require File.expand_path(File.join(File.dirname(__FILE__), 'processors', 'custom_property_filter'))
@@ -0,0 +1,62 @@
1
+ module MingleEvents
2
+ module Processors
3
+
4
+ # Removes all events from stream not triggered by the specified author
5
+ class AuthorFilter
6
+
7
+ def initialize(spec, mingle_access, project_identifier)
8
+ unless spec.size == 1
9
+ raise "Author spec must contain 1 and only 1 piece of criteria (the only legal criteria are each unique identifiers in and of themselves so multiple criteria is not needed.)"
10
+ end
11
+
12
+ @author_spec = AuthorSpec.new(spec, mingle_access, project_identifier)
13
+ end
14
+
15
+ def process_events(events)
16
+ events.select{|event| @author_spec.event_triggered_by?(event)}
17
+ end
18
+
19
+ class AuthorSpec
20
+
21
+ def initialize(spec, mingle_access, project_identifier)
22
+ @spec = spec
23
+ @mingle_access = mingle_access
24
+ @project_identifier = project_identifier
25
+ end
26
+
27
+ def event_triggered_by?(event)
28
+ event.author.uri == author_uri
29
+ end
30
+
31
+ private
32
+
33
+ def author_uri
34
+ lookup_author_uri
35
+ end
36
+
37
+ def lookup_author_uri
38
+ team_resource = "/api/v2/projects/#{@project_identifier}/team.xml"
39
+ @raw_xml ||= @mingle_access.fetch_page(URI.escape(team_resource))
40
+ @doc ||= Nokogiri::XML(@raw_xml)
41
+
42
+ users = @doc.search('/projects_members/projects_member/user').map do |user|
43
+ {
44
+ :url => user.attribute('url').inner_text,
45
+ :login => user.at('login').inner_text,
46
+ :email => user.at('email').inner_text
47
+ }
48
+ end
49
+
50
+ spec_user = users.find do |user|
51
+ # is this too hacky?
52
+ user.merge(@spec) == user
53
+ end
54
+
55
+ spec_user.nil? ? nil : spec_user[:url]
56
+ end
57
+
58
+ end
59
+
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,102 @@
1
+ module MingleEvents
2
+ module Processors
3
+
4
+ # Provides ability to lookup card data, e.g., card's type name, for any
5
+ # card serving as a source for the stream of events. Implements two interfaces:
6
+ # the standard event processing interface, handle_events, and also, for_card_event,
7
+ # which returns of has of card data for the given event. See the project README
8
+ # for additional information on using this class in a processing pipeline.
9
+ class CardData
10
+
11
+ def initialize(mingle_access, project_identifier, custom_properties = ProjectCustomProperties.new(mingle_access, project_identifier))
12
+ @mingle_access = mingle_access
13
+ @project_identifier = project_identifier
14
+ @custom_properties = custom_properties
15
+ @card_data_by_number_and_version = nil
16
+ end
17
+
18
+ # Capture which events are card events and might require data lookup. The
19
+ # actual data retrieval is lazy and will only occur as needed.
20
+ def process_events(events)
21
+ @card_events = events.select(&:card?)
22
+ events
23
+ end
24
+
25
+ # Return a hash of data for the card that sourced the passed event. The data
26
+ # will be for the version of the card that was created by the event and not the
27
+ # current state of the card. Currently supported data keys are: :number, :version,
28
+ # :card_type_name
29
+ def for_card_event(card_event)
30
+ if @card_data_by_number_and_version.nil?
31
+ load_bulk_card_data
32
+ end
33
+ key = data_key(card_event.card_number, card_event.version)
34
+ @card_data_by_number_and_version[key] ||= load_card_data_for_event(card_event)
35
+ end
36
+
37
+ private
38
+
39
+ def data_key(number, version)
40
+ "#{number}:#{version}"
41
+ end
42
+
43
+ def load_bulk_card_data
44
+ @card_data_by_number_and_version = {}
45
+
46
+ # TODO: figure out max number of card numbers before we have to chunk this up. or does mingle
47
+ # figure out how to handle a too-large IN clause?
48
+ card_numbers = @card_events.map(&:card_number).uniq
49
+ path = "/api/v2/projects/#{@project_identifier}/cards/execute_mql.xml?mql=WHERE number IN (#{card_numbers.join(',')})"
50
+
51
+ raw_xml = @mingle_access.fetch_page(URI.escape(path))
52
+ doc = Nokogiri::XML(raw_xml)
53
+
54
+ doc.search('/results/result').map do |card_result|
55
+ card_number = card_result.at('number').inner_text.to_i
56
+ card_version = card_result.at('version').inner_text.to_i
57
+ custom_properties = {}
58
+ @card_data_by_number_and_version[data_key(card_number, card_version)] = {
59
+ :number => card_number,
60
+ :version => card_version,
61
+ :card_type_name => card_result.at('card_type_name').inner_text,
62
+ :custom_properties => custom_properties
63
+ }
64
+ card_result.children.each do |child|
65
+ if child.name.index("cp_") == 0
66
+ custom_properties[@custom_properties.property_name_for_column(child.name)] =
67
+ nullable_value_from_element(child)
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ def load_card_data_for_event(card_event)
74
+ begin
75
+ page_xml = @mingle_access.fetch_page(card_event.card_version_resource_uri)
76
+ doc = Nokogiri::XML(page_xml)
77
+ custom_properties = {}
78
+ result = {
79
+ :number => card_event.card_number,
80
+ :version => card_event.version,
81
+ :card_type_name => doc.at('/card/card_type/name').inner_text,
82
+ :custom_properties => custom_properties
83
+ }
84
+ doc.search('/card/properties/property').each do |property|
85
+ custom_properties[property.at('name').inner_text] =
86
+ nullable_value_from_element(property.at('value'))
87
+ end
88
+
89
+ result
90
+ rescue HttpError => httpError
91
+ raise httpError unless httpError.not_found?
92
+
93
+ end
94
+ end
95
+
96
+ def nullable_value_from_element(element)
97
+ element['nil'] == 'true' ? nil : element.inner_text
98
+ end
99
+ end
100
+
101
+ end
102
+ end