vanity-source 0.6 → 0.7

Sign up to get free protection for your applications and to get access to all the features.
data/CHANGELOG CHANGED
@@ -1,3 +1,19 @@
1
+ 2010-08-24 v0.7 Added test suite and resources
2
+
3
+ Each source can have an associated resources file (YAML), e.g. ruby_gems.rb
4
+ would have ruby_gems.yml. The default implementation of methods like name,
5
+ description and display pull content from the resource. Resources can support
6
+ multiple languages, but we're starting with just EN.
7
+
8
+ Some sources also need API keys. These are accessed using the method api_key
9
+ that returns the API key value for the current source (often a string, but can
10
+ also be hash or array). There's an api_keys.yml file which contains fake API
11
+ keys. You can put a real key in there while generating a cassette for your test
12
+ case.
13
+
14
+ Test suite is here, using WebMock and VCR to rest API calls. Cassettes go in
15
+ test/cassettes, fixtures in test/fixtures.
16
+
1
17
  2010-08-19 v0.6 Added Backtweet
2
18
 
3
19
  2010-08-19 v0.5 Added Github and Github issues
data/Gemfile CHANGED
@@ -7,6 +7,8 @@ group :development do
7
7
  end
8
8
 
9
9
  group :test do
10
- gem "rack", "1.1.0"
10
+ gem "shoulda"
11
+ gem "timecop"
12
+ gem "vcr"
11
13
  gem "webmock"
12
14
  end
data/Rakefile CHANGED
@@ -1,4 +1,5 @@
1
1
  require "rake/testtask"
2
+ require "shoulda/tasks"
2
3
 
3
4
  # -- Building stuff --
4
5
 
@@ -25,4 +26,10 @@ task :push=>["test", "build"] do
25
26
  sh "gem push #{spec.name}-#{spec.version}.gem"
26
27
  end
27
28
 
28
- task :test
29
+
30
+ task :default=>:test
31
+ Rake::TestTask.new do |task|
32
+ task.test_files = FileList['test/**/*_test.rb']
33
+ #task.warning = true
34
+ task.verbose = true
35
+ end
data/lib/vanity/source.rb CHANGED
@@ -1,5 +1,8 @@
1
- require "uri"
1
+ require "active_support"
2
+ require "json"
2
3
  require "net/http"
4
+ require "rack"
5
+ require "uri"
3
6
 
4
7
  module Vanity
5
8
 
@@ -108,28 +111,49 @@ module Vanity
108
111
  logger.info "Loaded source #{id}: #{klass}"
109
112
  end
110
113
  end
114
+
115
+ # API keys (see instance method api_key).
116
+ attr_accessor :api_keys
117
+
118
+ def included(klass)
119
+ klass.extend ClassMethods
120
+ end
111
121
  end
112
122
 
123
+
124
+ module ClassMethods
125
+ # Source identifier.
126
+ def source_id
127
+ @source_id ||= name.demodulize.underscore
128
+ end
129
+ end
130
+
131
+
113
132
  # Returns the display name for this source.
114
133
  #
115
- # Default implementation uses the class name, e.g Source::OneUp becomes "One
116
- # Up".
134
+ # Uses the resource 'name', and fallbacks on the class name (e.g
135
+ # Source::OneUp becomes "One Up").
117
136
  def name
118
- self.class.name.demodulize.titleize
137
+ resources["name"] || source_id.titleize
119
138
  end
120
139
 
121
140
  # Returns additional information about this source.
122
141
  #
123
142
  # A good description helps the user find this source and decide whether or
124
143
  # not to use it.
144
+ #
145
+ # Uses the resource 'description'.
125
146
  def description
147
+ resources["description"]
126
148
  end
127
149
 
128
150
  # This method returns a hash with two values:
129
151
  # * inputs -- HTML fragment for a setup form
130
152
  # * notes -- HTML fragment for setup notes
153
+ #
154
+ # Uses the resources 'inputs' and 'notes'.
131
155
  def display
132
- {}
156
+ { :inputs=>resources["inputs"], :notes=>resources["notes"] }
133
157
  end
134
158
 
135
159
  # Called to setup a new collector with parameters from the HTML form (see
@@ -168,9 +192,31 @@ module Vanity
168
192
  []
169
193
  end
170
194
 
195
+ protected
196
+
197
+ # Source identifier.
198
+ def source_id
199
+ self.class.source_id
200
+ end
201
+
171
202
  # Logger. Dump messages that can help with troubleshooting.
172
203
  def logger
173
- self.class.logger
204
+ Vanity::Source.logger
205
+ end
206
+
207
+ # Returns I18n resources for this gem.
208
+ def resources
209
+ unless @resources
210
+ file_name = File.dirname(__FILE__) + "/sources/#{source_id}.yml"
211
+ @resources = File.exist?(file_name) ? YAML.load_file(file_name)["en"] : {}
212
+ end
213
+ @resources
214
+ end
215
+
216
+ # Returns API key for this source. May return string or hash, depending on
217
+ # the API.
218
+ def api_key
219
+ @api_key ||= Vanity::Source.api_keys[source_id] or raise "No API key for #{source_id}"
174
220
  end
175
221
  end
176
222
  end
@@ -4,20 +4,8 @@ module Vanity
4
4
  class Backtweets
5
5
  include Vanity::Source
6
6
 
7
- INPUTS = <<-HTML
8
- <label>URL <input type="text" name="source[url]" size="30"></label>
9
- HTML
10
-
11
- def description
12
- "Finds Tweets for any URL"
13
- end
14
-
15
- def display
16
- { :inputs=>INPUTS }
17
- end
18
-
19
7
  def setup(context, params)
20
- url = params["url"].strip
8
+ url = params["url"].strip.downcase.sub(/^http(s?):\/\//, "")
21
9
  context["metric.name"] = "Tweets for #{url}"
22
10
  context["metric.columns"] = [{ :id=>"tweets", :label=>"Tweets" }]
23
11
  context["metric.totals"] = true
@@ -30,14 +18,31 @@ module Vanity
30
18
 
31
19
  def update(context, webhook, &block)
32
20
  Net::HTTP.start "backtweets.com" do |http|
33
- get = Net::HTTP::Get.new("/search.json?key=#{::API["backtweet"]}&q=#{URI.escape context["url"]}")
34
- case response = http.request(get)
35
- when Net::HTTPOK
36
- json = JSON.parse(response.body)
37
- block.call :set=>{ :tweets=>json["totalresults"] }
38
- else
39
- raise "#{response.code}: #{response.message}"
21
+ get = Net::HTTP::Get.new("/search.json?key=#{api_key}&q=#{Rack::Utils.escape context["url"]}")
22
+ response = http.request(get)
23
+ json = JSON.parse(response.body) rescue nil if Net::HTTPOK === response
24
+ unless json
25
+ logger.error "Backtweets: #{response.code} #{response.message}"
26
+ raise "Last request didn't go as expected, trying again later"
27
+ end
28
+
29
+ if tweets = json["tweets"]
30
+ last_tweet_id = context["last_tweet_id"]
31
+ if last_tweet_id
32
+ new_ones = tweets.take_while { |tweet| tweet["tweet_id"] != last_tweet_id }.reverse
33
+ new_ones.each do |tweet|
34
+ handle, id = tweet["tweet_from_user"], tweet["tweet_id"]
35
+ block.call :activity=>{ :id=>id, :body=>tweet["tweet_text"], :url=>"http://twitter.com/#{handle}/#{id}",
36
+ :timestamp=>Time.parse(tweet["tweet_created_at"]).utc,
37
+ :person=>{ :name=>handle, :url=>"http://twitter.com/#{handle}", :photo=>tweet["tweet_profile_image_url"] } }
38
+ end
39
+ end
40
+ if recent = tweets.first
41
+ context["last_tweet_id"] = recent["tweet_id"]
42
+ end
40
43
  end
44
+
45
+ block.call :set=>{ :tweets=>json["totalresults"].to_i }
41
46
  end
42
47
  end
43
48
 
@@ -0,0 +1,9 @@
1
+ en:
2
+ description: "Finds Tweets linking to any URL"
3
+ inputs: |-
4
+ <label>URL <input type="text" name="source[url]" size="30"></label>
5
+ notes: |-
6
+ <ul>
7
+ <li>If this is a new search, it may take some time before we get the first results (including historical data). Check back in 8 hours.</li>
8
+ <li>Data provided by <a href="http://backtweets.com">Backtweets</a></li>
9
+ </ul>
@@ -4,45 +4,21 @@ module Vanity
4
4
  class Github
5
5
  include Vanity::Source
6
6
 
7
- INPUTS = <<-HTML
8
- <label>User/repository <input type="text" name="source[repo]" size="30"></label>
9
- <label>Branch <input type="text" name="source[branch]" size="30" value="master"></label>
10
- <p>For private repositories:</p>
11
- <label>Username <input type="text" name="source[username]" size="30"></label>
12
- <label><a href="https://github.com/account#admin_bucket" target="github">API Token</a> <input type="password" name="source[api_token]" size="30"></label>
13
- HTML
14
-
15
- NOTES = <<-HTML
16
- <ul>
17
- <li>Enter user/repository like this: <code>assaf/vanity</code></li>
18
- <li>To access private repositories, authenticate using your username and API token.
19
- You can find your API token in <a href="https://github.com/account#admin_bucket" target="github">Account Settings</a>.
20
- </li>
21
- </ul>
22
- HTML
23
-
24
- def description
25
- "Tracks Github commits, watchers and forks"
26
- end
27
-
28
- def display
29
- { :inputs=>INPUTS, :notes=>NOTES }
30
- end
31
-
32
7
  def setup(context, params)
33
8
  repo = params["repo"].strip
34
9
  context["metric.name"] = "Github: #{repo}"
35
- context["metric.columns"] = [{ :id=>"commits", :label=>"Commits" }, { :id=>"watchers", :label=>"Watchers" }, { :id=>"forks", :label=>"forks" }]
10
+ context["metric.columns"] = [{ :id=>"commits", :label=>"Commits" }, { :id=>"watchers", :label=>"Watchers" }, { :id=>"forks", :label=>"Forks" }]
36
11
  context["metric.totals"] = true
37
12
  context["repo"] = repo
38
- context["branch"] = params["branch"].strip
13
+ branch = params["branch"].strip
14
+ context["branch"] = branch.blank? ? "master" : branch
39
15
  context["username"] = params["username"]
40
16
  context["api_token"] = params["api_token"]
41
17
  end
42
18
 
43
19
  def validate(context)
44
- raise "Missing user/repository" if context["repo"].blank?
45
- raise "Missing branch name" if context["branch"].blank?
20
+ raise "Missing repository name" if context["repo"].blank?
21
+ raise "Need user name and repository name, e.g. assaf/vanity" unless context["repo"][/^[\w-]+\/[\w-]+$/]
46
22
  end
47
23
 
48
24
  def update(context, webhook, &block)
@@ -51,37 +27,52 @@ module Vanity
51
27
  http.start do
52
28
  case response = request(http, context, "/api/v2/json/repos/show/:repo")
53
29
  when Net::HTTPOK
54
- json = JSON.parse(response.body)["repository"]
55
- forks = json["forks"]
56
- watchers = json["watchers"]
57
- block.call :set=>{ :forks=>forks, :watchers=>watchers }
58
- context.update json.slice(*%w{description url homepage})
30
+ json = JSON.parse(response.body)["repository"] rescue nil
59
31
  when Net::HTTPNotFound, Net::HTTPBadRequest
60
- raise "Could not find the repository \"#{context["repo"]}\""
32
+ raise "Could not find the repository #{context["repo"]}"
61
33
  when Net::HTTPUnauthorized
62
34
  raise "You are not authorized to access this repository, or invalid username/password"
35
+ end
36
+ if json
37
+ context.update json.slice(*%w{description url homepage})
38
+ block.call :set=>{ :forks=>json["forks"], :watchers=>json["watchers"] }
63
39
  else
64
- raise "#{response.code}: #{response.message}"
40
+ logger.error "Backtweets: #{response.code} #{response.message}"
41
+ error = true
65
42
  end
66
43
 
67
44
  case response = request(http, context, "/api/v2/json/commits/list/:repo/:branch")
68
45
  when Net::HTTPOK
69
- commits = JSON.parse(response.body)["commits"]
70
- if context["last_commit_at"]
71
- since = commits.count { |commit| commit["committed_date"] > context["last_commit_at"] }
72
- block.call :inc=>{ :commits=>since }
46
+ commits = JSON.parse(response.body)["commits"] rescue nil
47
+ when Net::HTTPNotFound, Net::HTTPBadRequest
48
+ raise "Could not find the branch #{context["branch"]}"
49
+ end
50
+
51
+ if commits
52
+ last_seen_id = context["last_commit"]["id"] if context["last_commit"]
53
+ if last_seen_id
54
+ new_ones = commits.take_while { |commit| commit["id"] != last_seen_id }
55
+ new_ones.reverse.each do |commit|
56
+ committer = commit["committer"]
57
+ block.call :activity=>{ :id=>commit["id"], :body=>commit["message"], :url=>commit["url"],
58
+ :timestamp=>Time.parse(commit["committed_date"]).utc,
59
+ :person=>{ :name=>committer["name"], :url=>"http://github.com/#{committer["name"]}",
60
+ :email=>committer["email"] } }
61
+ end
62
+ block.call :inc=>{ :commits=>new_ones.count }
73
63
  else
74
64
  block.call :set=>{ :commits=>commits.count }
75
65
  end
76
66
  if last_commit = commits.first
77
- context["last_commit"] = last_commit["message"]
78
- context["last_commit_url"] = last_commit["url"]
79
- context["last_commit_at"] = last_commit["committed_date"]
67
+ context.update "last_commit"=>last_commit.slice("id", "message", "url").
68
+ merge("timestamp"=>Time.parse(last_commit["committed_date"]).utc)
80
69
  end
81
- when Net::HTTPNotFound, Net::HTTPBadRequest
82
- raise "Could not find the branch \"#{context["branch"]}\""
70
+ else
71
+ logger.error "Backtweets: #{response.code} #{response.message}"
72
+ error = true
83
73
  end
84
74
 
75
+ raise "Last request didn't go as expected, trying again later" if error
85
76
  end
86
77
  end
87
78
 
@@ -92,10 +83,14 @@ module Vanity
92
83
  end
93
84
 
94
85
  def meta(context)
95
- [ { :title=>"Repository", :text=>context["repo"], :url=>context["url"] },
96
- { :text=>context["description"] },
97
- { :title=>"Home page", :url=>context["homepage"] },
98
- { :title=>"Commit", :text=>context["last_commit"], :url=>context["last_commit_url"] }]
86
+ meta = [ { :title=>"Repository", :text=>context["repo"], :url=>context["url"] },
87
+ { :title=>"Branch", :text=>context["branch"] },
88
+ { :text=>context["description"] },
89
+ { :title=>"Home page", :url=>context["homepage"] } ]
90
+ if last_commit = context["last_commit"]
91
+ meta << { :title=>"Commit", :text=>last_commit["message"], :url=>last_commit["url"] }
92
+ end
93
+ meta
99
94
  end
100
95
  end
101
96
  end
@@ -0,0 +1,15 @@
1
+ en:
2
+ description: "Tracks Github commits, watchers and forks"
3
+ inputs: |-
4
+ <label>User/repository <input type="text" name="source[repo]" size="30"></label>
5
+ <label>Branch <input type="text" name="source[branch]" size="30" value="master"></label>
6
+ <p>For private repositories:</p>
7
+ <label>Username <input type="text" name="source[username]" size="30"></label>
8
+ <label><a href="https://github.com/account#admin_bucket" target="github">API Token</a> <input type="password" name="source[api_token]" size="30"></label>
9
+ notes: |-
10
+ <ul>
11
+ <li>Enter user name/repository like this: <code>assaf/vanity</code></li>
12
+ <li>To access private repositories, authenticate using your username and API token.
13
+ You can find your API token in <a href="https://github.com/account#admin_bucket" target="github">Account Settings</a>.
14
+ </li>
15
+ </ul>
@@ -4,34 +4,10 @@ module Vanity
4
4
  class GithubIssues
5
5
  include Vanity::Source
6
6
 
7
- INPUTS = <<-HTML
8
- <label>User/repository <input type="text" name="source[repo]" size="30"></label>
9
- <p>For private repositories:</p>
10
- <label>Username <input type="text" name="source[username]" size="30"></label>
11
- <label><a href="https://github.com/account#admin_bucket" target="github">API Token</a> <input type="password" name="source[api_token]" size="30"></label>
12
- HTML
13
-
14
- NOTES = <<-HTML
15
- <ul>
16
- <li>Enter user/repository like this: <code>assaf/vanity</code></li>
17
- <li>To access private repositories, authenticate using your username and API token.
18
- You can find your API token in <a href="https://github.com/account#admin_bucket" target="github">Account Settings</a>.
19
- </li>
20
- </ul>
21
- HTML
22
-
23
- def description
24
- "Tracks open/closed Github issues"
25
- end
26
-
27
- def display
28
- { :inputs=>INPUTS, :notes=>NOTES }
29
- end
30
-
31
7
  def setup(context, params)
32
8
  repo = params["repo"].to_s.strip
33
9
  context["metric.name"] = "Github Issues for #{repo}"
34
- context["metric.columns"] = [{ :id=>"open", :label=>"Opened" }, { :id=>"closed", :label=>"Closed" }]
10
+ context["metric.columns"] = [{ :id=>"open", :label=>"Open issues" }, { :id=>"closed", :label=>"Closed issues" }]
35
11
  context["metric.totals"] = true
36
12
  context["repo"] = repo
37
13
  context["username"] = params["username"]
@@ -40,27 +16,54 @@ module Vanity
40
16
 
41
17
  def validate(context)
42
18
  raise "Missing user/repository" if context["repo"].blank?
19
+ raise "Need user name and repository name, e.g. assaf/vanity" unless context["repo"][/^[\w-]+\/[\w-]+$/]
43
20
  end
44
21
 
45
22
  def update(context, webhook, &block)
46
23
  http = Net::HTTP.new("github.com", 443)
47
24
  http.use_ssl = true
48
25
  http.start do
26
+ update = {}
49
27
  case response = request(http, context, "open")
50
28
  when Net::HTTPOK
51
- open = JSON.parse(response.body)["issues"].length
29
+ open = JSON.parse(response.body)["issues"] rescue nil
52
30
  when Net::HTTPNotFound, Net::HTTPBadRequest
53
- raise "Could not find the repository \"#{context["repo"]}\""
31
+ raise "Could not find the repository #{context["repo"]}"
54
32
  when Net::HTTPUnauthorized
55
33
  raise "You are not authorized to access this repository, or invalid username/password"
56
34
  end
35
+ if open
36
+ update[:open] = open.count
37
+ if open_ids = context["open-ids"]
38
+ open.reject { |issue| open_ids.include?(issue["number"]) }.reverse.each do |issue|
39
+ sha = Digest::SHA1.hexdigest([context["repo"], "open", issue["number"], issue["updated_at"]].join(":"))
40
+ block.call :activity=>{ :id=>sha, :body=>"New issue opened: #{issue["title"]}",
41
+ :url=>"http://github.com/#{context["repo"]}/issues#issue/#{issue["number"]}",
42
+ :timestamp=>Time.parse(issue["created_at"]).utc }
43
+ end
44
+ end
45
+ context["open-ids"] = open.map { |issue| issue["number"] }
46
+ end
57
47
 
58
48
  case response = request(http, context, "closed")
59
49
  when Net::HTTPOK
60
- closed = JSON.parse(response.body)["issues"].length
50
+ closed = JSON.parse(response.body)["issues"] rescue nil
51
+ end
52
+ if closed
53
+ update[:closed] = closed.count
54
+ if closed_ids = context["closed-ids"]
55
+ closed.reject { |issue| closed_ids.include?(issue["number"]) }.reverse.each do |issue|
56
+ sha = Digest::SHA1.hexdigest([context["repo"], "closed", issue["number"], issue["updated_at"]].join(":"))
57
+ block.call :activity=>{ :id=>sha, :body=>"Issue closed: #{issue["title"]}",
58
+ :url=>"http://github.com/#{context["repo"]}/issues#issue/#{issue["number"]}",
59
+ :timestamp=>Time.parse(issue["closed_at"]).utc }
60
+ end
61
+ end
62
+ context["closed-ids"] = closed.map { |issue| issue["number"] }
61
63
  end
62
64
 
63
- block.call :set=>{ :open=>open, :closed=>closed } if open || closed
65
+ raise "Last request didn't go as expected, trying again later" if update.empty?
66
+ block.call :set=>update
64
67
  end
65
68
  end
66
69
 
@@ -0,0 +1,14 @@
1
+ en:
2
+ description: "Tracks open/closed Github issues"
3
+ inputs: |-
4
+ <label>User/repository <input type="text" name="source[repo]" size="30"></label>
5
+ <p>For private repositories:</p>
6
+ <label>Username <input type="text" name="source[username]" size="30"></label>
7
+ <label><a href="https://github.com/account#admin_bucket" target="github">API Token</a> <input type="password" name="source[api_token]" size="30"></label>
8
+ notes: |-
9
+ <ul>
10
+ <li>Enter user name/repository like this: <code>assaf/vanity</code></li>
11
+ <li>To access private repositories, authenticate using your username and API token.
12
+ You can find your API token in <a href="https://github.com/account#admin_bucket" target="github">Account Settings</a>.
13
+ </li>
14
+ </ul>
@@ -4,22 +4,6 @@ module Vanity
4
4
  class RubyGems
5
5
  include Vanity::Source
6
6
 
7
- INPUTS = <<-HTML
8
- <label>Gem name <input type="text" name="source[gem_name]" size="30"></label>
9
- HTML
10
-
11
- NOTES = <<-HTML
12
- Gem names are case sensitive
13
- HTML
14
-
15
- def description
16
- "Tracks activity (downloads and releases) of a Ruby Gem from rubygems.org"
17
- end
18
-
19
- def display
20
- { :inputs=>INPUTS, :notes=>NOTES }
21
- end
22
-
23
7
  def setup(context, params)
24
8
  gem_name = params["gem_name"].to_s.strip
25
9
  context["metric.name"] = "RubyGems: #{gem_name}"
@@ -33,16 +17,29 @@ module Vanity
33
17
  end
34
18
 
35
19
  def update(context, webhook)
36
- uri = URI.parse("http://rubygems.org/api/v1/gems/#{URI.escape context["gem_name"]}.json")
20
+ gem_name = context["gem_name"]
21
+ uri = URI.parse("http://rubygems.org/api/v1/gems/#{Rack::Utils.escape gem_name}.json")
37
22
  case response = Net::HTTP.get_response(uri)
38
23
  when Net::HTTPOK
39
- json = JSON.parse(response.body)
40
- context["version"] = json["version"]
41
- context.update json.slice(*%w{homepage_uri project_uri info authors info})
42
- yield :set=>{ :downloads=>json["downloads"] }
24
+ json = JSON.parse(response.body) rescue nil
43
25
  when Net::HTTPNotFound
44
- raise "Could not find the Gem \"#{context["gem_name"]}\""
26
+ raise "Could not find the Gem #{gem_name}"
27
+ end
28
+ unless json
29
+ logger.error "RubyGems: #{response.code} #{response.message}"
30
+ raise "Last request didn't go as expected, trying again later"
31
+ end
32
+
33
+ if context["version"]
34
+ current, latest = Gem::Version.new(context["version"]), Gem::Version.new(json["version"])
35
+ if latest > current
36
+ yield :activity=>{ :id=>"#{gem_name}-#{latest}", :url=>"http://rubygems.org/gems/#{gem_name}",
37
+ :body=>"Released <a href=\"http://rubygems.org/gems/#{gem_name}/versions/#{latest}\">#{gem_name} version #{latest}</a>" }
38
+ end
45
39
  end
40
+ context["version"] = json["version"]
41
+ context.update json.slice(*%w{homepage_uri project_uri info authors info})
42
+ yield :set=>{ :downloads=>json["downloads"] }
46
43
  end
47
44
 
48
45
  def meta(context)
@@ -0,0 +1,6 @@
1
+ en:
2
+ description: "Tracks downloads and releases of a Ruby Gem on rubygems.org"
3
+ inputs: |-
4
+ <label>Gem name <input type="text" name="source[gem_name]" size="30"></label>
5
+ notes: |-
6
+ Gem names are case sensitive.
data/test/api_keys.yml ADDED
@@ -0,0 +1 @@
1
+ backtweets: "4554feeeeeeeeeeeeeee"