mingle_events 0.0.4 → 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- data/README.textile +18 -1
- data/lib/mingle_events/feed/entry.rb +14 -0
- data/lib/mingle_events/mingle_basic_auth_access.rb +3 -4
- data/lib/mingle_events/mingle_oauth_access.rb +2 -2
- data/lib/mingle_events/poller.rb +1 -5
- data/lib/mingle_events/project_event_fetcher.rb +89 -32
- data/test/mingle_events/feed/entry_test.rb +28 -0
- data/test/mingle_events/project_event_fetcher_test.rb +36 -76
- metadata +3 -3
data/README.textile
CHANGED
@@ -114,4 +114,21 @@ Each event that is passed to the processor is an instance of type MingleEvents::
|
|
114
114
|
|
115
115
|
h2. Retry & Error handling
|
116
116
|
|
117
|
-
As of now, retry is not implemented. If an error occurs during event processing, the error will be logged and processing will stop. The next run will re-start at the point of the last error.
|
117
|
+
As of now, retry is not implemented. If an error occurs during event processing, the error will be logged and processing will stop. The next run will re-start at the point of the last error.
|
118
|
+
|
119
|
+
h2. Historical analysis
|
120
|
+
|
121
|
+
One of the great usages of event playback is historical analysis. E.g., if you wanted to see the number of stories added each day to your project, you could write a processor that counts new stories and takes a snapshot at the end of each day, where the end of the day is the first time you see an event for the next day. If you are doing this sort of analysis you will not want to poll the entire project's event history every time as that is quite time consuming.
|
122
|
+
|
123
|
+
It is possible to repeatedly run analysis against previously fetched events using the ProjectEventFetcher class that is only used in the internals of the standard polling mechanism. ProjectEventFetcher does most of the real work of reading new events from the server and making them available for local processing, so it's not a bad class to get to know.
|
124
|
+
|
125
|
+
<pre>
|
126
|
+
event_fetcher = MingleEvents::ProjectEventFetcher.new('mingle', mingle_access)
|
127
|
+
processor = MingleEvents::Processors::PutsPublisher.new
|
128
|
+
event_fetcher.all_fetched_entries.each do |e|
|
129
|
+
processor.process_events([e])
|
130
|
+
end
|
131
|
+
</pre>
|
132
|
+
|
133
|
+
That's not an ideal interface. It's a bit awkward to push a single event into an array for processing. We'll likely look to make this much better as we bring back batch processing of events.
|
134
|
+
|
@@ -72,6 +72,20 @@ module MingleEvents
|
|
72
72
|
def to_s
|
73
73
|
"Entry[entry_id=#{entry_id}, updated=#{updated}]"
|
74
74
|
end
|
75
|
+
|
76
|
+
def eql?(object)
|
77
|
+
if object.equal?(self)
|
78
|
+
return true
|
79
|
+
elsif !self.class.equal?(object.class)
|
80
|
+
return false
|
81
|
+
end
|
82
|
+
|
83
|
+
return object.entry_id == entry_id
|
84
|
+
end
|
85
|
+
|
86
|
+
def ==(object)
|
87
|
+
eql?(object)
|
88
|
+
end
|
75
89
|
|
76
90
|
private
|
77
91
|
|
@@ -24,7 +24,6 @@ WARNING!!
|
|
24
24
|
|
25
25
|
# Fetch the content at location via HTTP. Throws error if non-200 response.
|
26
26
|
def fetch_page(location)
|
27
|
-
MingleEvents.log.info("About to fetch #{location}...")
|
28
27
|
rsp = fetch_page_response(location)
|
29
28
|
case rsp
|
30
29
|
when Net::HTTPSuccess
|
@@ -51,18 +50,18 @@ See <http://www.thoughtworks-studios.com/mingle/3.3/help/configuring_mingle_auth
|
|
51
50
|
http.use_ssl = true
|
52
51
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
53
52
|
else
|
54
|
-
|
53
|
+
MingleEvents.log.warn BASIC_AUTH_HTTP_WARNING
|
55
54
|
end
|
56
55
|
|
57
56
|
path = uri.path
|
58
57
|
path += "?#{uri.query}" if uri.query
|
59
|
-
|
58
|
+
MingleEvents.log.info "Fetching page at #{path}..."
|
60
59
|
|
61
60
|
start = Time.now
|
62
61
|
req = Net::HTTP::Get.new(path)
|
63
62
|
req.basic_auth(@username, @password)
|
64
63
|
rsp = http.request(req)
|
65
|
-
|
64
|
+
MingleEvents.log.info "...#{path} fetched in #{Time.now - start} seconds."
|
66
65
|
|
67
66
|
rsp
|
68
67
|
end
|
@@ -24,11 +24,11 @@ module MingleEvents
|
|
24
24
|
|
25
25
|
path = uri.path
|
26
26
|
path += "?#{uri.query}" if uri.query
|
27
|
-
|
27
|
+
MingleEvents.log.info "Fetching page at #{path}..."
|
28
28
|
|
29
29
|
start = Time.now
|
30
30
|
response = http.get(path, headers)
|
31
|
-
|
31
|
+
MingleEvents.log.info "... #{path} fetched in #{Time.now - start} seconds."
|
32
32
|
|
33
33
|
# todo: what's the right way to raise on non 200 ?
|
34
34
|
# raise StandardError.new(response.body) unless response == Net::HTTPSuccess
|
data/lib/mingle_events/poller.rb
CHANGED
@@ -17,13 +17,9 @@ module MingleEvents
|
|
17
17
|
@processors_by_project_identifier.each do |project_identifier, processors|
|
18
18
|
fetcher = ProjectEventFetcher.new(project_identifier, @mingle_access)
|
19
19
|
fetcher.reset if options[:clean]
|
20
|
-
|
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'))
|
20
|
+
fetcher.fetch_latest.each do |entry|
|
24
21
|
MingleEvents.log.info("About to process event #{entry.entry_id}...")
|
25
22
|
processors.each{|p| p.process_events([entry])}
|
26
|
-
info_file_for_new_event = entry_info[:next_entry_file_path]
|
27
23
|
end
|
28
24
|
end
|
29
25
|
end
|
@@ -10,22 +10,24 @@ module MingleEvents
|
|
10
10
|
@state_dir = File.expand_path(state_dir || File.join('~', '.mingle_events', base_uri.host, base_uri.port.to_s, project_identifier, 'fetched_events'))
|
11
11
|
end
|
12
12
|
|
13
|
+
# blow away any existing state, when next used to fetch events from mingle
|
14
|
+
# will crawl all the way back to time zero
|
13
15
|
def reset
|
14
16
|
FileUtils.rm_rf(@state_dir)
|
15
17
|
end
|
16
18
|
|
19
|
+
# fetch the latest events from mingle, i.e., the ones not previously seen
|
17
20
|
def fetch_latest
|
18
21
|
page = Feed::Page.new("/api/v2/projects/#{@project_identifier}/feeds/events.xml", @mingle_access)
|
19
22
|
most_recent_new_entry = page.entries.first
|
20
|
-
|
21
23
|
last_fetched_entry = load_last_fetched_entry
|
22
|
-
|
24
|
+
last_fetched_entry_seen = false
|
23
25
|
next_entry = nil
|
24
|
-
while !
|
26
|
+
while !last_fetched_entry_seen && page
|
25
27
|
page.entries.each do |entry|
|
26
28
|
|
27
29
|
if last_fetched_entry && entry.entry_id == last_fetched_entry.entry_id
|
28
|
-
|
30
|
+
last_fetched_entry_seen = true
|
29
31
|
break
|
30
32
|
end
|
31
33
|
|
@@ -35,12 +37,56 @@ module MingleEvents
|
|
35
37
|
end
|
36
38
|
page = page.next
|
37
39
|
end
|
38
|
-
|
39
|
-
update_current_state(most_recent_new_entry)
|
40
|
-
|
41
|
-
|
40
|
+
|
41
|
+
update_current_state(next_entry, most_recent_new_entry)
|
42
|
+
Entries.new(file_for_entry(next_entry), file_for_entry(most_recent_new_entry))
|
43
|
+
end
|
44
|
+
|
45
|
+
# returns all previously fetched entries; can be used to reprocess the events for, say,
|
46
|
+
# various historical analyses
|
47
|
+
def all_fetched_entries
|
48
|
+
current_state = load_current_state
|
49
|
+
Entries.new(current_state[:first_fetched_entry_info_file], current_state[:last_fetched_entry_info_file])
|
50
|
+
end
|
51
|
+
|
52
|
+
def first_entry_fetched
|
53
|
+
current_state_entry(:first_fetched_entry_info_file)
|
54
|
+
end
|
55
|
+
|
56
|
+
def last_entry_fetched
|
57
|
+
current_state_entry(:last_fetched_entry_info_file)
|
58
|
+
end
|
59
|
+
|
60
|
+
# only public to facilitate testing
|
61
|
+
def update_current_state(oldest_new_entry, most_recent_new_entry)
|
62
|
+
current_state = load_current_state
|
63
|
+
if most_recent_new_entry
|
64
|
+
current_state.merge!(:last_fetched_entry_info_file => file_for_entry(most_recent_new_entry))
|
65
|
+
if current_state[:first_fetched_entry_info_file].nil?
|
66
|
+
current_state.merge!(:first_fetched_entry_info_file => file_for_entry(oldest_new_entry))
|
67
|
+
end
|
68
|
+
File.open(current_state_file, 'w'){|out| YAML.dump(current_state, out)}
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# only public to facilitate testing
|
73
|
+
def write_entry_to_disk(entry, next_entry)
|
74
|
+
file = file_for_entry(entry)
|
75
|
+
FileUtils.mkdir_p(File.dirname(file))
|
76
|
+
file_content = {:entry_xml => entry.raw_xml, :next_entry_file_path => file_for_entry(next_entry)}
|
77
|
+
File.open(file, 'w'){|out| YAML.dump(file_content, out)}
|
42
78
|
end
|
79
|
+
|
80
|
+
private
|
43
81
|
|
82
|
+
def current_state_entry(info_file_key)
|
83
|
+
if info_file = load_current_state[info_file_key]
|
84
|
+
entry_for_xml(YAML.load(File.new(info_file))[:entry_xml])
|
85
|
+
else
|
86
|
+
nil
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
44
90
|
def file_for_entry(entry)
|
45
91
|
return nil if entry.nil?
|
46
92
|
|
@@ -55,15 +101,9 @@ module MingleEvents
|
|
55
101
|
def current_state_file
|
56
102
|
File.expand_path(File.join(@state_dir, 'current_state.yml'))
|
57
103
|
end
|
58
|
-
|
59
|
-
private
|
60
104
|
|
61
105
|
def load_last_fetched_entry
|
62
|
-
current_state =
|
63
|
-
YAML.load(File.new(current_state_file))
|
64
|
-
else
|
65
|
-
{:last_fetched_entry_info_file => nil}
|
66
|
-
end
|
106
|
+
current_state = load_current_state
|
67
107
|
last_fetched_entry = if current_state[:last_fetched_entry_info_file]
|
68
108
|
last_fetched_entry_info = YAML.load(File.new(current_state[:last_fetched_entry_info_file]))
|
69
109
|
Feed::Entry.new(Nokogiri::XML(last_fetched_entry_info[:entry_xml]).at('/entry'))
|
@@ -71,26 +111,43 @@ module MingleEvents
|
|
71
111
|
nil
|
72
112
|
end
|
73
113
|
end
|
74
|
-
|
75
|
-
def update_current_state(most_recent_new_entry)
|
76
|
-
if most_recent_new_entry
|
77
|
-
File.open(current_state_file, 'w') do |out|
|
78
|
-
YAML.dump({:last_fetched_entry_info_file => file_for_entry(most_recent_new_entry)}, out)
|
79
|
-
end
|
80
|
-
end
|
81
|
-
end
|
82
114
|
|
83
|
-
def
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
:
|
88
|
-
:next_entry_file_path => file_for_entry(next_entry)
|
89
|
-
}
|
90
|
-
File.open(file, 'w') do |out|
|
91
|
-
YAML.dump(file_content, out)
|
115
|
+
def load_current_state
|
116
|
+
if File.exist?(current_state_file)
|
117
|
+
YAML.load(File.new(current_state_file))
|
118
|
+
else
|
119
|
+
{:last_fetched_entry_info_file => nil, :first_fetched_entry_info_file => nil}
|
92
120
|
end
|
93
121
|
end
|
94
122
|
|
123
|
+
def entry_for_xml(entry_xml)
|
124
|
+
Feed::Entry.new(Nokogiri::XML(entry_xml).at('/entry'))
|
125
|
+
end
|
126
|
+
|
127
|
+
class Entries
|
128
|
+
|
129
|
+
include Enumerable
|
130
|
+
|
131
|
+
def initialize(first_info_file, last_info_file)
|
132
|
+
@first_info_file = first_info_file
|
133
|
+
@last_info_file = last_info_file
|
134
|
+
end
|
135
|
+
|
136
|
+
def entry_for_xml(entry_xml)
|
137
|
+
Feed::Entry.new(Nokogiri::XML(entry_xml).at('/entry'))
|
138
|
+
end
|
139
|
+
|
140
|
+
def each(&block)
|
141
|
+
current_file = @first_info_file
|
142
|
+
while current_file
|
143
|
+
current_entry_info = YAML.load(File.new(current_file))
|
144
|
+
yield(entry_for_xml(current_entry_info[:entry_xml]))
|
145
|
+
break if File.expand_path(current_file) == File.expand_path(@last_info_file)
|
146
|
+
current_file = current_entry_info[:next_entry_file_path]
|
147
|
+
end
|
148
|
+
end
|
149
|
+
|
150
|
+
end
|
151
|
+
|
95
152
|
end
|
96
153
|
end
|
@@ -134,6 +134,34 @@ module MingleEvents
|
|
134
134
|
assert_equal(entry.entry_id, entry.event_id)
|
135
135
|
end
|
136
136
|
|
137
|
+
def test_entry_id_determines_equality
|
138
|
+
element_xml_text_1 = %{
|
139
|
+
<entry xmlns:mingle="http://www.thoughtworks-studios.com/ns/mingle">
|
140
|
+
<id>https://mingle.example.com/projects/mingle/events/index/234443</id>
|
141
|
+
<category term="page" scheme="http://www.thoughtworks-studios.com/ns/mingle#categories"/>
|
142
|
+
</entry>}
|
143
|
+
entry_1 = Entry.new(Nokogiri::XML(element_xml_text_1))
|
144
|
+
|
145
|
+
element_xml_text_2 = %{
|
146
|
+
<entry xmlns:mingle="http://www.thoughtworks-studios.com/ns/mingle">
|
147
|
+
<id>https://mingle.example.com/projects/mingle/events/index/234443</id>
|
148
|
+
<category term="card" scheme="http://www.thoughtworks-studios.com/ns/mingle#categories"/>
|
149
|
+
</entry>}
|
150
|
+
entry_2 = Entry.new(Nokogiri::XML(element_xml_text_2))
|
151
|
+
|
152
|
+
element_xml_text_3 = %{
|
153
|
+
<entry xmlns:mingle="http://www.thoughtworks-studios.com/ns/mingle">
|
154
|
+
<id>https://mingle.example.com/projects/mingle/events/index/234</id>
|
155
|
+
<category term="card" scheme="http://www.thoughtworks-studios.com/ns/mingle#categories"/>
|
156
|
+
</entry>}
|
157
|
+
entry_3 = Entry.new(Nokogiri::XML(element_xml_text_3))
|
158
|
+
|
159
|
+
assert entry_1.eql?(entry_2)
|
160
|
+
assert entry_1 == entry_2
|
161
|
+
assert !entry_2.eql?(entry_3)
|
162
|
+
assert entry_2 != entry_3
|
163
|
+
end
|
164
|
+
|
137
165
|
end
|
138
166
|
|
139
167
|
end
|
@@ -7,60 +7,34 @@ module MingleEvents
|
|
7
7
|
state_dir = temp_dir
|
8
8
|
fetcher = ProjectEventFetcher.new('atlas', stub_mingle_access, state_dir)
|
9
9
|
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
'https://mingle.example.com/projects/atlas/events/index/98',
|
16
|
-
'https://mingle.example.com/projects/atlas/events/index/99',
|
17
|
-
'https://mingle.example.com/projects/atlas/events/index/100',
|
18
|
-
'https://mingle.example.com/projects/atlas/events/index/101',
|
19
|
-
'https://mingle.example.com/projects/atlas/events/index/103'
|
20
|
-
]
|
21
|
-
|
22
|
-
assert_expected_entry_chain_written_to_disk(expected_entry_ids, file_for_first_new_entry, fetcher)
|
10
|
+
latest_entries = fetcher.fetch_latest
|
11
|
+
expected_latest_entries = [23, 97, 98, 99, 100, 101, 103].map{|n| entry(n)}
|
12
|
+
assert_equal(expected_latest_entries, latest_entries.to_a)
|
13
|
+
assert_equal entry(23), fetcher.first_entry_fetched
|
14
|
+
assert_equal entry(103), fetcher.last_entry_fetched
|
23
15
|
end
|
24
16
|
|
25
17
|
def test_can_fetch_all_entries_and_write_to_disk_when_existing_state
|
26
18
|
state_dir = temp_dir
|
27
19
|
fetcher = ProjectEventFetcher.new('atlas', stub_mingle_access, state_dir)
|
28
|
-
setup_current_state(%{
|
29
|
-
<entry>
|
30
|
-
<id>https://mingle.example.com/projects/atlas/events/index/97</id>
|
31
|
-
<title>entry 97</title>
|
32
|
-
<updated>2011-02-03T01:10:52Z</updated>
|
33
|
-
<author><name>Harry</name></author>
|
34
|
-
</entry>}, fetcher)
|
35
20
|
|
36
|
-
|
21
|
+
setup_current_state(23, 97, 97, fetcher)
|
37
22
|
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
'https://mingle.example.com/projects/atlas/events/index/103'
|
44
|
-
]
|
45
|
-
|
46
|
-
assert_expected_entry_chain_written_to_disk(expected_entry_ids, file_for_first_new_entry, fetcher)
|
23
|
+
latest_entries = fetcher.fetch_latest
|
24
|
+
expected_latest_entries = [98, 99, 100, 101, 103].map{|n| entry(n)}
|
25
|
+
assert_equal(expected_latest_entries, latest_entries.to_a)
|
26
|
+
assert_equal entry(23), fetcher.first_entry_fetched
|
27
|
+
assert_equal entry(103), fetcher.last_entry_fetched
|
47
28
|
end
|
48
29
|
|
49
30
|
def test_no_new_entries_with_current_state
|
50
31
|
state_dir = temp_dir
|
51
32
|
fetcher = ProjectEventFetcher.new('atlas', stub_mingle_access, state_dir)
|
52
|
-
setup_current_state(
|
53
|
-
<entry>
|
54
|
-
<id>https://mingle.example.com/projects/atlas/events/index/103</id>
|
55
|
-
<title>entry 103</title>
|
56
|
-
<updated>2011-02-03T01:10:52Z</updated>
|
57
|
-
<author><name>Harry</name></author>
|
58
|
-
</entry>}, fetcher)
|
33
|
+
setup_current_state(23, 97, 103, fetcher)
|
59
34
|
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
assert_equal(@last_fetched_entry_info_file, current_state[:last_fetched_entry_info_file])
|
35
|
+
assert fetcher.fetch_latest.to_a.empty?
|
36
|
+
assert_equal entry(23), fetcher.first_entry_fetched
|
37
|
+
assert_equal entry(103), fetcher.last_entry_fetched
|
64
38
|
end
|
65
39
|
|
66
40
|
def test_no_new_entries_with_no_current_state
|
@@ -77,46 +51,32 @@ module MingleEvents
|
|
77
51
|
</feed>})
|
78
52
|
fetcher = ProjectEventFetcher.new('atlas', mingle_access, state_dir)
|
79
53
|
|
80
|
-
|
81
|
-
|
54
|
+
assert fetcher.fetch_latest.to_a.empty?
|
55
|
+
assert_nil fetcher.first_entry_fetched
|
56
|
+
assert_nil fetcher.last_entry_fetched
|
82
57
|
end
|
83
58
|
|
84
59
|
private
|
85
60
|
|
86
|
-
def setup_current_state(
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
File.open(@last_fetched_entry_info_file, 'w') do |out|
|
94
|
-
YAML.dump(last_fetched_entry_info, out)
|
95
|
-
end
|
96
|
-
current_state = {:last_fetched_entry_info_file => @last_fetched_entry_info_file}
|
97
|
-
File.open(fetcher.current_state_file, 'w') do |out|
|
98
|
-
YAML.dump(current_state, out)
|
99
|
-
end
|
100
|
-
|
61
|
+
def setup_current_state(first_entry_id, second_entry_id, last_entry_id, fetcher)
|
62
|
+
first_entry = entry(first_entry_id)
|
63
|
+
second_entry = entry(second_entry_id)
|
64
|
+
last_entry = entry(last_entry_id)
|
65
|
+
fetcher.write_entry_to_disk(first_entry, second_entry)
|
66
|
+
fetcher.write_entry_to_disk(last_entry, nil)
|
67
|
+
fetcher.update_current_state(first_entry, last_entry)
|
101
68
|
end
|
102
|
-
|
103
|
-
def
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
end
|
114
|
-
|
115
|
-
current_state = YAML.load(File.new(fetcher.current_state_file))
|
116
|
-
assert_equal(file_for_last_inspected_entry, current_state[:last_fetched_entry_info_file])
|
117
|
-
|
69
|
+
|
70
|
+
def entry(entry_id)
|
71
|
+
entry_xml = %{
|
72
|
+
<entry>
|
73
|
+
<id>https://mingle.example.com/projects/atlas/events/index/#{entry_id}</id>
|
74
|
+
<title>entry #{entry_id}</title>
|
75
|
+
<updated>2011-02-03T01:10:52Z</updated>
|
76
|
+
<author><name>Bob</name></author>
|
77
|
+
</entry>
|
78
|
+
}
|
79
|
+
Feed::Entry.new(Nokogiri::XML(entry_xml).at('/entry'))
|
118
80
|
end
|
119
|
-
|
120
|
-
|
121
81
|
end
|
122
82
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: mingle_events
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash:
|
4
|
+
hash: 19
|
5
5
|
prerelease:
|
6
6
|
segments:
|
7
7
|
- 0
|
8
8
|
- 0
|
9
|
-
-
|
10
|
-
version: 0.0.
|
9
|
+
- 6
|
10
|
+
version: 0.0.6
|
11
11
|
platform: ruby
|
12
12
|
authors:
|
13
13
|
- David Rice
|