circleci_reporter 1.0.0

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