mingle_events 0.0.4

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.
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