jiminy 0.1.0.pre1
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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +20 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +0 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +245 -0
- data/LICENSE +21 -0
- data/README.md +173 -0
- data/Rakefile +12 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/example.png +0 -0
- data/exe/jiminy +9 -0
- data/jiminy.gemspec +42 -0
- data/lib/jiminy/cli.rb +137 -0
- data/lib/jiminy/configuration.rb +97 -0
- data/lib/jiminy/github_apiable.rb +17 -0
- data/lib/jiminy/recording/n_plus_one.rb +50 -0
- data/lib/jiminy/recording/prosopite_ext/send_notifications_with_tmp_file.rb +44 -0
- data/lib/jiminy/recording/prosopite_ext/tmp_file_recorder.rb +40 -0
- data/lib/jiminy/recording/rspec.rb +22 -0
- data/lib/jiminy/recording/test_controller_concern.rb +26 -0
- data/lib/jiminy/recording.rb +14 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci/api_request.rb +45 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci/artifact.rb +17 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci/base.rb +46 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci/job.rb +17 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci/pipeline.rb +49 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci/vcs.rb +13 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci/workflow.rb +41 -0
- data/lib/jiminy/reporting/ci_providers/circle_ci.rb +54 -0
- data/lib/jiminy/reporting/ci_providers/github.rb +29 -0
- data/lib/jiminy/reporting/ci_providers/local/artifact.rb +25 -0
- data/lib/jiminy/reporting/ci_providers/local.rb +30 -0
- data/lib/jiminy/reporting/ci_providers/provider_configuration.rb +28 -0
- data/lib/jiminy/reporting/ci_providers.rb +12 -0
- data/lib/jiminy/reporting/n_plus_one.rb +39 -0
- data/lib/jiminy/reporting/reporters/base_reporter.rb +26 -0
- data/lib/jiminy/reporting/reporters/dry_run_reporter.rb +13 -0
- data/lib/jiminy/reporting/reporters/github_reporter.rb +28 -0
- data/lib/jiminy/reporting/reporters.rb +11 -0
- data/lib/jiminy/reporting/yaml_file_comment_presenter.rb +71 -0
- data/lib/jiminy/reporting.rb +32 -0
- data/lib/jiminy/rspec.rb +3 -0
- data/lib/jiminy/setup.rb +10 -0
- data/lib/jiminy/templates/reporting/comment_header.md.erb +5 -0
- data/lib/jiminy/templates/reporting/n_plus_one.md.erb +12 -0
- data/lib/jiminy/version.rb +5 -0
- data/lib/jiminy.rb +13 -0
- metadata +254 -0
data/lib/jiminy/cli.rb
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
require "thor"
|
5
|
+
require "byebug"
|
6
|
+
class CLI < Thor
|
7
|
+
require "jiminy/reporting/ci_providers/circle_ci"
|
8
|
+
include Jiminy::Reporting::CIProviders
|
9
|
+
|
10
|
+
class WorkflowStillRunningError < StandardError; end
|
11
|
+
private_constant :WorkflowStillRunningError
|
12
|
+
|
13
|
+
MAX_TIMEOUT = 1800 # 1 hour
|
14
|
+
POLL_INTERVAL = 60 # 1 min
|
15
|
+
|
16
|
+
def self.exit_on_failure?
|
17
|
+
false
|
18
|
+
end
|
19
|
+
|
20
|
+
desc "Report results", "Reports the results of tests"
|
21
|
+
method_option :commit, type: :string, aliases: "c", required: true,
|
22
|
+
banner: "3e078f8770743549b722382ec5d412a30b9fdcc5",
|
23
|
+
desc: "The full SHA for the current HEAD commit"
|
24
|
+
method_option :pr_number, type: :numeric, aliases: %w[pr p], required: true,
|
25
|
+
banner: "1",
|
26
|
+
desc: "The GitHub PR number"
|
27
|
+
method_option :dry_run, type: :boolean, default: false, lazy_default: true,
|
28
|
+
desc: "Print to STDOUT instead of leaving a comment on GitHub"
|
29
|
+
method_option :timeout, type: :numeric, aliases: %w[max-timeout], default: MAX_TIMEOUT,
|
30
|
+
desc: "How long to poll CircleCI before timing out (in seconds)"
|
31
|
+
method_option :poll_interval, type: :numeric, aliases: %w[poll-interval], default: POLL_INTERVAL,
|
32
|
+
desc: "How frequently to poll CircleCI (in seconds)"
|
33
|
+
method_option :source, type: :string, default: "circleci",
|
34
|
+
desc: "Where are the results.yml files we should report?"
|
35
|
+
def report
|
36
|
+
self.start_time = Time.now
|
37
|
+
artifact_urls = artifacts.map(&:url)
|
38
|
+
|
39
|
+
Jiminy::Reporting.report!(*artifact_urls,
|
40
|
+
pr_number: options[:pr_number],
|
41
|
+
dry_run: options[:dry_run])
|
42
|
+
|
43
|
+
$stdout.puts "Reported N+1s successfully"
|
44
|
+
exit(0)
|
45
|
+
end
|
46
|
+
|
47
|
+
# rubocop:disable Metrics/BlockLength
|
48
|
+
no_tasks do
|
49
|
+
attr_accessor :start_time
|
50
|
+
|
51
|
+
def poll_interval
|
52
|
+
options[:poll_interval] || POLL_INTERVAL
|
53
|
+
end
|
54
|
+
|
55
|
+
def source
|
56
|
+
options[:source] || :circleci
|
57
|
+
end
|
58
|
+
|
59
|
+
def max_timeout
|
60
|
+
options[:timeout] || MAX_TIMEOUT
|
61
|
+
end
|
62
|
+
|
63
|
+
def timed_out?
|
64
|
+
(Time.now - start_time) > max_timeout
|
65
|
+
end
|
66
|
+
|
67
|
+
def git_revision
|
68
|
+
options[:commit]
|
69
|
+
end
|
70
|
+
|
71
|
+
def pr_number
|
72
|
+
options[:pr_number]
|
73
|
+
end
|
74
|
+
|
75
|
+
def pipeline
|
76
|
+
@_pipeline ||= CircleCI::Pipeline.find_by_revision(git_revision: git_revision, pr_number: pr_number) or
|
77
|
+
abort("No such Pipeline with commit SHA #{git_revision}")
|
78
|
+
end
|
79
|
+
|
80
|
+
def missing_options
|
81
|
+
[].tap do |array|
|
82
|
+
array.concat("--pr-number") unless pr_number
|
83
|
+
array.concat("--commit") unless git_revision
|
84
|
+
end
|
85
|
+
end
|
86
|
+
|
87
|
+
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
|
88
|
+
def workflow
|
89
|
+
@_workflow ||= begin
|
90
|
+
result = CircleCI::Workflow.find(pipeline_id: pipeline.id, workflow_name: Jiminy.config.ci_workflow_name)
|
91
|
+
if result.nil?
|
92
|
+
abort("Unable to find workflow called '#{Jiminy.config.ci_workflow_name}' in Pipeline #{pipeline.id}")
|
93
|
+
end
|
94
|
+
|
95
|
+
if result.not_run? || result.running?
|
96
|
+
$stdout.puts "Workflow still running..."
|
97
|
+
raise(WorkflowStillRunningError)
|
98
|
+
end
|
99
|
+
abort("Workflow #{result.status}—aborting...") unless result.success?
|
100
|
+
|
101
|
+
result
|
102
|
+
rescue WorkflowStillRunningError
|
103
|
+
sleep(poll_interval)
|
104
|
+
$stdout.puts "Retrying..."
|
105
|
+
retry unless timed_out?
|
106
|
+
|
107
|
+
abort("Process timed out after #{Time.now - start_time} seconds")
|
108
|
+
end
|
109
|
+
end
|
110
|
+
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength, Metrics/CyclomaticComplexity
|
111
|
+
|
112
|
+
def jobs
|
113
|
+
@_jobs ||= CircleCI::Job.all(workflow_id: workflow.id)
|
114
|
+
end
|
115
|
+
|
116
|
+
def artifacts
|
117
|
+
@_artifacts ||= begin
|
118
|
+
unless respond_to?(:"artifacts_from_#{source}")
|
119
|
+
raise ArgumentError, "Uknown value for source option #{source}"
|
120
|
+
end
|
121
|
+
|
122
|
+
public_send(:"artifacts_from_#{source}")
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
def artifacts_from_local
|
127
|
+
Local::Artifact.all
|
128
|
+
end
|
129
|
+
|
130
|
+
def artifacts_from_circle_ci
|
131
|
+
CircleCI::Artifact.all(job_number: jobs.first.job_number)
|
132
|
+
end
|
133
|
+
alias_method :artifacts_from_circleci, :artifacts_from_circle_ci
|
134
|
+
end
|
135
|
+
# rubocop:enable Metrics/BlockLength
|
136
|
+
end
|
137
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
class Configuration
|
5
|
+
DEFAULT_CONFIG_READER = -> { instance_variable_get(:"@_#{__callee__}" || raise_missing_required(__callee__)) }
|
6
|
+
DEFAULT_CONFIG_WRITER = ->(value) { instance_variable_set(:"@_#{__callee__}"[0..-2], value) }
|
7
|
+
|
8
|
+
class MissingConfigError < StandardError
|
9
|
+
attr_reader :missing_config_name
|
10
|
+
|
11
|
+
def initialize(missing_config_name)
|
12
|
+
super()
|
13
|
+
@missing_config_name = missing_config_name
|
14
|
+
end
|
15
|
+
|
16
|
+
def message
|
17
|
+
<<~MESSAGE
|
18
|
+
You must provide a configuration value for ##{missing_config_name}
|
19
|
+
|
20
|
+
This probably means you haven't set Jiminy.config.#{missing_config_name}
|
21
|
+
MESSAGE
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
class << self
|
26
|
+
def define_config(name, options = {})
|
27
|
+
define_method(:"#{name}=", DEFAULT_CONFIG_WRITER)
|
28
|
+
define_method(name.to_sym, DEFAULT_CONFIG_READER)
|
29
|
+
defined_configs[name].merge!(options)
|
30
|
+
end
|
31
|
+
|
32
|
+
def defined_configs
|
33
|
+
@_defined_configs ||= Hash.new { |hash, key| hash[key] = { default: nil, required: true } }
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
define_config :ci_workflow_name, default: "build"
|
38
|
+
|
39
|
+
define_config :circle_ci_api_token
|
40
|
+
|
41
|
+
define_config :github_token
|
42
|
+
|
43
|
+
define_config :ignore_file_path, default: File.join("./jiminy_ignores.yml")
|
44
|
+
|
45
|
+
define_config :project_reponame
|
46
|
+
|
47
|
+
define_config :project_username
|
48
|
+
|
49
|
+
define_config :temp_file_location, default: File.join("./tmp/jiminy/results.yml")
|
50
|
+
|
51
|
+
def initialize
|
52
|
+
apply_defaults!
|
53
|
+
end
|
54
|
+
|
55
|
+
def repo_path
|
56
|
+
[project_username, project_reponame].join("/")
|
57
|
+
end
|
58
|
+
|
59
|
+
private
|
60
|
+
|
61
|
+
def defined_configs_with_defaults
|
62
|
+
self.class.defined_configs.select { |_config_name, options| options[:default] }
|
63
|
+
end
|
64
|
+
|
65
|
+
def required_configs
|
66
|
+
self.class.defined_configs.select { |_config_name, options| options[:required] }
|
67
|
+
end
|
68
|
+
|
69
|
+
def apply_defaults!
|
70
|
+
defined_configs_with_defaults.each do |config_name, options|
|
71
|
+
public_send(:"#{config_name}=", options[:default])
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def raise_missing_required(config_name)
|
76
|
+
return unless required_configs.key?(config_name)
|
77
|
+
|
78
|
+
raise MissingConfigError, config_name
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
module ConfigurationMethods
|
83
|
+
def configure(&block)
|
84
|
+
block.call(configuration)
|
85
|
+
end
|
86
|
+
|
87
|
+
def configuration
|
88
|
+
@_configuration ||= Configuration.new
|
89
|
+
end
|
90
|
+
|
91
|
+
alias config configuration
|
92
|
+
|
93
|
+
def configured?
|
94
|
+
!!configuration
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module GithubAPIable
|
5
|
+
require "octokit"
|
6
|
+
|
7
|
+
private
|
8
|
+
|
9
|
+
def env_config
|
10
|
+
@_env_config ||= Reporting::CIProviders::Github::Configuration.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def client
|
14
|
+
@_client ||= Octokit::Client.new(access_token: env_config.github_token)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Recording
|
5
|
+
class NPlusOne
|
6
|
+
attr_reader :file, :location
|
7
|
+
|
8
|
+
LOCATION_MATCHER = /(?<file>.+\.rb):
|
9
|
+
(?<line>\d+):in\s`
|
10
|
+
(?:block\sin\s)?
|
11
|
+
(?<method_name>.+)'
|
12
|
+
/x.freeze
|
13
|
+
|
14
|
+
EXAMPLES_COUNT = 3
|
15
|
+
|
16
|
+
def initialize(location:, queries: [])
|
17
|
+
@location = location.to_s.strip
|
18
|
+
@queries = queries
|
19
|
+
match = location.match(LOCATION_MATCHER)
|
20
|
+
@line = match[:line]
|
21
|
+
@method_name = match[:method_name]
|
22
|
+
@file = match[:file]
|
23
|
+
freeze
|
24
|
+
end
|
25
|
+
|
26
|
+
def ==(other)
|
27
|
+
location == other.location
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_h
|
31
|
+
{
|
32
|
+
location => attributes
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
attr_reader :queries, :line, :method_name
|
39
|
+
|
40
|
+
def attributes
|
41
|
+
{
|
42
|
+
"file" => file,
|
43
|
+
"line" => line,
|
44
|
+
"method" => method_name,
|
45
|
+
"examples" => queries.take(EXAMPLES_COUNT)
|
46
|
+
}
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Recording
|
5
|
+
module ProsopiteExt
|
6
|
+
require_relative "tmp_file_recorder"
|
7
|
+
|
8
|
+
module SendNotificationsWithTmpFile
|
9
|
+
def prepare_results_file!
|
10
|
+
TmpFileRecorder.prepare_results_file!
|
11
|
+
end
|
12
|
+
|
13
|
+
def tmp_file=(value)
|
14
|
+
@tmp_file = value
|
15
|
+
end
|
16
|
+
|
17
|
+
def tmp_file
|
18
|
+
!!@tmp_file
|
19
|
+
end
|
20
|
+
|
21
|
+
def send_notifications
|
22
|
+
super
|
23
|
+
|
24
|
+
return unless Prosopite.tmp_file
|
25
|
+
|
26
|
+
# https://github.com/charkost/prosopite/blob/main/lib/prosopite.rb#L157
|
27
|
+
tc[:prosopite_notifications].each do |queries, backtrace|
|
28
|
+
absolute_location = backtrace.detect { |path| path.exclude?(Bundler.bundle_path.to_s) }
|
29
|
+
next unless absolute_location
|
30
|
+
|
31
|
+
relative_location = absolute_location.gsub("#{Rails.root.realpath}/", "")
|
32
|
+
tmp_file_recorder.record(location: relative_location, queries: queries)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def tmp_file_recorder
|
39
|
+
@_tmp_file_recorder ||= TmpFileRecorder.new
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Recording
|
5
|
+
module ProsopiteExt
|
6
|
+
class TmpFileRecorder
|
7
|
+
require_relative "../n_plus_one"
|
8
|
+
|
9
|
+
def record(location:, queries:)
|
10
|
+
yaml_content = File.read(Jiminy.config.temp_file_location)
|
11
|
+
array = YAML.safe_load(yaml_content)
|
12
|
+
n_plus_one = NPlusOne.new(location: location, queries: queries)
|
13
|
+
|
14
|
+
array << n_plus_one.to_h unless location_in_array?(location, array) || filepath_ignored?(n_plus_one.file)
|
15
|
+
File.write(Jiminy.config.temp_file_location, array.to_yaml)
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def location_in_array?(location, array)
|
21
|
+
array.detect { |hash| hash.key?(location) }
|
22
|
+
end
|
23
|
+
|
24
|
+
def filepath_ignored?(filepath)
|
25
|
+
ignored_files.include?(filepath)
|
26
|
+
end
|
27
|
+
|
28
|
+
def ignored_files
|
29
|
+
@_ignored_files ||= load_ignored_files
|
30
|
+
end
|
31
|
+
|
32
|
+
def load_ignored_files
|
33
|
+
return [] unless File.exist?(Jiminy.config.ignore_file_path)
|
34
|
+
|
35
|
+
YAML.load_file(Jiminy.config.ignore_file_path) || []
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Recording
|
5
|
+
require "jiminy/recording/prosopite_ext/send_notifications_with_tmp_file"
|
6
|
+
|
7
|
+
module RSpec
|
8
|
+
require "prosopite"
|
9
|
+
require "jiminy/recording/test_controller_concern"
|
10
|
+
|
11
|
+
::Prosopite.singleton_class.prepend ProsopiteExt::SendNotificationsWithTmpFile
|
12
|
+
|
13
|
+
def wrap_rspec_example(example)
|
14
|
+
Prosopite.tmp_file = true
|
15
|
+
ActionController::Base.include(Jiminy::Recording::TestControllerConcern)
|
16
|
+
example.run
|
17
|
+
Prosopite.tmp_file = false
|
18
|
+
end
|
19
|
+
Jiminy.extend(self)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Recording
|
5
|
+
module TestControllerConcern
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
around_action :_test_n_plus_one_detection
|
10
|
+
end
|
11
|
+
|
12
|
+
private
|
13
|
+
|
14
|
+
def _test_n_plus_one_detection
|
15
|
+
yield and return if Prosopite.scan?
|
16
|
+
|
17
|
+
begin
|
18
|
+
Prosopite.scan
|
19
|
+
yield
|
20
|
+
ensure
|
21
|
+
Prosopite.finish
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Recording
|
5
|
+
abort("Jiminy::Recording must be run from a Rails app") unless defined?(Rails)
|
6
|
+
|
7
|
+
module_function
|
8
|
+
|
9
|
+
def reset_results_file!
|
10
|
+
Rails.root.join(File.dirname(Jiminy.config.temp_file_location)).mkpath
|
11
|
+
File.write(Jiminy.config.temp_file_location, [].to_yaml)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Reporting
|
5
|
+
module CIProviders
|
6
|
+
module CircleCI
|
7
|
+
class APIRequest
|
8
|
+
API_BASE = "https://circleci.com/api/v2/"
|
9
|
+
|
10
|
+
CIRCLE_TOKEN_HEADER = "Circle-Token"
|
11
|
+
|
12
|
+
def initialize(path)
|
13
|
+
@url = URI.join(API_BASE, path)
|
14
|
+
end
|
15
|
+
|
16
|
+
def perform!
|
17
|
+
puts url
|
18
|
+
response
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
attr_reader :url
|
24
|
+
|
25
|
+
def response
|
26
|
+
@_response ||= http.request(request)
|
27
|
+
end
|
28
|
+
|
29
|
+
def request
|
30
|
+
@_request ||= Net::HTTP::Get.new(url).tap do |req|
|
31
|
+
req[CIRCLE_TOKEN_HEADER] = Jiminy.config.circle_ci_api_token
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def http
|
36
|
+
@_http ||= Net::HTTP.new(url.host, url.port).tap do |http_instance|
|
37
|
+
http_instance.use_ssl = true
|
38
|
+
http_instance.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Reporting
|
5
|
+
module CIProviders
|
6
|
+
module CircleCI
|
7
|
+
class Artifact < Base
|
8
|
+
define_attribute_readers :url
|
9
|
+
|
10
|
+
def self.all(job_number:)
|
11
|
+
fetch_api_resource("project/gh/#{Jiminy.config.repo_path}/#{job_number}/artifacts")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Reporting
|
5
|
+
module CIProviders
|
6
|
+
module CircleCI
|
7
|
+
require_relative "api_request"
|
8
|
+
|
9
|
+
class Base
|
10
|
+
require "uri"
|
11
|
+
require "net/http"
|
12
|
+
require "json"
|
13
|
+
|
14
|
+
attr_reader :attributes
|
15
|
+
|
16
|
+
def self.define_attribute_readers(*attr_names)
|
17
|
+
attr_names.each do |attr_name|
|
18
|
+
define_method(attr_name, -> { attributes[__callee__.to_s] })
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
class << self
|
23
|
+
attr_reader :next_token
|
24
|
+
end
|
25
|
+
|
26
|
+
class << self
|
27
|
+
attr_writer :next_token
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.fetch_api_resource(path)
|
31
|
+
response = APIRequest.new(path).perform!
|
32
|
+
raise "Error response: #{response.body}" unless response.is_a?(Net::HTTPOK)
|
33
|
+
|
34
|
+
json_body = JSON.parse(response.read_body)
|
35
|
+
self.next_token = json_body["next_page_token"]
|
36
|
+
json_body["items"].map { |attributes| new(attributes) }
|
37
|
+
end
|
38
|
+
|
39
|
+
def initialize(attributes = {})
|
40
|
+
@attributes = attributes.transform_keys!(&:to_s)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Reporting
|
5
|
+
module CIProviders
|
6
|
+
module CircleCI
|
7
|
+
class Job < Base
|
8
|
+
define_attribute_readers :job_number
|
9
|
+
|
10
|
+
def self.all(workflow_id:)
|
11
|
+
fetch_api_resource("workflow/#{workflow_id}/job")
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Reporting
|
5
|
+
module CIProviders
|
6
|
+
module CircleCI
|
7
|
+
require_relative "base"
|
8
|
+
require_relative "vcs"
|
9
|
+
|
10
|
+
class Pipeline < Base
|
11
|
+
MAX_PAGE_LOOKUP = 20
|
12
|
+
|
13
|
+
define_attribute_readers :id, :project_slug, :vcs
|
14
|
+
|
15
|
+
def self.find_by_revision(git_revision:, pr_number:)
|
16
|
+
attempt_count = 0
|
17
|
+
matching_pipeline = nil
|
18
|
+
until matching_pipeline || attempt_count >= MAX_PAGE_LOOKUP
|
19
|
+
page_pipelines = fetch_page_from_api(next_token)
|
20
|
+
matching_pipeline = page_pipelines.detect { |p| pipeline_match?(p, git_revision, pr_number) }
|
21
|
+
attempt_count += 1
|
22
|
+
end
|
23
|
+
matching_pipeline
|
24
|
+
end
|
25
|
+
|
26
|
+
def self.fetch_page_from_api(page_token)
|
27
|
+
query = "org-slug=gh/#{Jiminy.config.project_username}&mine=false"
|
28
|
+
query += "&page-token=#{page_token}" if page_token
|
29
|
+
url = "pipeline?#{query}"
|
30
|
+
fetch_api_resource(url)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.pipeline_match?(pipeline, git_revision, _pr_number)
|
34
|
+
return false unless pipeline.project_slug.to_s.downcase.end_with?(Jiminy.config.repo_path.downcase)
|
35
|
+
|
36
|
+
pipeline.vcs.revision == git_revision
|
37
|
+
|
38
|
+
# TODO: Get PR comparison working too
|
39
|
+
# pipeline.vcs.review_url.end_with?("pull/#{pr_number}")
|
40
|
+
end
|
41
|
+
|
42
|
+
def vcs
|
43
|
+
VCS.new(attributes["vcs"])
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Jiminy
|
4
|
+
module Reporting
|
5
|
+
module CIProviders
|
6
|
+
module CircleCI
|
7
|
+
class Workflow < Base
|
8
|
+
# Link to states: https://circleci.com/docs/2.0/workflows/#states
|
9
|
+
SUCCESS = "success"
|
10
|
+
FAILED = "failed"
|
11
|
+
NOT_RUN = "not run"
|
12
|
+
RUNNING = "running"
|
13
|
+
|
14
|
+
define_attribute_readers :id, :name, :status
|
15
|
+
|
16
|
+
def self.find(pipeline_id:, workflow_name:)
|
17
|
+
url = "pipeline/#{pipeline_id}/workflow"
|
18
|
+
collection = fetch_api_resource(url)
|
19
|
+
collection.detect { |w| w.name.to_s == workflow_name.to_s }
|
20
|
+
end
|
21
|
+
|
22
|
+
def success?
|
23
|
+
status == SUCCESS
|
24
|
+
end
|
25
|
+
|
26
|
+
def running?
|
27
|
+
status == RUNNING
|
28
|
+
end
|
29
|
+
|
30
|
+
def not_run?
|
31
|
+
status == NOT_RUN
|
32
|
+
end
|
33
|
+
|
34
|
+
def failed?
|
35
|
+
status == FAILED
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|