├── README.md ├── docs ├── CNAME ├── _config.yml └── README.md ├── .rspec ├── gemfiles ├── .bundle │ └── config └── minimums.gemfile ├── lib ├── sleet │ ├── version.rb │ ├── error.rb │ ├── circle_ci.rb │ ├── rspec_file_merger.rb │ ├── branch.rb │ ├── artifact_downloader.rb │ ├── build.rb │ ├── fetch_command.rb │ ├── build_selector.rb │ ├── repo.rb │ ├── job_fetcher.rb │ ├── local_repo.rb │ ├── cli.rb │ └── config.rb └── sleet.rb ├── exe └── sleet ├── .sleet.yml ├── .editorconfig ├── bin ├── setup ├── release └── console ├── Rakefile ├── Gemfile ├── Appraisals ├── spec ├── models │ ├── sleet_spec.rb │ └── sleet │ │ └── circle_ci_spec.rb ├── cli │ ├── version_spec.rb │ └── fetch_spec.rb ├── spec_helper.rb └── support │ ├── cli_helper.rb │ └── git_helper.rb ├── .github └── dependabot.yml ├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── LICENSE ├── sleet.gemspec ├── .circleci └── config.yml ├── CODE_OF_CONDUCT.md └── CHANGELOG.md /README.md: -------------------------------------------------------------------------------- 1 | docs/README.md -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | sleet.dev 2 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /lib/sleet/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | VERSION = '0.6.0' 5 | end 6 | -------------------------------------------------------------------------------- /exe/sleet: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'sleet' 5 | 6 | Sleet::Cli.start(ARGV) 7 | -------------------------------------------------------------------------------- /.sleet.yml: -------------------------------------------------------------------------------- 1 | output_file: 'spec/.rspec_example_statuses' 2 | username: coreyja 3 | workflows: 4 | test: spec/.rspec_example_statuses 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task default: :spec 9 | -------------------------------------------------------------------------------- /lib/sleet/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class Error < ::Thor::Error 5 | def message 6 | "ERROR: #{super}".red 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | group :test do 8 | gem 'rspec_junit_formatter' 9 | gem 'rubocop-junit-formatter' 10 | end 11 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | appraise "minimums" do 2 | gem "colorize", "0.8.1" 3 | gem "faraday", "1.0" 4 | gem "rspec", "3.3.0" 5 | gem "rugged", "1.1" 6 | gem "terminal-table", "1.8" 7 | gem "thor", "0.20" 8 | end 9 | -------------------------------------------------------------------------------- /spec/models/sleet_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Sleet, type: :model do 6 | it 'has a version number' do 7 | expect(described_class::VERSION).not_to be nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "10:00" 8 | open-pull-requests-limit: 5 9 | allow: 10 | - dependency-type: direct 11 | - dependency-type: indirect 12 | -------------------------------------------------------------------------------- /bin/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | bundle exec gem bump -v $1 5 | bundle exec github_changelog_generator -u coreyja -p sleet --future-release v$(bundle exec exe/sleet version --bare) 6 | git add -A 7 | bundle exec git commit --amend --no-edit 8 | bundle exec gem release -tp 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /.ruby-version 5 | /_yardoc/ 6 | /coverage/ 7 | /doc/ 8 | /pkg/ 9 | /spec/reports/ 10 | /tmp/ 11 | *.gem 12 | 13 | # Ignore Rspec Example Status File 14 | spec/.rspec_example_statuses 15 | 16 | .envrc 17 | 18 | gemfiles/*.gemfile.lock 19 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'sleet' 6 | 7 | require 'pry' 8 | 9 | # You can add fixtures and/or initialization code here to make experimenting 10 | # with your gem easier. You can also use a different console, if you like. 11 | 12 | Pry.start 13 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | rubocop-coreyja: 3 | - config/default.yml 4 | 5 | inherit_from: .rubocop_todo.yml 6 | 7 | AllCops: 8 | TargetRubyVersion: 2.4 9 | 10 | RSpec/MultipleExpectations: 11 | Enabled: false 12 | 13 | RSpec/DescribeClass: 14 | Exclude: 15 | - 'spec/cli/**/*.rb' 16 | 17 | RSpec/ExampleLength: 18 | Max: 10 19 | -------------------------------------------------------------------------------- /gemfiles/minimums.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "colorize", "0.8.1" 6 | gem "faraday", "1.0" 7 | gem "rspec", "3.3.0" 8 | gem "rugged", "1.1" 9 | gem "terminal-table", "1.8" 10 | gem "thor", "0.20" 11 | 12 | group :test do 13 | gem "rspec_junit_formatter" 14 | gem "rubocop-junit-formatter" 15 | end 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /lib/sleet/circle_ci.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'singleton' 4 | 5 | module Sleet 6 | class CircleCi 7 | def self.get(url, token) 8 | connection.get(url, 'circle-token' => token) 9 | end 10 | 11 | def self.connection 12 | Faraday.new do |b| 13 | b.use FaradayMiddleware::FollowRedirects 14 | b.adapter :net_http 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/cli/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'sleet version', type: :cli do 6 | it 'has the correct prefix' do 7 | expect_command('version').to output(/^Sleet v/).to_stdout.and output_nothing.to_stderr 8 | end 9 | 10 | it 'outputs only the version when given the bare option' do 11 | expect_command('version --bare').to output(/\d+\.\d+\.\d+/).to_stdout.and without_error 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/sleet/rspec_file_merger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class RspecFileMerger 5 | def initialize(files) 6 | @files = files 7 | end 8 | 9 | def output 10 | RSpec::Core::ExampleStatusDumper.dump(sorted_examples) 11 | end 12 | 13 | private 14 | 15 | attr_reader :files 16 | 17 | def sorted_examples 18 | examples.sort_by { |hash| hash[:example_id] } 19 | end 20 | 21 | def examples 22 | files.flat_map do |file| 23 | RSpec::Core::ExampleStatusParser.parse(file) 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2018-03-08 00:46:21 -0500 using RuboCop version 0.53.0. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Offense count: 1 10 | Metrics/AbcSize: 11 | Max: 18 12 | 13 | # Offense count: 1 14 | # Configuration parameters: Max. 15 | RSpec/NestedGroups: 16 | Exclude: 17 | - 'spec/cli/fetch_spec.rb' 18 | -------------------------------------------------------------------------------- /lib/sleet/branch.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class Branch 5 | def initialize(circle_ci_token:, github_user:, github_repo:, branch:) 6 | @circle_ci_token = circle_ci_token 7 | @github_user = github_user 8 | @github_repo = github_repo 9 | @branch = CGI.escape(branch) 10 | end 11 | 12 | def builds 13 | @builds ||= JSON.parse(Sleet::CircleCi.get(url, circle_ci_token).body) 14 | end 15 | 16 | private 17 | 18 | attr_reader :github_user, :github_repo, :branch, :circle_ci_token 19 | 20 | def url 21 | "https://circleci.com/api/v1.1/project/github/#{github_user}/#{github_repo}/tree/#{branch}" \ 22 | '?filter=completed&limit=100' 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/sleet/artifact_downloader.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class ArtifactDownloader 5 | def initialize(circle_ci_token:, artifacts:, file_name:) 6 | @circle_ci_token = circle_ci_token 7 | @artifacts = artifacts 8 | @file_name = file_name 9 | end 10 | 11 | def files 12 | @files ||= urls.map do |url| 13 | Sleet::CircleCi.get(url, circle_ci_token) 14 | end.map(&:body) 15 | end 16 | 17 | private 18 | 19 | attr_reader :artifacts, :file_name, :circle_ci_token 20 | 21 | def urls 22 | rspec_artifacts.map { |x| x['url'] } 23 | end 24 | 25 | def rspec_artifacts 26 | artifacts.select { |x| x['path'].end_with?(file_name) } 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/sleet/build.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class Build 5 | attr_reader :build_num 6 | 7 | def initialize(circle_ci_token:, github_user:, github_repo:, build_num:) 8 | @circle_ci_token = circle_ci_token 9 | @github_user = github_user 10 | @github_repo = github_repo 11 | @build_num = build_num 12 | end 13 | 14 | def artifacts 15 | @artifacts ||= JSON.parse(Sleet::CircleCi.get(url, circle_ci_token).body) 16 | end 17 | 18 | private 19 | 20 | attr_reader :github_user, :github_repo, :circle_ci_token 21 | 22 | def url 23 | "https://circleci.com/api/v1.1/project/github/#{github_user}/#{github_repo}/#{build_num}/artifacts" # rubocop:disable Metrics/LineLength 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/sleet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'colorize' 4 | require 'faraday' 5 | require 'faraday_middleware' 6 | require 'forwardable' 7 | require 'json' 8 | require 'rspec' 9 | require 'rugged' 10 | require 'terminal-table' 11 | require 'thor' 12 | require 'yaml' 13 | 14 | # This is to load the classes that are defined in the same file as this one 15 | # We are most definitely relying on Private API here 16 | begin 17 | RSpec::Core::ExampleStatusPersister 18 | end 19 | 20 | require 'sleet/artifact_downloader' 21 | require 'sleet/branch' 22 | require 'sleet/build' 23 | require 'sleet/build_selector' 24 | require 'sleet/circle_ci' 25 | require 'sleet/config' 26 | require 'sleet/error' 27 | require 'sleet/fetch_command' 28 | require 'sleet/job_fetcher' 29 | require 'sleet/local_repo' 30 | require 'sleet/repo' 31 | require 'sleet/rspec_file_merger' 32 | require 'sleet/version' 33 | 34 | require 'sleet/cli' 35 | 36 | module Sleet 37 | end 38 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path('../lib', __dir__) 4 | require 'sleet' 5 | 6 | require 'English' 7 | require 'fileutils' 8 | require 'securerandom' 9 | require 'tmpdir' 10 | 11 | require 'webmock/rspec' 12 | require 'pry' 13 | 14 | WebMock.disable_net_connect! 15 | 16 | Dir[File.dirname(__FILE__) + '/support/**/*.rb'].each { |f| require f } 17 | 18 | RSpec.configure do |c| 19 | c.expect_with :rspec do |config| 20 | config.include_chain_clauses_in_custom_matcher_descriptions = true 21 | end 22 | c.example_status_persistence_file_path = 'spec/.rspec_example_statuses' 23 | 24 | c.filter_run focus: true 25 | c.run_all_when_everything_filtered = true 26 | 27 | c.before :each, type: :cli do 28 | extend CliHelper 29 | extend GitHelper 30 | end 31 | 32 | c.around :each, type: :cli do |example| 33 | Dir.mktmpdir do |spec_dir| 34 | Dir.chdir(spec_dir) do 35 | example.run 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/sleet/fetch_command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class FetchCommand 5 | def initialize(config) 6 | @config = config 7 | end 8 | 9 | def do! 10 | error_messages = [] 11 | fetchers.map do |fetcher| 12 | begin 13 | fetcher.do! 14 | rescue Sleet::Error => e 15 | error_messages << e.message 16 | end 17 | end 18 | raise Thor::Error, error_messages.join("\n") unless error_messages.empty? 19 | end 20 | 21 | private 22 | 23 | attr_reader :config 24 | 25 | def fetchers 26 | job_name_to_output_files.map do |job_name, output_filename| 27 | Sleet::JobFetcher.new( 28 | config: config, 29 | output_filename: output_filename, 30 | repo: repo, 31 | job_name: job_name 32 | ) 33 | end 34 | end 35 | 36 | def job_name_to_output_files 37 | config.workflows || { nil => config.output_file } 38 | end 39 | 40 | def repo 41 | @repo ||= Sleet::Repo.from_config(config) 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Corey Alexander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/sleet/build_selector.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class BuildSelector 5 | def initialize(repo:, job_name:) 6 | @repo = repo 7 | @job_name = job_name 8 | end 9 | 10 | def build 11 | @build ||= repo.build_for(chosen_build_num) 12 | end 13 | 14 | def validate! 15 | must_find_a_build! 16 | chosen_build_must_have_input_file! 17 | end 18 | 19 | private 20 | 21 | attr_reader :repo, :job_name 22 | 23 | def branch 24 | repo.branch 25 | end 26 | 27 | def chosen_build_num 28 | chosen_build_json['build_num'] 29 | end 30 | 31 | def chosen_build_json 32 | branch.builds.find do |b| 33 | b.fetch('workflows', nil)&.fetch('job_name', nil) == job_name 34 | end 35 | end 36 | 37 | def must_find_a_build! 38 | !chosen_build_json.nil? || 39 | raise(Error, "No builds found#{" for job name [#{job_name}]" if job_name}") 40 | end 41 | 42 | def chosen_build_must_have_input_file! 43 | build.artifacts.any? || 44 | raise(Error, "No Rspec example file found in the latest build (##{chosen_build_num})") 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/support/cli_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'open3' 4 | 5 | module CliHelper 6 | def cli_executable_path 7 | "#{__dir__}/../../exe/sleet" 8 | end 9 | 10 | def expect_command(cmd) 11 | expect { Sleet::Cli.start(cmd.split(' ')) } 12 | end 13 | 14 | def error_with(message) 15 | exit_with_code(1).and(output(message.red + "\n").to_stderr) 16 | end 17 | 18 | def without_error 19 | output_nothing.to_stderr 20 | end 21 | 22 | RSpec::Matchers.define_negated_matcher(:output_nothing, :output) 23 | 24 | RSpec::Matchers.define :exit_with_code do |exp_code| 25 | supports_block_expectations 26 | 27 | actual = nil 28 | match do |block| 29 | begin 30 | block.call 31 | rescue SystemExit => e 32 | actual = e.status 33 | end 34 | actual && (actual == exp_code) 35 | end 36 | failure_message do |_block| 37 | "expected block to call exit(#{exp_code}) but exit" + 38 | (actual.nil? ? ' was not called' : "(#{actual}) was called") 39 | end 40 | failure_message_when_negated do |_block| 41 | "expected block not to call exit(#{exp_code})" 42 | end 43 | description do 44 | "expect block to call exit(#{exp_code})" 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /lib/sleet/repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class Repo 5 | def self.from_config(config) 6 | local_repo = Sleet::LocalRepo.new(source_dir: config.source_dir) 7 | 8 | new( 9 | circle_ci_token: config.circle_ci_token, 10 | username: config.username || local_repo.username, 11 | project: config.project || local_repo.project, 12 | branch_name: config.branch || local_repo.branch_name 13 | ) 14 | end 15 | 16 | def initialize(circle_ci_token:, username:, project:, branch_name:) 17 | @circle_ci_token = circle_ci_token 18 | @github_user = username 19 | @github_repo = project 20 | @branch_name = branch_name 21 | end 22 | 23 | def build_for(build_num) 24 | Sleet::Build.new( 25 | circle_ci_token: circle_ci_token, 26 | github_user: github_user, 27 | github_repo: github_repo, 28 | build_num: build_num 29 | ) 30 | end 31 | 32 | def branch 33 | @branch ||= Sleet::Branch.new( 34 | circle_ci_token: circle_ci_token, 35 | github_user: github_user, 36 | github_repo: github_repo, 37 | branch: branch_name 38 | ) 39 | end 40 | 41 | private 42 | 43 | attr_reader :circle_ci_token, :github_user, :github_repo, :branch_name 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/models/sleet/circle_ci_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe Sleet::CircleCi, type: :model do 6 | describe '.get' do 7 | context 'without server redirect' do 8 | let!(:stubbed_request) do 9 | stub_request(:get, 'http://circleci.com').with(query: { 'circle-token' => 'FAKE_TOKEN' }) 10 | end 11 | 12 | it 'adds the token as a query param and only reads the token from disk once' do 13 | described_class.get 'http://circleci.com', 'FAKE_TOKEN' 14 | expect(stubbed_request).to have_been_requested.once 15 | end 16 | end 17 | 18 | context 'with server redirect' do 19 | let!(:stubbed_redirect) do 20 | stub_request(:get, 'http://circleci.com') 21 | .with(query: { 'circle-token' => 'FAKE_TOKEN' }) 22 | .to_return( 23 | status: 301, 24 | body: 'Your are being redirected', 25 | headers: { Location: 'http://s3.amazonaws.com/file' } 26 | ) 27 | end 28 | let!(:stubbed_request) do 29 | stub_request(:get, 'http://s3.amazonaws.com/file') 30 | end 31 | 32 | it 'adds the token as a query param and only reads the token from disk once' do 33 | described_class.get 'http://circleci.com', 'FAKE_TOKEN' 34 | expect(stubbed_redirect).to have_been_requested.once 35 | expect(stubbed_request).to have_been_requested.once 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/sleet/job_fetcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class JobFetcher 5 | def initialize(config:, output_filename:, job_name:, repo:) 6 | @circle_ci_token = config.circle_ci_token 7 | @source_dir = config.source_dir 8 | @input_filename = config.input_file 9 | @output_filename = output_filename 10 | @job_name = job_name 11 | @repo = repo 12 | end 13 | 14 | def do! 15 | validate! 16 | create_output_file! 17 | end 18 | 19 | private 20 | 21 | attr_reader :input_filename, :output_filename, :job_name, :source_dir, :repo, :circle_ci_token 22 | 23 | def validate! 24 | build_selector.validate! 25 | end 26 | 27 | def create_output_file! 28 | File.write(File.join(source_dir, output_filename), combined_file) 29 | puts "Created file (#{output_filename}) from build (##{build.build_num})".green 30 | end 31 | 32 | def combined_file 33 | Sleet::RspecFileMerger.new(build_persistance_artifacts).output 34 | end 35 | 36 | def build_persistance_artifacts 37 | @build_persistance_artifacts ||= Sleet::ArtifactDownloader.new( 38 | file_name: input_filename, 39 | artifacts: build.artifacts, 40 | circle_ci_token: circle_ci_token 41 | ).files 42 | end 43 | 44 | def build 45 | build_selector.build 46 | end 47 | 48 | def build_selector 49 | @build_selector ||= Sleet::BuildSelector.new(job_name: job_name, repo: repo) 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/support/git_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module GitHelper 4 | DEFAULT_BRANCH = 'main' 5 | 6 | def create_commit(repo) 7 | index = add_file_to_index(repo) 8 | Rugged::Commit.create(repo, 9 | author: fake_author, 10 | message: 'Hello world', 11 | committer: fake_author, 12 | parents: repo.empty? ? [] : [repo.head.target].compact, 13 | tree: index.write_tree(repo), 14 | update_ref: 'HEAD') 15 | 16 | create_default_branch repo 17 | end 18 | 19 | def create_default_branch(repo) 20 | return if repo.branches[DEFAULT_BRANCH] 21 | 22 | repo.branches.create(DEFAULT_BRANCH, repo.head.target_id, force: true) 23 | repo.checkout(DEFAULT_BRANCH) 24 | end 25 | 26 | def assign_upstream(repo, local_branch, remote_branch) 27 | path = "#{repo.path}/refs/remotes/#{remote_branch}" 28 | dirname = File.dirname(path) 29 | File.directory?(dirname) || FileUtils.mkdir_p(dirname) 30 | File.write(path, repo.head.target.tree_id) 31 | repo.branches[local_branch].upstream = repo.branches[remote_branch] 32 | end 33 | 34 | def remove_upstream(repo, local_branch) 35 | repo.branches[local_branch].upstream = nil 36 | end 37 | 38 | private 39 | 40 | def add_file_to_index(repo) 41 | content = "This is a random blob. #{SecureRandom.uuid}" 42 | filename = 'README.md' 43 | oid = repo.write(content, :blob) 44 | index = repo.index 45 | index.read_tree(repo.head.target.tree) unless repo.empty? 46 | index.add(path: filename, oid: oid, mode: 0o100644) 47 | index 48 | end 49 | 50 | def fake_author 51 | { email: 'email@example.com', time: Time.now, name: 'Person' } 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /sleet.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'sleet/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = 'sleet' 9 | spec.version = Sleet::VERSION 10 | spec.authors = ['Corey Alexander'] 11 | spec.email = ['coreyja@gmail.com'] 12 | 13 | spec.summary = 'CircleCI RSpec Status Persistance File Aggregator' 14 | spec.description = <<~DOC 15 | Sleet provides an easy way to grab the most recent Rspec persistance files from CircleCI. 16 | It also aggregates the artifacts from CircleCI, since you will have 1 per build container. 17 | DOC 18 | spec.homepage = 'https://github.com/coreyja/sleet' 19 | spec.license = 'MIT' 20 | 21 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 22 | f.match(%r{^(test|spec|features)/}) 23 | end 24 | spec.bindir = 'exe' 25 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 26 | spec.require_paths = ['lib'] 27 | 28 | spec.add_dependency 'colorize', '~> 0.8.1' 29 | spec.add_dependency 'faraday', '>= 1.0' 30 | spec.add_dependency 'faraday_middleware' 31 | spec.add_dependency 'rspec', '~> 3.3' 32 | spec.add_dependency 'rugged', '>= 1.1', '< 1.7' 33 | spec.add_dependency 'terminal-table', '~> 1.8' 34 | spec.add_dependency 'thor', '>= 0.20', '< 1.3' 35 | 36 | spec.add_development_dependency 'gem-release', '= 2.1.1' 37 | spec.add_development_dependency 'github_changelog_generator', '~> 1.14' 38 | spec.add_development_dependency 'pry', '~> 0.10' 39 | spec.add_development_dependency 'rake', '~> 13.0' 40 | spec.add_development_dependency 'rubocop-coreyja', '0.4.0' 41 | spec.add_development_dependency 'webmock', '~> 3.18.1' 42 | spec.add_development_dependency 'appraisal', '~> 2.4.1' 43 | end 44 | -------------------------------------------------------------------------------- /lib/sleet/local_repo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class LocalRepo 5 | REMOTE_BRANCH_REGEX = %r{^([^\/.]+)\/(.+)}.freeze 6 | CURRENT_BRANCH_REGEX = %r{^refs\/heads\/}.freeze 7 | GITHUB_MATCH_REGEX = %r{github.com[:\/](.+)\/(.+)\.git}.freeze 8 | 9 | def initialize(source_dir:) 10 | @source_dir = source_dir 11 | end 12 | 13 | def username 14 | validate! 15 | 16 | github_match[1] 17 | end 18 | 19 | def project 20 | validate! 21 | 22 | github_match[2] 23 | end 24 | 25 | def branch_name 26 | validate! 27 | 28 | current_branch.upstream.name.match(REMOTE_BRANCH_REGEX)[2] 29 | end 30 | 31 | private 32 | 33 | attr_reader :source_dir 34 | 35 | def current_branch_name 36 | @current_branch_name ||= repo.head.name.sub(CURRENT_BRANCH_REGEX, '') 37 | end 38 | 39 | def current_branch 40 | @current_branch ||= repo.branches[current_branch_name] 41 | end 42 | 43 | def github_match 44 | @github_match ||= GITHUB_MATCH_REGEX.match(current_branch.remote.url) 45 | end 46 | 47 | def repo 48 | @repo ||= Rugged::Repository.new(source_dir) 49 | end 50 | 51 | def validate! 52 | return if @validated 53 | 54 | must_be_on_branch! 55 | must_have_an_upstream_branch! 56 | upstream_remote_must_be_github! 57 | @validated = true 58 | end 59 | 60 | def must_be_on_branch! 61 | !current_branch.nil? || 62 | raise(Error, 'Not on a branch') 63 | end 64 | 65 | def must_have_an_upstream_branch! 66 | !current_branch.remote.nil? || 67 | raise(Error, "No upstream branch set for the current branch of #{current_branch_name}") 68 | end 69 | 70 | def upstream_remote_must_be_github! 71 | !github_match.nil? || 72 | raise(Error, 'Upstream remote is not GitHub') 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | jobs: 4 | test: 5 | parameters: 6 | docker-image: 7 | type: string 8 | gemfile: 9 | type: string 10 | default: Gemfile 11 | parallelism: 1 12 | docker: 13 | - image: << parameters.docker-image >> 14 | environment: 15 | BUNDLE_GEMFILE: << parameters.gemfile >> 16 | 17 | steps: 18 | - checkout 19 | 20 | # Install 21 | - run: bundle check || (sudo apt-get update && sudo apt-get install cmake && bundle install) 22 | 23 | - run: 24 | command: | 25 | bundle exec rubocop \ 26 | --config .rubocop.yml \ 27 | -r $(bundle show rubocop-junit-formatter)/lib/rubocop/formatter/junit_formatter.rb \ 28 | --format RuboCop::Formatter::JUnitFormatter \ 29 | --out /tmp/test-results/rubocop.xml \ 30 | --format progress \ 31 | --force-exclusion \ 32 | $(circleci tests glob "**/*.rb" | circleci tests split --split-by=filesize --show-counts) 33 | 34 | # Run rspec in parallel 35 | - run: 36 | command: | 37 | bundle exec rspec --profile 10 \ 38 | --format RspecJunitFormatter \ 39 | --out /tmp/test-results/rspec.xml \ 40 | --format progress \ 41 | $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings --show-counts) 42 | 43 | # Save artifacts 44 | - store_test_results: 45 | path: /tmp/test-results 46 | 47 | - store_artifacts: 48 | path: spec/.rspec_example_statuses 49 | 50 | workflows: 51 | version: 2 52 | test-workflow: 53 | jobs: 54 | - test: 55 | matrix: 56 | parameters: 57 | docker-image: 58 | - cimg/ruby:2.7.6 59 | - cimg/ruby:3.0.1 60 | - cimg/ruby:3.1.3 61 | - cimg/ruby:3.2.1 62 | gemfile: 63 | - Gemfile 64 | - gemfiles/minimums.gemfile 65 | exclude: 66 | # We don't test the mins on 3.2 since rugged doesn't install 67 | - docker-image: cimg/ruby:3.2.1 68 | gemfile: gemfiles/minimums.gemfile 69 | -------------------------------------------------------------------------------- /lib/sleet/cli.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class Cli < Thor 5 | default_task :fetch 6 | 7 | def self.exit_on_failure? 8 | true 9 | end 10 | 11 | desc 'fetch', 'Fetch and Aggregate RSpec Persistance Files from CircleCI' 12 | long_desc <<~LONGDESC 13 | `sleet fetch` will find build(s) in CircleCI for the current branch, and 14 | download the chosen Rspec Persistance Files. Since there will be 1 per container, 15 | and builds may have more than 1 container `sleet` will combine all the indivudual 16 | persistance files. 17 | LONGDESC 18 | option :source_dir, type: :string, aliases: [:s], desc: <<~DESC 19 | This is the directory of the source git repo. If a source_dir is NOT given we look up from the current directory for the nearest git repo. 20 | DESC 21 | option :input_file, type: :string, aliases: [:i], desc: <<~DESC 22 | This is the name of the Rspec Circle Persistance File in CircleCI. The default is .rspec_example_statuses. This will match if the full path on CircleCI ends in the given name. 23 | DESC 24 | option :output_file, type: :string, aliases: [:o], desc: <<~DESC 25 | This is the name for the output file, on your local system. It is relative to the source_dir. Will be IGNORED if workflows is provided. 26 | DESC 27 | option :username, type: :string, aliases: [:u], desc: <<~DESC 28 | This is the GitHub username that is referenced by the CircleCI build. By default, Sleet will base this on your upstream git remote. 29 | DESC 30 | option :project, type: :string, aliases: [:p], desc: <<~DESC 31 | This is the GitHub project that is referenced by the CircleCI build. By default, Sleet will base this on your upstream git remote. 32 | DESC 33 | option :branch, type: :string, aliases: [:b], desc: <<~DESC 34 | This is the remote branch that is referenced by the CircleCI build. Sleet will attempt to guess this by default, but if you are pushing to a forked repo, you may need to specify a different branch name (e.g. "pull/1234"). 35 | DESC 36 | option :workflows, type: :hash, aliases: [:w], desc: <<~DESC 37 | To use Sleet with CircleCI Workflows you need to tell Sleet which build(s) to look in, and where each output should be saved. The input is a hash, where the key is the build name and the value is the output_file for that build. Sleet supports saving the artifacts to multiple builds, meaning it can support a mono-repo setup. 38 | DESC 39 | option :print_config, type: :boolean 40 | def fetch 41 | sleet_config = Sleet::Config.new(cli_hash: options, dir: Dir.pwd) 42 | if options[:print_config] 43 | sleet_config.print! 44 | exit 45 | end 46 | raise Sleet::Error, 'circle_ci_token required and not provided' unless sleet_config.circle_ci_token 47 | 48 | Sleet::FetchCommand.new(sleet_config).do! 49 | end 50 | 51 | desc 'version', 'Display the version' 52 | option :bare, type: :boolean, default: false 53 | def version 54 | if options[:bare] 55 | puts Sleet::VERSION 56 | else 57 | puts "Sleet v#{Sleet::VERSION}" 58 | end 59 | end 60 | 61 | desc 'config', 'Print the config' 62 | option :show_sensitive, type: :boolean 63 | def config 64 | Sleet::Config.new(cli_hash: options, dir: Dir.pwd).print! 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at coreyja@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /lib/sleet/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Sleet 4 | class Config # rubocop:disable Metrics/ClassLength 5 | OPTION_FILENAME = '.sleet.yml' 6 | HIDDEN_UNLESS_IN_CLI_OPTIONS = %w[show_sensitive print_config].freeze 7 | ConfigOption = Struct.new(:value, :source) 8 | 9 | def initialize(dir:, cli_hash: {}) 10 | @dir = dir 11 | @cli_hash = cli_hash 12 | end 13 | 14 | def source_dir 15 | options_hash[:source_dir] 16 | end 17 | 18 | def input_file 19 | options_hash[:input_file] 20 | end 21 | 22 | def output_file 23 | options_hash[:output_file] 24 | end 25 | 26 | def username 27 | options_hash[:username] 28 | end 29 | 30 | def project 31 | options_hash[:project] 32 | end 33 | 34 | def branch 35 | options_hash[:branch] 36 | end 37 | 38 | def workflows 39 | options_hash[:workflows] 40 | end 41 | 42 | def circle_ci_token 43 | options_hash[:circle_ci_token] 44 | end 45 | 46 | def print! 47 | puts Terminal::Table.new headings: %w[Option Value Source], rows: table_rows 48 | end 49 | 50 | private 51 | 52 | attr_reader :cli_hash, :dir 53 | 54 | def options 55 | @options ||= default_options.merge(file_options).merge(cli_options) 56 | end 57 | 58 | def options_hash 59 | @options_hash ||= Thor::CoreExt::HashWithIndifferentAccess.new(options.map { |k, o| [k, o.value] }.to_h) 60 | end 61 | 62 | def table_rows 63 | table_options.map do |key, option| 64 | if key.to_sym == :workflows 65 | [key, Terminal::Table.new(headings: ['Job Name', 'Output File'], rows: option.value.to_a), option.source] 66 | elsif key.to_sym == :circle_ci_token && !options['show_sensitive'].value 67 | [key, '**REDACTED**', option.source] 68 | else 69 | [key, option.value, option.source] 70 | end 71 | end 72 | end 73 | 74 | def table_options 75 | options.reject do |key, _option| 76 | HIDDEN_UNLESS_IN_CLI_OPTIONS.include?(key) && !cli_hash.key?(key) 77 | end 78 | end 79 | 80 | def cli_options 81 | build_option_hash('CLI', cli_hash) 82 | end 83 | 84 | def file_options 85 | file_hashes.map do |file, options| 86 | build_option_hash(file, options) 87 | end.reduce({}, :merge) 88 | end 89 | 90 | def file_hashes 91 | files.map { |f| [f, ::YAML.load_file(f) || {}] } 92 | end 93 | 94 | def files 95 | paths_to_search.select { |f| File.file?(f) } 96 | end 97 | 98 | def paths_to_search 99 | directories.each_index.map do |i| 100 | (directories[0..i] + [OPTION_FILENAME]).join('/') 101 | end 102 | end 103 | 104 | def directories 105 | @directories ||= dir.split('/') 106 | end 107 | 108 | def default_options 109 | build_option_hash 'default', default_hash 110 | end 111 | 112 | def default_hash 113 | { 114 | 'source_dir' => File.expand_path(default_dir), 115 | 'input_file' => '.rspec_example_statuses', 116 | 'output_file' => '.rspec_example_statuses', 117 | 'show_sensitive' => false, 118 | 'print_config' => true 119 | } 120 | end 121 | 122 | def default_dir 123 | Rugged::Repository.discover(Dir.pwd).path + '..' 124 | rescue Rugged::RepositoryError 125 | '.' 126 | end 127 | 128 | def build_option_hash(source, options) 129 | options.map do |key, value| 130 | [key, ConfigOption.new(value, source)] 131 | end.to_h 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Sleet ☁️ ❄️ 2 | 3 | [![Gem Version](https://badge.fury.io/rb/sleet.svg)](https://badge.fury.io/rb/sleet) 4 | [![Maintainability](https://api.codeclimate.com/v1/badges/7f346b368d72b53ef630/maintainability)](https://codeclimate.com/github/coreyja/sleet/maintainability) 5 | [![CircleCI](https://circleci.com/gh/coreyja/sleet.svg?style=svg)](https://circleci.com/gh/coreyja/sleet) 6 | [![Join the chat at https://gitter.im/rspec-sleet/community](https://badges.gitter.im/rspec-sleet/community.svg)](https://gitter.im/rspec-sleet/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 7 | 8 | ## Background and Problem 9 | 10 | RSpec has a [feature](https://relishapp.com/rspec/rspec-core/v/3-7/docs/command-line/only-failures) that I find very useful which is the `--only-failures` option. This will re-run only that examples that failed the previous run. 11 | 12 | CircleCI has support for [uploading artifacts](https://circleci.com/docs/2.0/artifacts/) with your builds, which allows us to store the persistance file that powers the RSpec only failures option. 13 | However! CircleCI also supports and encourages parallelizing your build, which means even if you upload your rspec persistance file, you actually have a number of them each containing a subset of your test suite. 14 | This is where `Sleet` comes in! 15 | 16 | ## Purpose 17 | 18 | This tool does two things: 19 | 1. It downloads all of the `.rspec_failed_examples` files that were uploaded to CircleCI for the most recent build of the current branch 20 | 2. It combines the multiple files into a single sorted `.rspec_failed_examples` file, and moves it to the [current directory](https://github.com/coreyja/CRSPFA/issues/1) 21 | 22 | ## Getting Started 23 | 24 | ### 1. Configure RSpec to Create and Use an example persistance file 25 | 26 | We need to set the `example_status_persistence_file_path` config in RSpec. Here are the relevant [RSpec docs](https://relishapp.com/rspec/rspec-core/v/3-7/docs/command-line/only-failures#background). 27 | 28 | The first step is to create(/or add to) your `spec/spec_helper.rb` file. We want to include the following configuration, which tells RSpec where to store the status persistance file. The actual location and file name are up to you, this is just an example. (Though using this name will require less configuration later.) 29 | 30 | ``` 31 | RSpec.configure do |c| 32 | c.example_status_persistence_file_path = ".rspec_example_statuses" 33 | end 34 | ``` 35 | 36 | if you just created the `spec_helper.rb` file then you will need to create a `.rspec` file containing the following to load your new helper file. 37 | 38 | ``` 39 | --require spec_helper 40 | ``` 41 | 42 | Again there are other ways to load your `spec_helper.rb` file, including requiring it from each spec. Pick one that works for you. 43 | 44 | ### 2. Collect the example persistance files in CircleCI 45 | 46 | To do this we need to create a step which [saves](https://circleci.com/docs/2.0/artifacts/) the `.rspec_example_statuses` as artifacts of the build. The following is an example of such a step in CircleCI. This must happen after rspec has run or else the persistance file will not exist. 47 | 48 | ``` 49 | - store_artifacts: 50 | path: .rspec_example_statuses 51 | 52 | ``` 53 | 54 | ### 3. Save a CircleCI Token locally (to access private builds) 55 | 56 | In order to see private builds/repos in CircleCI you will need to get a CircleCI token and save it locally to a Sleet Configuration file. 57 | The recommended approach is to create a yml file in your home directory which contains your the key `circle_ci_token` 58 | 59 | ``` 60 | circle_ci_token: PLACE_TOKEN_HERE 61 | ``` 62 | 63 | An API token can be generated here: [https://circleci.com/account/api](https://circleci.com/account/api) 64 | 65 | ### 4. Run this tool from your project 66 | 67 | ``` 68 | sleet 69 | ``` 70 | 71 | This will look up the latest completed build in CircleCI for this branch, and download all the relevant `.rspec_example_statuses` files. It then combines and sorts them and saves the result to the `.rspec_example_statuses` file locally. 72 | 73 | ### 5. Run RSpec with `--only-failures` 74 | 75 | ``` 76 | bundle exec rspec --only-failures 77 | ``` 78 | 79 | This will run only the examples that failed in CircleCI! 80 | 81 | ## Configuration 82 | 83 | If you are using Worklfows in your CircleCI builds, or you are working with a different persistance file name, you may need to configure Sleet beyond the defaults. 84 | 85 | Sleet currently supports two ways to input configurations: 86 | 87 | 1. Through YML files 88 | - `Sleet` will search 'up' from where the command was run and look for `.sleet.yml` files. It will combine all the files it finds, such that 'deeper' files take presedence. This allows you to have a user-level config at `~/.sleet.yml` and have project specific files which take presendence over the user level config (ex: `~/Projects/foo/.sleet.yml`) 89 | 2. Through the CLI 90 | - These always take presendece the options provided in the YML files 91 | 92 | To view your current configuration use the `sleet config` command which will give you a table of the current configuration. You can also use the `--print-config` flag with the `fetch` command to print out the config, including any other CLI options. This can be useful for bebugging as the output also tells you where each option came from. 93 | 94 | ### Options 95 | 96 | These are the options that are currently supported 97 | 98 | #### `--source_dir` 99 | 100 | Alias: `s` 101 | 102 | This is the directory of the source git repo. If a `source_dir` is NOT given we look up from the current directory for the nearest git repo. 103 | 104 | #### `--input_file` 105 | 106 | Alias: `i` 107 | 108 | This is the name of the Rspec Circle Persistance File in CircleCI. The default is `.rspec_example_statuses` 109 | 110 | This will match if the full path on CircleCI ends in the given name. 111 | 112 | #### `--output_file` 113 | 114 | Alias: `o` 115 | 116 | This is the name for the output file, on your local system. It is relative to the `source_dir`. 117 | 118 | Will be IGNORED if `workflows` is provided. 119 | 120 | #### `--workflows` 121 | 122 | Alias: `w` 123 | 124 | If you are using workflows in CircleCI, then this is for you! You need to tell `Sleet` which build(s) to look in, and where each output should be saved. 125 | The input is a hash, where the key is the build name and the value is the `output_file` for that build. Sleet supports saving the artifacts to multiple builds, meaning it can support a mono-repo setup. 126 | 127 | Build-Test-Deploy Demo: 128 | 129 | For this example you have three jobs in your CircleCI Workflow, `build`, `test` and `deploy`, but only 1 (the `test` build) generate an Rspec persistance file 130 | 131 | This command will pick the `test` build and save its artifacts to the `.rspec_example_statuses` file 132 | 133 | ``` 134 | sleet fetch --workflows test:.rspec_example_statuses 135 | ``` 136 | MonoRepo Demo: 137 | 138 | If you have a mono-repo that contains 3 sub-dirs. `foo`, `bar` and `baz`. And each one has an accompanying build. We can process all these sub-dirs at once with the following workflow command. 139 | 140 | ``` 141 | sleet fetch --workflows foo-test:foo/.rpsec_example_statuses bar-test:bar/.rspec_example_statuses baz-specs:baz/spec/examples.txt 142 | ``` 143 | 144 | #### `--username` 145 | 146 | Alias: `u` 147 | 148 | This is the GitHub username that is referenced by the CircleCI build. By default, Sleet will base this on your upstream git remote. 149 | 150 | #### `--project` 151 | 152 | Alias: `p` 153 | 154 | This is the GitHub project that is referenced by the CircleCI build. By default, Sleet will base this on your upstream git remote. 155 | 156 | #### `--branch` 157 | 158 | Alias: `b` 159 | 160 | This is the remote branch that is referenced by the CircleCI build. Sleet will attempt to guess this by default, but if you are pushing to a forked repo, you may need to specify a different branch name (e.g. "pull/1234"). 161 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.6.0](https://github.com/coreyja/sleet/tree/v0.6.0) (2023-03-11) 4 | 5 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.5.5...v0.6.0) 6 | 7 | **Merged pull requests:** 8 | 9 | - chore: Bump Minimums [\#114](https://github.com/coreyja/sleet/pull/114) ([coreyja](https://github.com/coreyja)) 10 | 11 | ## [v0.5.5](https://github.com/coreyja/sleet/tree/v0.5.5) (2023-03-11) 12 | 13 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.5.4...v0.5.5) 14 | 15 | **Merged pull requests:** 16 | 17 | - feat: Ruby 3.2 Support [\#113](https://github.com/coreyja/sleet/pull/113) ([coreyja](https://github.com/coreyja)) 18 | 19 | ## [v0.5.4](https://github.com/coreyja/sleet/tree/v0.5.4) (2023-03-11) 20 | 21 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.5.3...v0.5.4) 22 | 23 | **Merged pull requests:** 24 | 25 | - Bug: Fix CI by making sure we can install `cmake` [\#112](https://github.com/coreyja/sleet/pull/112) ([coreyja](https://github.com/coreyja)) 26 | - bug: CircleCI removed has\_artifacts from v1.1 API response [\#111](https://github.com/coreyja/sleet/pull/111) ([jesseproudman](https://github.com/jesseproudman)) 27 | 28 | ## [v0.5.3](https://github.com/coreyja/sleet/tree/v0.5.3) (2020-08-10) 29 | 30 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.5.2...v0.5.3) 31 | 32 | **Merged pull requests:** 33 | 34 | - bug: Add support for redirects. [\#80](https://github.com/coreyja/sleet/pull/80) ([temochka](https://github.com/temochka)) 35 | 36 | ## [v0.5.2](https://github.com/coreyja/sleet/tree/v0.5.2) (2020-06-14) 37 | 38 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.5.1...v0.5.2) 39 | 40 | **Merged pull requests:** 41 | 42 | - bug: Fix bug in error message when no upstream exists [\#79](https://github.com/coreyja/sleet/pull/79) ([coreyja](https://github.com/coreyja)) 43 | 44 | ## [v0.5.1](https://github.com/coreyja/sleet/tree/v0.5.1) (2020-06-14) 45 | 46 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.5.0...v0.5.1) 47 | 48 | ## [v0.5.0](https://github.com/coreyja/sleet/tree/v0.5.0) (2020-06-14) 49 | 50 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.4.3...v0.5.0) 51 | 52 | **Closed issues:** 53 | 54 | - Add Support for Forked Repos in Github [\#41](https://github.com/coreyja/sleet/issues/41) 55 | 56 | **Merged pull requests:** 57 | 58 | - Support PR builds from forked repos!!! ✨ [\#77](https://github.com/coreyja/sleet/pull/77) ([smudge](https://github.com/smudge)) 59 | - Update rugged requirement from \>= 0.26, \< 0.100 to \>= 0.26, \< 1.1 [\#76](https://github.com/coreyja/sleet/pull/76) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 60 | - Update rugged requirement from \>= 0.26, \< 0.29 to \>= 0.26, \< 0.100 [\#75](https://github.com/coreyja/sleet/pull/75) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 61 | - Update thor requirement from ~\> 0.20.0 to \>= 0.20, \< 1.1 [\#70](https://github.com/coreyja/sleet/pull/70) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 62 | 63 | ## [v0.4.3](https://github.com/coreyja/sleet/tree/v0.4.3) (2020-03-07) 64 | 65 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.4.2...v0.4.3) 66 | 67 | **Closed issues:** 68 | 69 | - s/Artifcats/Artifacts/ [\#44](https://github.com/coreyja/sleet/issues/44) 70 | 71 | **Merged pull requests:** 72 | 73 | - fix: Replace "artifi🐱s" with "artifacts". [\#74](https://github.com/coreyja/sleet/pull/74) ([temochka](https://github.com/temochka)) 74 | - feat: Look at 100 recent builds instead of just 30. [\#73](https://github.com/coreyja/sleet/pull/73) ([temochka](https://github.com/temochka)) 75 | - Update webmock requirement from ~\> 3.7.0 to ~\> 3.8.0 [\#72](https://github.com/coreyja/sleet/pull/72) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 76 | - Update faraday requirement from \>= 0.13.1, \< 0.16.0 to \>= 0.13.1, \< 1.1.0 [\#71](https://github.com/coreyja/sleet/pull/71) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 77 | - Update gem-release requirement from = 2.1.0 to = 2.1.1 [\#69](https://github.com/coreyja/sleet/pull/69) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 78 | - Update gem-release requirement from = 2.0.4 to = 2.1.0 [\#68](https://github.com/coreyja/sleet/pull/68) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 79 | - Update gem-release requirement from = 2.0.3 to = 2.0.4 [\#67](https://github.com/coreyja/sleet/pull/67) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 80 | - Update rake requirement from ~\> 12.3 to ~\> 13.0 [\#64](https://github.com/coreyja/sleet/pull/64) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 81 | - Update webmock requirement from ~\> 3.6.0 to ~\> 3.7.0 [\#63](https://github.com/coreyja/sleet/pull/63) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 82 | - Update gem-release requirement from = 2.0.2 to = 2.0.3 [\#62](https://github.com/coreyja/sleet/pull/62) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 83 | - Update gem-release requirement from = 2.0.1 to = 2.0.2 [\#61](https://github.com/coreyja/sleet/pull/61) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 84 | - Update webmock requirement from ~\> 3.5.1 to ~\> 3.6.0 [\#60](https://github.com/coreyja/sleet/pull/60) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 85 | 86 | ## [v0.4.2](https://github.com/coreyja/sleet/tree/v0.4.2) (2019-05-27) 87 | 88 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.4.1...v0.4.2) 89 | 90 | **Closed issues:** 91 | 92 | - Dependabot can't resolve your Ruby dependency files [\#51](https://github.com/coreyja/sleet/issues/51) 93 | - Dependabot can't resolve your Ruby dependency files [\#50](https://github.com/coreyja/sleet/issues/50) 94 | - Dependabot can't resolve your Ruby dependency files [\#48](https://github.com/coreyja/sleet/issues/48) 95 | - Dependabot can't resolve your Ruby dependency files [\#49](https://github.com/coreyja/sleet/issues/49) 96 | 97 | **Merged pull requests:** 98 | 99 | - Fix CircleCI Builds [\#59](https://github.com/coreyja/sleet/pull/59) ([coreyja](https://github.com/coreyja)) 100 | - Add a Gitter chat badge to README.md [\#58](https://github.com/coreyja/sleet/pull/58) ([gitter-badger](https://github.com/gitter-badger)) 101 | - Symlink to docs README.md from top level [\#57](https://github.com/coreyja/sleet/pull/57) ([coreyja](https://github.com/coreyja)) 102 | - Docs site fixes [\#56](https://github.com/coreyja/sleet/pull/56) ([coreyja](https://github.com/coreyja)) 103 | - Setup Github Pages [\#54](https://github.com/coreyja/sleet/pull/54) ([coreyja](https://github.com/coreyja)) 104 | - Update rugged requirement from \>= 0.26, \< 0.28 to \>= 0.26, \< 0.29 [\#53](https://github.com/coreyja/sleet/pull/53) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 105 | - Remove direct dependency on Bundler [\#52](https://github.com/coreyja/sleet/pull/52) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 106 | - Update webmock requirement from ~\> 3.4.0 to ~\> 3.5.1 [\#47](https://github.com/coreyja/sleet/pull/47) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 107 | 108 | ## [v0.4.1](https://github.com/coreyja/sleet/tree/v0.4.1) (2018-11-24) 109 | 110 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.4.0...v0.4.1) 111 | 112 | **Merged pull requests:** 113 | 114 | - chore: Set up Rubocop-Coreyja gem [\#45](https://github.com/coreyja/sleet/pull/45) ([coreyja](https://github.com/coreyja)) 115 | - Update README.md [\#40](https://github.com/coreyja/sleet/pull/40) ([coreyja](https://github.com/coreyja)) 116 | - Update gem-release requirement to = 2.0.1 [\#33](https://github.com/coreyja/sleet/pull/33) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 117 | - Update webmock requirement to ~\> 3.4.0 [\#28](https://github.com/coreyja/sleet/pull/28) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 118 | - Update faraday requirement to \>= 0.13.1, \< 0.16.0 [\#27](https://github.com/coreyja/sleet/pull/27) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 119 | - Update rubocop requirement to ~\> 0.55.0 [\#26](https://github.com/coreyja/sleet/pull/26) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 120 | - Update gem-release requirement to = 2.0.0.rc.3 [\#25](https://github.com/coreyja/sleet/pull/25) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 121 | - Update rugged requirement to \>= 0.26, \< 0.28 [\#24](https://github.com/coreyja/sleet/pull/24) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 122 | 123 | ## [v0.4.0](https://github.com/coreyja/sleet/tree/v0.4.0) (2018-03-08) 124 | 125 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.10...v0.4.0) 126 | 127 | **Closed issues:** 128 | 129 | - Allow Specifying Token from Config Files [\#9](https://github.com/coreyja/sleet/issues/9) 130 | 131 | **Merged pull requests:** 132 | 133 | - Get Token From Config File [\#21](https://github.com/coreyja/sleet/pull/21) ([coreyja](https://github.com/coreyja)) 134 | 135 | ## [v0.3.10](https://github.com/coreyja/sleet/tree/v0.3.10) (2018-03-08) 136 | 137 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.9...v0.3.10) 138 | 139 | **Merged pull requests:** 140 | 141 | - Base Version Option [\#20](https://github.com/coreyja/sleet/pull/20) ([coreyja](https://github.com/coreyja)) 142 | - Update rubocop requirement to ~\> 0.53.0 [\#19](https://github.com/coreyja/sleet/pull/19) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 143 | 144 | ## [v0.3.9](https://github.com/coreyja/sleet/tree/v0.3.9) (2018-03-04) 145 | 146 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.8...v0.3.9) 147 | 148 | **Closed issues:** 149 | 150 | - There are No Specs [\#6](https://github.com/coreyja/sleet/issues/6) 151 | 152 | **Merged pull requests:** 153 | 154 | - Refactor [\#18](https://github.com/coreyja/sleet/pull/18) ([coreyja](https://github.com/coreyja)) 155 | - Update rake requirement to ~\> 12.3 [\#17](https://github.com/coreyja/sleet/pull/17) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 156 | - Update faraday requirement to \>= 0.13.1, \< 0.15.0 [\#16](https://github.com/coreyja/sleet/pull/16) ([dependabot-preview[bot]](https://github.com/apps/dependabot-preview)) 157 | - More Specs [\#15](https://github.com/coreyja/sleet/pull/15) ([coreyja](https://github.com/coreyja)) 158 | 159 | ## [v0.3.8](https://github.com/coreyja/sleet/tree/v0.3.8) (2018-02-25) 160 | 161 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.7...v0.3.8) 162 | 163 | ## [v0.3.7](https://github.com/coreyja/sleet/tree/v0.3.7) (2018-01-19) 164 | 165 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.6...v0.3.7) 166 | 167 | **Merged pull requests:** 168 | 169 | - Fix token auth and finally add a spec for it so I don't break it again [\#14](https://github.com/coreyja/sleet/pull/14) ([coreyja](https://github.com/coreyja)) 170 | 171 | ## [v0.3.6](https://github.com/coreyja/sleet/tree/v0.3.6) (2018-01-18) 172 | 173 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.5...v0.3.6) 174 | 175 | **Merged pull requests:** 176 | 177 | - Fix Auth By Going Back to Query Params [\#13](https://github.com/coreyja/sleet/pull/13) ([coreyja](https://github.com/coreyja)) 178 | 179 | ## [v0.3.5](https://github.com/coreyja/sleet/tree/v0.3.5) (2018-01-17) 180 | 181 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.4...v0.3.5) 182 | 183 | ## [v0.3.4](https://github.com/coreyja/sleet/tree/v0.3.4) (2018-01-17) 184 | 185 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.3...v0.3.4) 186 | 187 | **Merged pull requests:** 188 | 189 | - Only Fetch Branch Once [\#12](https://github.com/coreyja/sleet/pull/12) ([coreyja](https://github.com/coreyja)) 190 | - Fix Auth [\#11](https://github.com/coreyja/sleet/pull/11) ([coreyja](https://github.com/coreyja)) 191 | 192 | ## [v0.3.3](https://github.com/coreyja/sleet/tree/v0.3.3) (2018-01-16) 193 | 194 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.2...v0.3.3) 195 | 196 | **Merged pull requests:** 197 | 198 | - Add `version` cmd [\#10](https://github.com/coreyja/sleet/pull/10) ([coreyja](https://github.com/coreyja)) 199 | 200 | ## [v0.3.2](https://github.com/coreyja/sleet/tree/v0.3.2) (2018-01-16) 201 | 202 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.1...v0.3.2) 203 | 204 | **Merged pull requests:** 205 | 206 | - Update Readme and Add Descriptions to Help Menus [\#8](https://github.com/coreyja/sleet/pull/8) ([coreyja](https://github.com/coreyja)) 207 | 208 | ## [v0.3.1](https://github.com/coreyja/sleet/tree/v0.3.1) (2018-01-15) 209 | 210 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.3.0...v0.3.1) 211 | 212 | **Merged pull requests:** 213 | 214 | - Add Dependency Versions [\#5](https://github.com/coreyja/sleet/pull/5) ([coreyja](https://github.com/coreyja)) 215 | 216 | ## [v0.3.0](https://github.com/coreyja/sleet/tree/v0.3.0) (2018-01-15) 217 | 218 | [Full Changelog](https://github.com/coreyja/sleet/compare/v0.2.0...v0.3.0) 219 | 220 | **Closed issues:** 221 | 222 | - Places `.rspec_failed_examples` relative to where the script is called from [\#1](https://github.com/coreyja/sleet/issues/1) 223 | 224 | **Merged pull requests:** 225 | 226 | - Workflows\(/Monorepos\) [\#4](https://github.com/coreyja/sleet/pull/4) ([coreyja](https://github.com/coreyja)) 227 | - Error Messages [\#3](https://github.com/coreyja/sleet/pull/3) ([coreyja](https://github.com/coreyja)) 228 | 229 | ## [v0.2.0](https://github.com/coreyja/sleet/tree/v0.2.0) (2018-01-14) 230 | 231 | [Full Changelog](https://github.com/coreyja/sleet/compare/aa005fdae00ae843909f3a2d1753db3727a27c2c...v0.2.0) 232 | 233 | **Merged pull requests:** 234 | 235 | - Gemify [\#2](https://github.com/coreyja/sleet/pull/2) ([coreyja](https://github.com/coreyja)) 236 | 237 | 238 | 239 | \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* 240 | -------------------------------------------------------------------------------- /spec/cli/fetch_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe 'sleet fetch', type: :cli do 6 | let(:branch_response) do 7 | [{ has_artifacts: true, build_num: 23 }] 8 | end 9 | let(:build_response) do 10 | [ 11 | { 12 | path: '.rspec_example_statuses', 13 | url: 'https://fake_circle_ci_artfiacts.com/some-artifact' 14 | } 15 | ] 16 | end 17 | let(:artifact_response) do 18 | <<~ARTIFACT 19 | example_id | status | run_time | 20 | --------------------------------------- | ------ | --------------- | 21 | ./spec/cli/fetch_spec.rb[1:1:1] | passed | 0.00912 seconds | 22 | ./spec/cli/fetch_spec.rb[1:2:1] | passed | 0.0078 seconds | 23 | ./spec/cli/fetch_spec.rb[1:2:2:1] | passed | 0.01431 seconds | 24 | ./spec/cli/fetch_spec.rb[1:2:2:2:1] | passed | 0.0193 seconds | 25 | ./spec/cli/fetch_spec.rb[1:2:2:3:1:1] | passed | 0.03077 seconds | 26 | ./spec/cli/fetch_spec.rb[1:2:2:3:2:1] | passed | 0.02891 seconds | 27 | ./spec/cli/fetch_spec.rb[1:2:2:3:3:1:1] | passed | 0.03863 seconds | 28 | ./spec/cli/fetch_spec.rb[1:2:2:3:3:2:1] | passed | 0.05603 seconds | 29 | ./spec/cli/version_spec.rb[1:1] | passed | 0.00165 seconds | 30 | ./spec/model/circle_ci_spec.rb[1:1:1] | passed | 0.00814 seconds | 31 | ./spec/model/sleet_spec.rb[1:1] | passed | 0.00073 seconds | 32 | ARTIFACT 33 | end 34 | let(:happy_path_final_file) { artifact_response } 35 | let(:stubbed_branch_request_url) { 'https://circleci.com/api/v1.1/project/github/someuser/somerepo/tree/main' } 36 | let(:stubbed_build_request_url) { 'https://circleci.com/api/v1.1/project/github/someuser/somerepo/23/artifacts' } 37 | let(:stubbed_branch_request) do 38 | stub_request(:get, stubbed_branch_request_url) 39 | .with(query: { 'circle-token' => 'FAKE_TOKEN', 'filter' => 'completed', 'limit' => '100' }) 40 | .to_return(body: branch_response.to_json) 41 | end 42 | let(:stubbed_build_request) do 43 | stub_request(:get, stubbed_build_request_url) 44 | .with(query: { 'circle-token' => 'FAKE_TOKEN' }) 45 | .to_return(body: build_response.to_json) 46 | end 47 | let(:stubbed_artifact_request) do 48 | stub_request(:get, 'https://fake_circle_ci_artfiacts.com/some-artifact') 49 | .with(query: { 'circle-token' => 'FAKE_TOKEN' }) 50 | .to_return(body: artifact_response) 51 | end 52 | 53 | let(:repo_directory) { Dir.pwd } 54 | let(:repo) { Rugged::Repository.init_at(repo_directory) } 55 | let(:remote) { repo.remotes.create('origin', 'git://github.com/someuser/somerepo.git') } 56 | 57 | let(:yaml_options) { { circle_ci_token: 'FAKE_TOKEN' } } 58 | 59 | before do 60 | File.write('.sleet.yml', yaml_options.to_yaml) 61 | stubbed_branch_request 62 | stubbed_build_request 63 | stubbed_artifact_request 64 | 65 | repo 66 | create_commit(repo) 67 | 68 | remote 69 | assign_upstream repo, 'main', 'origin/main' 70 | end 71 | 72 | it 'downloads and saves the persistance file locally' do 73 | expect_command('fetch').to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 74 | expect(File.read('.rspec_example_statuses')).to eq happy_path_final_file 75 | expect(stubbed_branch_request).to have_been_made.once 76 | expect(stubbed_build_request).to have_been_made.once 77 | end 78 | 79 | context 'when the circleci token is missing from the yml file' do 80 | let(:yaml_options) { {} } 81 | 82 | it 'errors about the missing token' do 83 | expect_command('fetch') 84 | .to error_with('ERROR: circle_ci_token required and not provided') 85 | .and output_nothing.to_stdout 86 | end 87 | end 88 | 89 | context 'when NOT in a git repo' do 90 | let(:repo_directory) { Dir.mktmpdir } 91 | 92 | it 'fails when a source is not provided' do 93 | expect_command('fetch').to raise_error Rugged::RepositoryError 94 | end 95 | 96 | it 'succeeds when given the source path as an option' do 97 | expect_command("fetch --source-dir #{repo_directory}") 98 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 99 | expect(File.read("#{repo_directory}/.rspec_example_statuses")).to eq happy_path_final_file 100 | end 101 | 102 | it 'succeeds when all the required options are passed as cli options' do 103 | expect_command('fetch --username someuser --project somerepo --branch main') 104 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 105 | expect(File.read("#{Dir.pwd}/.rspec_example_statuses")).to eq happy_path_final_file 106 | end 107 | end 108 | 109 | context 'when there is a NON github upstream' do 110 | let(:remote) { repo.remotes.create('origin', 'git://gitlab.com/someuser/somerepo.git') } 111 | 112 | before { assign_upstream repo, 'main', 'origin/main' } 113 | 114 | it 'runs and outputs the correct error message' do 115 | expect_command('fetch').to error_with 'ERROR: Upstream remote is not GitHub' 116 | end 117 | 118 | it 'runs with multiple workflows and only outputs the error once' do 119 | expect_command('fetch --workflows a:a b:b').to error_with 'ERROR: Upstream remote is not GitHub' 120 | end 121 | end 122 | 123 | context 'when there is no upstream' do 124 | before { remove_upstream repo, 'main' } 125 | 126 | it 'runs and outputs the correct error message' do 127 | expect_command('fetch').to error_with 'ERROR: No upstream branch set for the current branch of main' 128 | end 129 | end 130 | 131 | context 'when there are no completed builds found for the branch' do 132 | let(:branch_response) do 133 | [] 134 | end 135 | 136 | it 'fails with the correct error message' do 137 | expect_command('fetch').to error_with 'ERROR: No builds found' 138 | end 139 | end 140 | 141 | context 'when none of the artifacts end with the default path' do 142 | let(:build_response) do 143 | [ 144 | { 145 | path: 'random_file.txt', 146 | url: 'https://fake_circle_ci_artfiacts.com/some-artifact' 147 | } 148 | ] 149 | end 150 | 151 | it 'runs and creates an empty file for the persistance status file' do 152 | expect_command('fetch') 153 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 154 | expect(File.read('.rspec_example_statuses').strip).to eq '' 155 | end 156 | 157 | it 'creates the correct file when given the correct short hand input file option' do 158 | expect_command('fetch -i random_file.txt') 159 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 160 | expect(File.read('.rspec_example_statuses')).to eq happy_path_final_file 161 | end 162 | it 'creates the correct file when given the correct input file option' do 163 | expect_command('fetch --input-file random_file.txt') 164 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 165 | expect(File.read('.rspec_example_statuses')).to eq happy_path_final_file 166 | end 167 | end 168 | 169 | context 'when one of the artifacts end with the correct path' do 170 | let(:build_response) do 171 | [ 172 | { 173 | path: 'random_file.txt', 174 | url: 'fake_url.com/fake_path' 175 | }, 176 | { 177 | path: '.rspec_example_statuses', 178 | url: 'https://fake_circle_ci_artfiacts.com/some-artifact' 179 | } 180 | ] 181 | end 182 | 183 | it 'runs and save the persistance file locally' do 184 | expect_command('fetch') 185 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 186 | expect(File.read('.rspec_example_statuses')).to eq happy_path_final_file 187 | end 188 | end 189 | 190 | context 'when multiple artifacts contain the correct path' do 191 | let(:build_response) do 192 | [ 193 | { 194 | path: 'random_file.txt', 195 | url: 'BLAH' 196 | }, 197 | { 198 | path: '.rspec_example_statuses', 199 | url: 'https://fake_circle_ci_artfiacts.com/some-artifact' 200 | }, 201 | { 202 | path: '.rspec_example_statuses', 203 | url: 'https://fake_circle_ci_artfiacts.com/some-artifact-2' 204 | } 205 | ] 206 | end 207 | let(:stubbed_single_artifact_1_request) do 208 | stub_request(:get, 'https://fake_circle_ci_artfiacts.com/some-artifact') 209 | .with(query: hash_including) 210 | .to_return(body: stubbed_single_artifact_1_response) 211 | end 212 | let(:stubbed_single_artifact_1_response) do 213 | <<~ARTIFACT 214 | example_id | status | run_time | 215 | --------------------------------------- | ------ | --------------- | 216 | ./spec/cli/fetch_spec.rb[1:2:2:3:3:2:1] | passed | 0.05603 seconds | 217 | ./spec/model/circle_ci_spec.rb[1:1:1] | passed | 0.00814 seconds | 218 | ./spec/cli/fetch_spec.rb[1:2:2:2:1] | passed | 0.0193 seconds | 219 | ./spec/cli/fetch_spec.rb[1:2:2:3:1:1] | passed | 0.03077 seconds | 220 | ./spec/cli/fetch_spec.rb[1:2:2:3:2:1] | passed | 0.02891 seconds | 221 | ./spec/cli/fetch_spec.rb[1:2:2:3:3:1:1] | passed | 0.03863 seconds | 222 | ARTIFACT 223 | end 224 | let(:stubbed_single_artifact_2_request) do 225 | stub_request(:get, 'https://fake_circle_ci_artfiacts.com/some-artifact-2') 226 | .with(query: hash_including) 227 | .to_return(body: stubbed_single_artifact_2_response) 228 | end 229 | let(:stubbed_single_artifact_2_response) do 230 | <<~ARTIFACT 231 | example_id | status | run_time | 232 | --------------------------------- | ------ | --------------- | 233 | ./spec/cli/fetch_spec.rb[1:1:1] | passed | 0.00912 seconds | 234 | ./spec/cli/fetch_spec.rb[1:2:1] | passed | 0.0078 seconds | 235 | ./spec/cli/fetch_spec.rb[1:2:2:1] | passed | 0.01431 seconds | 236 | ./spec/model/sleet_spec.rb[1:1] | passed | 0.00073 seconds | 237 | ./spec/cli/version_spec.rb[1:1] | passed | 0.00165 seconds | 238 | ARTIFACT 239 | end 240 | 241 | before do 242 | stubbed_single_artifact_1_request 243 | stubbed_single_artifact_2_request 244 | end 245 | 246 | it 'downloads and combines the artifacts and saves the persistance file locally' do 247 | expect_command('fetch') 248 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 249 | expect(File.read('.rspec_example_statuses')).to eq happy_path_final_file 250 | end 251 | end 252 | 253 | it 'respects the output file CLI option' do 254 | expect_command('fetch --output-file some_cool_file.txt') 255 | .to output('Created file (some_cool_file.txt) from build (#23)'.green + "\n").to_stdout.and without_error 256 | expect(File.read('some_cool_file.txt')).to eq happy_path_final_file 257 | end 258 | it 'respects the shortened output file CLI option' do 259 | expect_command('fetch -o some_cool_file.txt') 260 | .to output('Created file (some_cool_file.txt) from build (#23)'.green + "\n").to_stdout.and without_error 261 | expect(File.read('some_cool_file.txt')).to eq happy_path_final_file 262 | end 263 | 264 | context 'when the repo uses workflows' do 265 | let(:branch_response) do 266 | [ 267 | { has_artifacts: true, build_num: 14, workflows: { job_name: 'prep-job' } }, 268 | { has_artifacts: true, build_num: 23, workflows: { job_name: 'rspec-job' } }, 269 | { has_artifacts: true, build_num: 509, workflows: { job_name: 'other-random-job' } } 270 | ] 271 | end 272 | 273 | it 'works when given the rspec job and a single output' do 274 | expect_command('fetch --workflows rspec-job:.rspec_example_file') 275 | .to output('Created file (.rspec_example_file) from build (#23)'.green + "\n").to_stdout 276 | expect(File.read('.rspec_example_file')).to eq happy_path_final_file 277 | end 278 | 279 | context 'when used in a mono-repo' do 280 | let(:branch_response) do 281 | [ 282 | { has_artifacts: true, build_num: 14, workflows: { job_name: 'some-app-rspec' } }, 283 | { has_artifacts: true, build_num: 23, workflows: { job_name: 'app-rspec' } }, 284 | { has_artifacts: true, build_num: 509, workflows: { job_name: 'third-app-rspec' } } 285 | ] 286 | end 287 | let(:build_response_thrid_app) do 288 | [ 289 | { 290 | path: '.rspec_example_statuses', 291 | url: 'https://fake_circle_ci_artfiacts.com/third-app-artifact' 292 | } 293 | ] 294 | end 295 | let(:build_response_some_app) do 296 | [ 297 | { 298 | path: '.rspec_example_statuses', 299 | url: 'https://fake_circle_ci_artfiacts.com/some-app-artifact' 300 | } 301 | ] 302 | end 303 | let(:third_artifact_response) do 304 | <<~ARTIFACT 305 | example_id | status | run_time | 306 | -------------------------------------- | ------ | --------------- | 307 | ./spec/model/taco_spec.rb[1:3] | passed | 0.00111 seconds | 308 | ARTIFACT 309 | end 310 | let(:some_artifact_response) do 311 | <<~ARTIFACT 312 | example_id | status | run_time | 313 | ---------------------------------------- | ------ | --------------- | 314 | ./spec/model/random_spec.rb[1:1] | passed | 0.00073 seconds | 315 | ARTIFACT 316 | end 317 | let(:stubbed_build_14_request) do 318 | stub_request(:get, %r{https://circleci.com/api/v1.1/project/github/.+/.+/14/artifacts}) 319 | .with(query: hash_including) 320 | .to_return(body: build_response.to_json) 321 | end 322 | let(:stubbed_artifact_14_request) do 323 | stub_request(:get, 'https://fake_circle_ci_artfiacts.com/some-app-artifact') 324 | .with(query: hash_including) 325 | .to_return(body: artifact_response) 326 | end 327 | let(:stubbed_build_509_request) do 328 | stub_request(:get, %r{https://circleci.com/api/v1.1/project/github/.+/.+/509/artifacts}) 329 | .with(query: hash_including) 330 | .to_return(body: build_response.to_json) 331 | end 332 | let(:stubbed_artifact_509_request) do 333 | stub_request(:get, 'https://fake_circle_ci_artfiacts.com/third-app-artifact') 334 | .with(query: hash_including) 335 | .to_return(body: artifact_response) 336 | end 337 | let(:expected_output) do 338 | 'Created file (app/.rspec_example_file) from build (#23)'.green + "\n" + 339 | 'Created file (some_app/.rspec_example_status) from build (#14)'.green + "\n" + 340 | 'Created file (third_app/rspec.txt) from build (#509)'.green + "\n" 341 | end 342 | 343 | before do 344 | stubbed_build_14_request 345 | stubbed_artifact_14_request 346 | stubbed_build_509_request 347 | stubbed_artifact_509_request 348 | 349 | Dir.mkdir('app') 350 | Dir.mkdir('some_app') 351 | Dir.mkdir('third_app') 352 | end 353 | 354 | it 'works when given the rspec job and a single output' do 355 | expect_command('fetch --workflows app-rspec:app/.rspec_example_file 356 | some-app-rspec:some_app/.rspec_example_status 357 | third-app-rspec:third_app/rspec.txt') 358 | .to output(expected_output).to_stdout 359 | expect(File.read('app/.rspec_example_file')).to eq happy_path_final_file 360 | expect(File.read('some_app/.rspec_example_status')).to eq happy_path_final_file 361 | expect(File.read('third_app/rspec.txt')).to eq happy_path_final_file 362 | expect(stubbed_branch_request).to have_been_made.once 363 | end 364 | 365 | context 'when using a config file for the options' do 366 | let(:yaml_options) do 367 | { 368 | circle_ci_token: 'FAKE_TOKEN', 369 | workflows: { 370 | 'app-rspec' => 'app/.rspec_example_file', 371 | 'some-app-rspec' => 'some_app/.rspec_example_status', 372 | 'third-app-rspec' => 'third_app/rspec.txt' 373 | } 374 | } 375 | end 376 | 377 | it 'works when given the rspec job and a single output' do 378 | expect_command('fetch') 379 | .to output(expected_output).to_stdout 380 | expect(File.read('app/.rspec_example_file')).to eq happy_path_final_file 381 | expect(File.read('some_app/.rspec_example_status')).to eq happy_path_final_file 382 | expect(File.read('third_app/rspec.txt')).to eq happy_path_final_file 383 | end 384 | end 385 | end 386 | end 387 | 388 | context 'when some repo values are provided in the CLI' do 389 | let(:stubbed_branch_request_url) do 390 | 'https://circleci.com/api/v1.1/project/github/otheruser/somerepo/tree/otherbranch' 391 | end 392 | let(:stubbed_build_request_url) { 'https://circleci.com/api/v1.1/project/github/otheruser/somerepo/23/artifacts' } 393 | 394 | it 'prefers the cli options over the local repo options' do 395 | expect_command('fetch --username otheruser --branch otherbranch') 396 | .to output('Created file (.rspec_example_statuses) from build (#23)'.green + "\n").to_stdout 397 | expect(File.read("#{repo_directory}/.rspec_example_statuses")).to eq happy_path_final_file 398 | 399 | expect(stubbed_branch_request).to have_been_made.once 400 | expect(stubbed_build_request).to have_been_made.once 401 | end 402 | end 403 | end 404 | --------------------------------------------------------------------------------