circleci_reporter 1.0.0

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,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'circleci_reporter/client'
4
+ require_relative 'circleci_reporter/configuration'
5
+ require_relative 'circleci_reporter/errors'
6
+ require_relative 'circleci_reporter/runner'
7
+
8
+ require_relative 'circleci_reporter/reporters/flow'
9
+ require_relative 'circleci_reporter/reporters/link'
10
+ require_relative 'circleci_reporter/reporters/rubycritic'
11
+ require_relative 'circleci_reporter/reporters/simplecov'
12
+
13
+ module CircleCIReporter
14
+ class << self
15
+ # Setters for shared global objects
16
+ # @api private
17
+ attr_writer :client, :configuration
18
+ end
19
+
20
+ # @return [Configuration]
21
+ def self.configuration
22
+ @configuration ||= Configuration.new
23
+ end
24
+
25
+ # @return [Client]
26
+ def self.client
27
+ @client ||= Client.new
28
+ end
29
+
30
+ # Yields the global configuration to a block.
31
+ #
32
+ # @yield [Configuration]
33
+ def self.configure
34
+ yield configuration if block_given?
35
+ end
36
+
37
+ # @return [void]
38
+ def self.run
39
+ configuration.reporters.select!(&:active?)
40
+ configuration.dump
41
+ raise NoActiveReporter if configuration.reporters.empty?
42
+
43
+ Runner.new.tap(&:dump).run
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleCIReporter
4
+ # Encapsulate a CircleCI artifact
5
+ #
6
+ # @attr path [String] abstract path to the artifact in CircleCI container
7
+ # @attr url [String] URL of the artifact
8
+ # @attr node_index [Integer] the ID of the artifact's container
9
+ Artifact = Struct.new(:path, :url, :node_index) do
10
+ # @param value [String]
11
+ # @param node_index [Integer, nil]
12
+ # @return [Boolean]
13
+ def match?(value, node_index: nil)
14
+ path.end_with?(value) && (node_index.nil? || self.node_index == node_index)
15
+ end
16
+
17
+ # @return [String] content of the artifact
18
+ def body
19
+ @body ||= CircleCIReporter.client.get(url).body
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleCIReporter
4
+ # Encapsulate a CircleCI build
5
+ #
6
+ # @attr vcs_revision [String] revision of git
7
+ # @attr build_number [Integer] the ID of the CircleCI build
8
+ Build = Struct.new(:vcs_revision, :build_number) do
9
+ # @param revision [String]
10
+ # @return [Boolean]
11
+ def match?(revision)
12
+ vcs_revision.start_with?(revision)
13
+ end
14
+
15
+ # @return [Array<Artifact>]
16
+ def artifacts
17
+ @artifacts ||= CircleCIReporter.client.artifacts(build_number)
18
+ end
19
+
20
+ # @param string [String]
21
+ # @param node_index [Integer, nil]
22
+ # @return [Artifact, nil]
23
+ def find_artifact(string, node_index: nil)
24
+ artifacts.find { |artifact| artifact.match?(string, node_index: node_index) }
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ require_relative 'artifact'
6
+ require_relative 'build'
7
+ require_relative 'errors'
8
+
9
+ module CircleCIReporter
10
+ # CircleCI API client
11
+ class Client
12
+ CIRCLECI_ENDPOINT = 'https://circleci.com/api/v1.1'
13
+
14
+ # Fetch a build data from API and create a {Build} object for it.
15
+ #
16
+ # @param build_number [Integer, nil]
17
+ # @return [Build, nil]
18
+ # @raise [RequestError]
19
+ # @see https://circleci.com/docs/api/v1-reference/#build
20
+ def single_build(build_number)
21
+ return unless build_number
22
+
23
+ resp = get(single_build_url(build_number))
24
+ body = JSON.parse(resp.body)
25
+ raise RequestError.new(body['message'], resp) unless resp.success?
26
+
27
+ create_build(body)
28
+ end
29
+
30
+ # Retrieve artifacts for the build.
31
+ #
32
+ # @param build_number [Integer]
33
+ # @return [Array<Artifact>]
34
+ # @raise [RequestError]
35
+ # @see https://circleci.com/docs/api/v1-reference/#build-artifacts
36
+ def artifacts(build_number)
37
+ resp = get(artifacts_url(build_number))
38
+ body = JSON.parse(resp.body)
39
+ raise RequestError.new(body['message'], resp) unless resp.success?
40
+
41
+ body.map{ |hash| create_artifact(hash) }
42
+ end
43
+
44
+ # Find the latest build number for the given vcs revision.
45
+ #
46
+ # @param revision [String]
47
+ # @param branch [String, nil]
48
+ # @return [Integer, nil]
49
+ def build_number_by_revision(revision, branch: nil)
50
+ build = recent_builds(branch).find { |recent_build| recent_build.match?(revision) }
51
+ build ? build.build_number : nil
52
+ end
53
+
54
+ # Raw entry point for GET APIs.
55
+ #
56
+ # @param url [String]
57
+ # @param params [Hash]
58
+ # @return [Faraday::Response]
59
+ def get(url, params = {})
60
+ params['circle-token'] = configuration.circleci_token
61
+ query_string = params.map { |key, value| "#{key}=#{value}" }.join('&')
62
+ Faraday.get("#{url}?#{query_string}")
63
+ end
64
+
65
+ private
66
+
67
+ # @return [Configuration]
68
+ def configuration
69
+ CircleCIReporter.configuration
70
+ end
71
+
72
+ # @param build_number [Integer]
73
+ # @return [String] URL for "Artifacts of a Bulid API"
74
+ def artifacts_url(build_number)
75
+ [
76
+ CIRCLECI_ENDPOINT,
77
+ 'project',
78
+ configuration.vcs_type,
79
+ configuration.project,
80
+ build_number,
81
+ 'artifacts'
82
+ ].join('/')
83
+ end
84
+
85
+ # @param branch [String, nil]
86
+ # @return [Array<Build>]
87
+ # @raise [RequestError]
88
+ def recent_builds(branch)
89
+ resp = get(recent_builds_url(branch), limit: 100)
90
+ body = JSON.parse(resp.body)
91
+ raise RequestError.new(body['message'], resp) unless resp.success?
92
+
93
+ body.map { |hash| create_build(hash) }
94
+ end
95
+
96
+ # @param branch [String, nil]
97
+ # @return [String]
98
+ def recent_builds_url(branch)
99
+ elements = [
100
+ CIRCLECI_ENDPOINT,
101
+ 'project',
102
+ configuration.vcs_type,
103
+ configuration.project
104
+ ]
105
+ elements += ['tree', branch] if branch
106
+ elements.join('/')
107
+ end
108
+
109
+ # @param build_number [Integer]
110
+ # @return [String]
111
+ def single_build_url(build_number)
112
+ [
113
+ CIRCLECI_ENDPOINT,
114
+ 'project',
115
+ configuration.vcs_type,
116
+ configuration.project,
117
+ build_number
118
+ ].join('/')
119
+ end
120
+
121
+ # @param hash [Hash]
122
+ # @return [Artifact]
123
+ def create_artifact(hash)
124
+ Artifact.new(hash['path'], hash['url'], hash['node_index'])
125
+ end
126
+
127
+ # @param hash [Hash]
128
+ # @return [Build]
129
+ def create_build(hash)
130
+ Build.new(hash['vcs_revision'], hash['build_num'])
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'reporters/flow'
4
+ require_relative 'reporters/rubycritic'
5
+ require_relative 'reporters/simplecov'
6
+
7
+ module CircleCIReporter
8
+ class Configuration
9
+ DEFAULT_REPORTERS = [
10
+ Reporters::SimpleCov.new,
11
+ Reporters::Flow.new,
12
+ Reporters::RubyCritic.new
13
+ ].freeze
14
+ DEFAULT_VCS_TYPE = 'github'
15
+
16
+ attr_accessor :circleci_token, :vcs_token
17
+ attr_writer :artifacts_dir, :base_revision, :current_build_number,
18
+ :current_revision, :previous_build_number, :reporters, :repository_name,
19
+ :template, :template_safe_mode, :user_name, :vcs_type
20
+
21
+ # @return [String]
22
+ def project
23
+ "#{user_name}/#{repository_name}"
24
+ end
25
+
26
+ # @return [Array<Reporters::Base>]
27
+ def reporters
28
+ @reporters ||= DEFAULT_REPORTERS.dup
29
+ end
30
+
31
+ # @return [String]
32
+ def vcs_type
33
+ @vcs_type ||= DEFAULT_VCS_TYPE
34
+ end
35
+
36
+ # @return [String]
37
+ def artifacts_dir
38
+ @artifacts_dir ||= ENV['CIRCLE_ARTIFACTS']
39
+ end
40
+
41
+ # @return [String]
42
+ def base_revision
43
+ @base_revision ||= `git merge-base origin/master HEAD`.strip
44
+ end
45
+
46
+ # @return [Integer]
47
+ def current_build_number
48
+ @current_build_number ||= ENV['CIRCLE_BUILD_NUM']
49
+ end
50
+
51
+ # @return [String]
52
+ def current_revision
53
+ @current_revision ||= ENV['CIRCLE_SHA1']
54
+ end
55
+
56
+ # @return [Integer, nil]
57
+ def previous_build_number
58
+ @previous_build_number ||= ENV['CIRCLE_PREVIOUS_BUILD_NUM']&.to_i
59
+ end
60
+
61
+ # @return [String]
62
+ def repository_name
63
+ @repository_name ||= ENV['CIRCLE_PROJECT_REPONAME']
64
+ end
65
+
66
+ # @return [String]
67
+ def user_name
68
+ @user_name ||= ENV['CIRCLE_PROJECT_USERNAME']
69
+ end
70
+
71
+ # @return [void]
72
+ def dump
73
+ puts <<~CONFIGURATION
74
+ Configuration | Value
75
+ ----------------------|----------------------------------------------------------------------------
76
+ artifacts_dir | #{artifacts_dir.inspect}
77
+ base_revision | #{base_revision.inspect}
78
+ circleci_token | #{circleci_token[-4..].rjust(40, '*').inspect if circleci_token}
79
+ current_build_number | #{current_build_number.inspect}
80
+ current_revision | #{current_revision.inspect}
81
+ previous_build_number | #{previous_build_number.inspect}
82
+ reporters | #{reporters.inspect}
83
+ repository_name | #{repository_name.inspect}
84
+ user_name | #{user_name.inspect}
85
+ vcs_token | #{vcs_token[-4..].rjust(40, '*').inspect if vcs_token}
86
+ vcs_type | #{vcs_type.inspect}
87
+ CONFIGURATION
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleCIReporter
4
+ class Error < StandardError; end
5
+
6
+ class RequestError < Error
7
+ attr_reader :resp
8
+
9
+ # @param message [String]
10
+ # @param resp [Faraday::Response]
11
+ def initialize(message, resp)
12
+ @message = message
13
+ @resp = resp
14
+ super(message)
15
+ end
16
+ end
17
+
18
+ class NoActiveReporter < Error; end
19
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'circleci_reporter'
4
+
5
+ namespace :circleci_reporter do
6
+ desc 'Report test coverage'
7
+ task :coverage do
8
+ abort unless ENV['CIRCLECI']
9
+
10
+ CircleCIReporter.configure do |config|
11
+ config.circleci_token = ENV['CIRCLECI_REPORTER_CIRCLECI_TOKEN']
12
+ config.vcs_token = ENV['CIRCLECI_REPORTER_VCS_TOKEN']
13
+ end
14
+
15
+ CircleCIReporter.run
16
+ end
17
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CircleCIReporter
4
+ # Encapsulate a report created by a reporter.
5
+ #
6
+ # @see Reporters::Base#report
7
+ class Report
8
+ # @param reporter [Reporters::Base] the reporter of the report
9
+ # @param current [Result]
10
+ # @param base [Result, nil] result at master branch
11
+ # @param previous [Result, nil] result at previous build in same branch
12
+ def initialize(reporter, current, base: nil, previous: nil)
13
+ @reporter = reporter
14
+ @current_result = current
15
+ @base_result = base
16
+ @previous_result = previous
17
+ end
18
+
19
+ # @return [String]
20
+ def to_s
21
+ "#{link}: #{current_result.pretty_coverage}#{emoji}#{progress}"
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :reporter, :current_result, :base_result, :previous_result
27
+
28
+ # @return [String]
29
+ def link
30
+ "[#{reporter.name}](#{current_result.url})"
31
+ end
32
+
33
+ # @return [String]
34
+ def emoji
35
+ if base_diff.nil? || base_diff.nan? || base_diff.round(2).zero?
36
+ ''
37
+ elsif base_diff.positive?
38
+ ':chart_with_upwards_trend:'
39
+ else
40
+ ':chart_with_downwards_trend:'
41
+ end
42
+ end
43
+
44
+ # @return [String]
45
+ def progress
46
+ progresses.empty? ? '' : "(#{progresses.join(', ')})"
47
+ end
48
+
49
+ # @return [Array<String>]
50
+ def progresses
51
+ [base_progress, branch_progress].compact
52
+ end
53
+
54
+ # @return [String, nil]
55
+ def base_progress
56
+ base_diff ? "[master](#{base_result.url}): #{pretty_base_diff}" : nil
57
+ end
58
+
59
+ # @return [String, nil]
60
+ def branch_progress
61
+ branch_diff ? "[previous](#{previous_result.url}): #{pretty_branch_diff}" : nil
62
+ end
63
+
64
+ # @return [String, nil]
65
+ def pretty_base_diff
66
+ return unless base_diff
67
+
68
+ pretty_diff(base_diff.round(2))
69
+ end
70
+
71
+ # @return [String, nil]
72
+ def pretty_branch_diff
73
+ return unless branch_diff
74
+
75
+ pretty_diff(branch_diff.round(2))
76
+ end
77
+
78
+ # @return [Float, nil]
79
+ def base_diff
80
+ return unless base_result
81
+
82
+ current_result.coverage - base_result.coverage
83
+ end
84
+
85
+ # @return [Float, nil]
86
+ def branch_diff
87
+ return unless previous_result
88
+
89
+ current_result.coverage - previous_result.coverage
90
+ end
91
+
92
+ # @param diff [Float]
93
+ # @return [String]
94
+ def pretty_diff(diff)
95
+ if diff.nan?
96
+ 'NaN'
97
+ elsif diff.positive?
98
+ "+#{diff}"
99
+ elsif diff.negative?
100
+ diff.to_s
101
+ else
102
+ '±0'
103
+ end
104
+ end
105
+ end
106
+ end