vanity-source 0.6 → 0.7

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