metior 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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