metior 0.1.1

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.
@@ -0,0 +1,99 @@
1
+ # This code is free software; you can redistribute it and/or modify it under
2
+ # the terms of the new BSD License.
3
+ #
4
+ # Copyright (c) 2011, Sebastian Staudt
5
+
6
+ require 'octokit'
7
+
8
+ require 'metior/github'
9
+ require 'metior/github/commit'
10
+ require 'metior/repository'
11
+
12
+ module Metior
13
+
14
+ module GitHub
15
+
16
+ # Represents a GitHub source code repository
17
+ #
18
+ # @author Sebastian Staudt
19
+ class Repository < Metior::Repository
20
+
21
+ include Metior::GitHub
22
+
23
+ # @return [String] The project name of the repository
24
+ attr_reader :project
25
+
26
+ # @return [String] The GitHub username of the repository's owner
27
+ attr_reader :user
28
+
29
+ # Creates a new GitHub repository based on the given user and project
30
+ # names
31
+ #
32
+ # @param [String] user The GitHub username of repository's owner
33
+ # @param [String] project The name of the project
34
+ def initialize(user, project)
35
+ super "#{user}/#{project}"
36
+
37
+ @project = project
38
+ @user = user
39
+ end
40
+
41
+ private
42
+
43
+ # This method uses Octokit to load all commits from the given commit
44
+ # range
45
+ #
46
+ # If you want to compare a branch with another (i.e. if you supply a
47
+ # range of commits), it needs two calls to the GitHub API to get all
48
+ # commits of each branch. The comparison is done in the code, so the
49
+ # limits (see below) will be effectively cut in half.
50
+ #
51
+ # @note GitHub API is currently limited to 60 calls a minute, so you
52
+ # won't be able to query branches with more than 2100 commits
53
+ # (35 commits per call).
54
+ # @param [String, Range] range The range of commits for which the commits
55
+ # should be loaded. This may be given as a string
56
+ # (`'master..development'`), a range (`'master'..'development'`)
57
+ # or as a single ref (`'master'`). A single ref name means all
58
+ # commits reachable from that ref.
59
+ # @return [Array<Commit>] All commits in the given commit range
60
+ # @see #load_branch_commits
61
+ def load_commits(range)
62
+ commits = load_branch_commits(range.last, range)
63
+ base_commits = load_branch_commits(range.first, range).map! do |commit|
64
+ commit.id
65
+ end
66
+ commits.reject { |commit| base_commits.include? commit.id }
67
+ end
68
+
69
+ # This method uses Octokit to load all commits from the given branch
70
+ #
71
+ # Because of GitHub API limitations, the commits have to be loaded in
72
+ # batches.
73
+ #
74
+ # @note GitHub API is currently limited to 60 calls a minute, so you
75
+ # won't be able to query branches with more than 2100 commits
76
+ # (35 commits per call).
77
+ # @param [String] branch The branch to load commits from
78
+ # @param [String, Range] range The range of commits to which the loaded
79
+ # commits should be assigned
80
+ # @return [Array<Commit>] All commits from the given branch
81
+ # @see Octokit::Commits#commits
82
+ def load_branch_commits(branch, range)
83
+ commits = []
84
+ page = 1
85
+ begin
86
+ begin
87
+ commits += Octokit.commits(@path, range, :page => page)
88
+ page += 1
89
+ end while true
90
+ rescue Octokit::NotFound
91
+ end
92
+ commits
93
+ end
94
+
95
+ end
96
+
97
+ end
98
+
99
+ end
@@ -0,0 +1,273 @@
1
+ # This code is free software; you can redistribute it and/or modify it under
2
+ # the terms of the new BSD License.
3
+ #
4
+ # Copyright (c) 2011, Sebastian Staudt
5
+
6
+ require 'metior/actor'
7
+
8
+ module Metior
9
+
10
+ # This class represents a source code repository.
11
+ #
12
+ # @abstract It has to be subclassed to implement a repository representation
13
+ # for a specific VCS.
14
+ # @author Sebastian Staudt
15
+ class Repository
16
+
17
+ # @return [String] The file system path of this repository
18
+ attr_reader :path
19
+
20
+ # Creates a new repository instance with the given file system path
21
+ #
22
+ # @param [String] path The file system path of the repository
23
+ def initialize(path)
24
+ @authors = {}
25
+ @commits = {}
26
+ @committers = {}
27
+ @path = path
28
+ end
29
+
30
+ # Returns all authors from the given commit range in a hash where the IDs
31
+ # of the authors are the keys and the authors are the values
32
+ #
33
+ # This will call `commits(range)` if the authors for the commit range are
34
+ # not known yet.
35
+ #
36
+ # @param [String, Range] range The range of commits for which the authors
37
+ # should be retrieved. This may be given as a string
38
+ # (`'master..development'`), a range (`'master'..'development'`) or
39
+ # as a single ref (`'master'`). A single ref name means all commits
40
+ # reachable from that ref.
41
+ # @return [Hash<String, Actor>] All authors from the given commit range
42
+ # @see #commits
43
+ def authors(range = self.class::DEFAULT_BRANCH)
44
+ range = parse_range range
45
+ commits(range) if @authors[range].nil?
46
+ @authors[range]
47
+ end
48
+ alias_method :contributors, :authors
49
+
50
+ # Loads all commits including their committers and authors from the given
51
+ # commit range
52
+ #
53
+ # @param [String, Range] range The range of commits for which the commits
54
+ # should be retrieved. This may be given as a string
55
+ # (`'master..development'`), a range (`'master'..'development'`) or
56
+ # as a single ref (`'master'`). A single ref name means all commits
57
+ # reachable from that ref.
58
+ # @return [Array<Commit>] All commits from the given commit range
59
+ def commits(range = self.class::DEFAULT_BRANCH)
60
+ range = parse_range range
61
+ if @commits[range].nil?
62
+ @authors[range] = {}
63
+ @committers[range] = {}
64
+ @commits[range] = []
65
+ load_commits(range).each do |commit|
66
+ commit = self.class::Commit.new(self, range, commit)
67
+ @commits[range] << commit
68
+ @authors[range][commit.author.id] = commit.author
69
+ @committers[range][commit.committer.id] = commit.committer
70
+ end
71
+ end
72
+
73
+ @commits[range]
74
+ end
75
+
76
+ # Returns all committers from the given commit range in a hash where the
77
+ # IDs of the committers are the keys and the committers are the values
78
+ #
79
+ # This will call `commits(range)` if the committers for the commit range
80
+ # are not known yet.
81
+ #
82
+ # @param [String, Range] range The range of commits for which the
83
+ # committers should be retrieved. This may be given as a string
84
+ # (`'master..development'`), a range (`'master'..'development'`) or
85
+ # as a single ref (`'master'`). A single ref name means all commits
86
+ # reachable from that ref.
87
+ # @return [Hash<String, Actor>] All committers from the given commit range
88
+ # @see #commits
89
+ def committers(range = self.class::DEFAULT_BRANCH)
90
+ range = parse_range range
91
+ commits(range) if @committers[range].nil?
92
+ @committers[range]
93
+ end
94
+ alias_method :collaborators, :committers
95
+
96
+ # This evaluates basic statistics about the files in a given commit range.
97
+ #
98
+ # @example
99
+ # repo.file_stats
100
+ # => {
101
+ # 'a_file.rb' => {
102
+ # :added_date => Tue Mar 29 16:13:47 +0200 2011,
103
+ # :deleted_date => Sun Jun 05 12:56:18 +0200 2011,
104
+ # :last_modified_date => Thu Apr 21 20:08:00 +0200 2011,
105
+ # :modifications => 9
106
+ # }
107
+ # }
108
+ # @param [String, Range] range The range of commits for which the file
109
+ # stats should be retrieved. This may be given as a string
110
+ # (`'master..development'`), a range (`'master'..'development'`) or
111
+ # as a single ref (`'master'`). A single ref name means all commits
112
+ # reachable from that ref.
113
+ # @return [Hash<String, Hash<Symbol, Object>>] Each file is returned as a
114
+ # key in this hash. The value of this key is another hash
115
+ # containing the stats for this file. Depending on the state of the
116
+ # file this includes `:added_date`, `:last_modified_date`,
117
+ # `:last_modified_date` and `'master..development'`.
118
+ # @see Commit#added_files
119
+ # @see Commit#deleted_files
120
+ # @see Commit#modified_files
121
+ def file_stats(range = self.class::DEFAULT_BRANCH)
122
+ support! :line_stats
123
+
124
+ stats = {}
125
+ commits(range).each do |commit|
126
+ commit.added_files.each do |file|
127
+ stats[file] = { :modifications => 0 } unless stats.key? file
128
+ stats[file][:added_date] = commit.authored_date
129
+ stats[file][:modifications] += 1
130
+ end
131
+ commit.modified_files.each do |file|
132
+ stats[file] = { :modifications => 0 } unless stats.key? file
133
+ stats[file][:last_modified_date] = commit.authored_date
134
+ stats[file][:modifications] += 1
135
+ end
136
+ commit.deleted_files.each do |file|
137
+ stats[file] = { :modifications => 0 } unless stats.key? file
138
+ stats[file][:deleted_date] = commit.authored_date
139
+ end
140
+ end
141
+
142
+ stats
143
+ end
144
+
145
+ # This evaluates the changed lines in each commit of the given commit
146
+ # range.
147
+ #
148
+ # For easier use, the values are stored in separate arrays where each
149
+ # number represents the number of changed (i.e. added or deleted) lines in
150
+ # one commit.
151
+ #
152
+ # @example
153
+ # repo.line_history
154
+ # => { :additions => [10, 5, 0], :deletions => [0, -2, -1] }
155
+ # @param [String, Range] range The range of commits for which the commit
156
+ # stats should be retrieved. This may be given as a string
157
+ # (`'master..development'`), a range (`'master'..'development'`) or
158
+ # as a single ref (`'master'`). A single ref name means all commits
159
+ # reachable from that ref.
160
+ # @return [Hash<Symbol, Array>] Added lines are returned in an `Array`
161
+ # assigned to key `:additions`, deleted lines are assigned to
162
+ # `:deletions`
163
+ # @see Commit#additions
164
+ # @see Commit#deletions
165
+ def line_history(range = self.class::DEFAULT_BRANCH)
166
+ support! :line_stats
167
+
168
+ history = { :additions => [], :deletions => [] }
169
+ commits(range).reverse.each do |commit|
170
+ history[:additions] << commit.additions
171
+ history[:deletions] << -commit.deletions
172
+ end
173
+
174
+ history
175
+ end
176
+
177
+ # Returns a list of authors with the biggest impact on the repository, i.e.
178
+ # changing the most code
179
+ #
180
+ # @param [String, Range] range The range of commits for which the authors
181
+ # should be retrieved. This may be given as a string
182
+ # (`'master..development'`), a range (`'master'..'development'`) or
183
+ # as a single ref (`'master'`). A single ref name means all commits
184
+ # reachable from that ref.
185
+ # @param [Fixnum] count The number of authors to return
186
+ # @raise [UnsupportedError] if the VCS does not support `:line_stats`
187
+ # @return [Array<Actor>] An array of the given number of the most
188
+ # significant authors in the given commit range
189
+ def significant_authors(range = self.class::DEFAULT_BRANCH, count = 3)
190
+ support! :line_stats
191
+
192
+ authors = authors(range).values.sort_by { |author| author.modifications }
193
+ count = [count, authors.size].min
194
+ authors[-count..-1].reverse
195
+ end
196
+ alias_method :significant_contributors, :significant_authors
197
+
198
+ # Returns a list of commits with the biggest impact on the repository, i.e.
199
+ # changing the most code
200
+ #
201
+ # @param [String, Range] range The range of commits for which the commits
202
+ # should be retrieved. This may be given as a string
203
+ # (`'master..development'`), a range (`'master'..'development'`) or
204
+ # as a single ref (`'master'`). A single ref name means all commits
205
+ # reachable from that ref.
206
+ # @param [Fixnum] count The number of commits to return
207
+ # @raise [UnsupportedError] if the VCS does not support `:line_stats`
208
+ # @return [Array<Actor>] An array of the given number of the most
209
+ # significant commits in the given commit range
210
+ def significant_commits(range = self.class::DEFAULT_BRANCH, count = 10)
211
+ support! :line_stats
212
+
213
+ commits = commits(range).sort_by { |commit| commit.modifications }
214
+ count = [count, commits.size].min
215
+ commits[-count..-1].reverse
216
+ end
217
+
218
+ # Returns a list of top contributors in the given commit range
219
+ #
220
+ # This will first have to load all authors (and i.e. commits) from the
221
+ # given commit range.
222
+ #
223
+ # @param [String, Range] range The range of commits for which the top
224
+ # contributors should be retrieved. This may be given as a string
225
+ # (`'master..development'`), a range (`'master'..'development'`) or
226
+ # as a single ref (`'master'`). A single ref name means all commits
227
+ # reachable from that ref.
228
+ # @param [Fixnum] count The number of contributors to return
229
+ # @return [Array<Actor>] An array of the given number of top contributors
230
+ # in the given commit range
231
+ # @see #authors
232
+ def top_authors(range = self.class::DEFAULT_BRANCH, count = 3)
233
+ authors = authors(range).values.sort_by { |author| author.commits.size }
234
+ count = [count, authors.size].min
235
+ authors[-count..-1].reverse
236
+ end
237
+ alias_method :top_contributors, :top_authors
238
+
239
+ private
240
+
241
+ # Loads all commits from the given commit range
242
+ #
243
+ # @abstract It has to be implemented by VCS specific subclasses
244
+ # @param [String, Range] range The range of commits for which the commits
245
+ # should be retrieved. This may be given as a string
246
+ # (`'master..development'`), a range (`'master'..'development'`) or
247
+ # as a single ref (`'master'`). A single ref name means all commits
248
+ # reachable from that ref.
249
+ # @return [Array<Commit>] All commits from the given commit range
250
+ def load_commits(range = self.class::DEFAULT_BRANCH)
251
+ raise NotImplementedError
252
+ end
253
+
254
+ # Parses a string with a single ref name into
255
+ #
256
+ # If a range is given it will be returned as-is.
257
+ #
258
+ # @param [String, Range] range The string that should be parsed for a range
259
+ # or an existing range
260
+ # @return [Range] The range parsed from a string or unchanged from the
261
+ # given parameter
262
+ def parse_range(range)
263
+ if range.is_a? Range
264
+ range
265
+ else
266
+ range = range.to_s.split '..'
267
+ ((range.size == 1) ? '' : range.first)..range.last
268
+ end
269
+ end
270
+
271
+ end
272
+
273
+ end
data/lib/metior/vcs.rb ADDED
@@ -0,0 +1,150 @@
1
+ # This code is free software; you can redistribute it and/or modify it under
2
+ # the terms of the new BSD License.
3
+ #
4
+ # Copyright (c) 2011, Sebastian Staudt
5
+
6
+ require 'metior'
7
+ require 'metior/errors'
8
+
9
+ module Metior
10
+
11
+ # This hash will be dynamically filled with all available VCS types and the
12
+ # corresponding implementation modules
13
+ @@vcs_types = {}
14
+
15
+ # Returns the VCS implementation `Module` for a given symbolic VCS name
16
+ #
17
+ # @param [Symbol] type The symbolic type name of the VCS
18
+ # @return [VCS] The VCS for the given name
19
+ def self.vcs(type)
20
+ type = type.to_sym
21
+ unless @@vcs_types.key? type
22
+ raise 'No VCS registered for :%s' % type
23
+ end
24
+ @@vcs_types[type].init
25
+ end
26
+
27
+ # Returns a Hash with all available VCS types as keys and the implementation
28
+ # modules as values
29
+ #
30
+ # @return [Hash<Symbol, VCS>] All available VCS implementations and their
31
+ # corresponding names
32
+ def self.vcs_types
33
+ @@vcs_types
34
+ end
35
+
36
+ # This module provides functionality to automatically register new VCS
37
+ # implementations `Module`s
38
+ #
39
+ # @author Sebastian Staudt
40
+ module VCS
41
+
42
+ # This module provided class methods for VCS implementation `Module`s that
43
+ # implement smart auto-loading of dependencies and classes.
44
+ module ClassMethods
45
+
46
+ # Missing constants may indicate
47
+ #
48
+ # Trying to access either the `Actor`, `Commit` or `Repository` class
49
+ # in a VCS `Module` will trigger auto-loading first.
50
+ #
51
+ # @param [Symbol] The symbolic name of the missing constant
52
+ # @see #init
53
+ def const_missing(const)
54
+ init if [:Actor, :Commit, :Repository].include?(const)
55
+ super unless const_defined? const
56
+ const_get const
57
+ end
58
+
59
+ # This initializes the VCS's implementation `Module`
60
+ #
61
+ # First the corresponding Bundler group is loaded so all dependencies are
62
+ # met. Afterwards the `Actor`, `Commit` and `Repository` classes are
63
+ # required.
64
+ #
65
+ # @see Bundler.setup
66
+ def init
67
+ Bundler.setup self::NAME
68
+
69
+ path = self::NAME.to_s
70
+ require "metior/#{path}/actor"
71
+ require "metior/#{path}/commit"
72
+ require "metior/#{path}/repository"
73
+
74
+ self
75
+ end
76
+
77
+ # Marks one or more features as not supported by the VCSs (or its
78
+ # implementation)
79
+ #
80
+ # @param [Array<Symbol>] features The features that are not supported
81
+ # @see #supports?
82
+ def not_supported(*features)
83
+ features.each do |feature|
84
+ self.send(:class_variable_get, :@@features)[feature] = false
85
+ end
86
+ end
87
+
88
+ # Checks if a specific feature is supported by the VCS (or its
89
+ # implementation)
90
+ #
91
+ # @param [Symbol] feature The feature to check
92
+ # @return [true, false] `true` if the feature is supported
93
+ # @see #not_supported
94
+ # @see VCS#supports?
95
+ def supports?(feature)
96
+ self.send(:class_variable_get, :@@features)[feature] == true
97
+ end
98
+
99
+ end
100
+
101
+ # Including `VCS` will make a `Module` available as a supported VCS type in
102
+ # Metior
103
+ #
104
+ # @example This will automatically register `ExoticVCS` as `:exotic`
105
+ # module ExoticVCS
106
+ #
107
+ # NAME = :exotic
108
+ #
109
+ # include Metior::VCS
110
+ #
111
+ # end
112
+ #
113
+ # @param [Module] mod The `Module` that provides a Metior implementation
114
+ # for a specific VCS
115
+ # @raise [RuntimeError] if the VCS `Module` does not have the `NAME`
116
+ # constant defined prior to including `Metior::VCS`
117
+ # @see Metior.vcs_types
118
+ def self.included(mod)
119
+ mod.extend ClassMethods
120
+ mod.send :class_variable_set, :@@features, {
121
+ :file_stats => true,
122
+ :line_stats => true
123
+ }
124
+
125
+ raise "#{mod}::NAME is not set." unless mod.const_defined? :NAME
126
+ Metior.vcs_types[mod::NAME.to_sym] = mod
127
+ end
128
+
129
+ # Checks if a specific feature is supported by the VCS (or its
130
+ # implementation) and raises an error if the feature is not available
131
+ #
132
+ # @raise [UnsupportedError] if the feature is not supported by the VCS (or
133
+ # its implementation)
134
+ # @see #supports?
135
+ def support!(feature)
136
+ raise UnsupportedError unless supports? feature
137
+ end
138
+
139
+ # Checks if a specific feature is supported by the VCS (or its
140
+ # implementation)
141
+ #
142
+ # @return [true, false] `true` if the feature is supported
143
+ # @see ClassMethods#supports?
144
+ def supports?(feature)
145
+ singleton_class.included_modules.first.supports? feature
146
+ end
147
+
148
+ end
149
+
150
+ end