├── log └── .keep ├── repos └── .keep ├── app ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ └── .keep │ ├── batch_entry.rb │ ├── batch_job.rb │ └── branch.rb ├── assets │ ├── images │ │ └── .keep │ ├── stylesheets │ │ └── application.css │ └── javascripts │ │ └── application.js ├── controllers │ ├── concerns │ │ └── .keep │ ├── main_controller.rb │ └── application_controller.rb ├── views │ ├── layouts │ │ ├── _footer.html.erb │ │ ├── _navbar.html.erb │ │ └── application.html.erb │ └── main │ │ ├── index.html.erb │ │ └── _commit_monitor.html.erb ├── helpers │ └── application_helper.rb └── workers │ ├── batch_job_monitor.rb │ ├── schedulers │ └── stale_issue_marker.rb │ ├── commit_monitor_handlers │ └── commit_range │ │ ├── branch_mergeability_checker.rb │ │ ├── path_based_labeler.rb │ │ ├── rubocop_checker.rb │ │ ├── rubocop_checker │ │ └── rubocop_results_filter.rb │ │ ├── github_pr_commenter │ │ ├── diff_filename_checker.rb │ │ ├── diff_content_checker.rb │ │ └── commit_metadata_checker.rb │ │ └── github_pr_commenter.rb │ ├── github_notification_monitor_worker.rb │ ├── concerns │ ├── batch_job_worker_mixin.rb │ ├── code_analysis_mixin.rb │ ├── batch_entry_worker_mixin.rb │ ├── branch_worker_mixin.rb │ └── sidekiq_worker_mixin.rb │ ├── pull_request_monitor_handlers │ ├── merge_target_titler.rb │ └── wip_labeler.rb │ ├── pull_request_monitor.rb │ ├── pr_mergeability_checker.rb │ └── stale_issue_marker.rb ├── bundler.d └── .gitkeep ├── lib ├── assets │ └── .keep ├── tasks │ ├── .keep │ ├── commit_monitor.rake │ └── pull_request_monitor.rake ├── git_service │ ├── error.rb │ ├── unmergeable_error.rb │ ├── repo.rb │ ├── credentials.rb │ ├── diff.rb │ └── commit.rb ├── github_service │ ├── issue_comment.rb │ ├── message_builder.rb │ ├── commands │ │ ├── assign.rb │ │ ├── close_issue.rb │ │ ├── add_reviewer.rb │ │ ├── set_milestone.rb │ │ ├── unassign.rb │ │ ├── remove_reviewer.rb │ │ ├── move_issue.rb │ │ ├── remove_label.rb │ │ ├── base.rb │ │ └── add_label.rb │ ├── notification.rb │ ├── response │ │ └── ratelimit_logger.rb │ ├── concerns │ │ └── is_team_member.rb │ └── command_dispatcher.rb ├── threadsafe_service_mixin.rb ├── offense_message.rb ├── linter │ ├── haml.rb │ ├── rubocop.rb │ └── yaml.rb ├── console_methods.rb ├── service_mixin.rb ├── offense_message │ └── entry.rb ├── message_collector.rb └── minigit_service.rb ├── public ├── favicon.ico ├── robots.txt ├── 500.html ├── 422.html └── 404.html ├── config ├── settings │ ├── development.yml │ └── test.yml ├── initializers │ ├── config.rb │ ├── session_store.rb │ ├── mime_types.rb │ ├── application_controller_renderer.rb │ ├── cookies_serializer.rb │ ├── config_listener.rb │ ├── filter_parameter_logging.rb │ ├── permissions_policy.rb │ ├── assets.rb │ ├── wrap_parameters.rb │ ├── backtrace_silencers.rb │ ├── inflections.rb │ └── content_security_policy.rb ├── boot.rb ├── environment.rb ├── cable.yml ├── routes.rb ├── settings.yml ├── database.vagrant.yml ├── credentials.yml.enc ├── database.yml.sample ├── sidecloq.yml ├── locales │ └── en.yml ├── storage.yml ├── application.rb ├── puma.rb └── environments │ ├── test.rb │ └── development.rb ├── vendor ├── assets │ ├── javascripts │ │ └── .keep │ └── stylesheets │ │ └── .keep └── stubs │ └── parser │ └── current.rb ├── .production_env ├── .rspec ├── .whitesource ├── spec ├── workers │ ├── commit_monitor_handlers │ │ └── commit_range │ │ │ ├── rubocop_checker │ │ │ ├── data │ │ │ │ ├── .rubocop_local.yml │ │ │ │ ├── with_results_without_column_numbers_and_cop_names │ │ │ │ │ ├── example.haml │ │ │ │ │ └── results.json │ │ │ │ ├── with_haml_file_using_haml-lint │ │ │ │ │ ├── example.haml │ │ │ │ │ └── results.json │ │ │ │ ├── .rubocop.yml │ │ │ │ ├── with_results_with_offenses │ │ │ │ │ ├── no_offenses.rb │ │ │ │ │ ├── ruby_warning.rb │ │ │ │ │ ├── ruby_syntax_error.rb │ │ │ │ │ └── coding_convention.rb │ │ │ │ ├── with_results_with_no_offenses │ │ │ │ │ ├── no_offenses.rb │ │ │ │ │ └── results.json │ │ │ │ ├── with_void_warnings_in_spec_files │ │ │ │ │ ├── non_spec_file_with_void_warning.rb │ │ │ │ │ ├── spec │ │ │ │ │ │ ├── non_spec_file_in_spec_dir_with_void_warning.rb │ │ │ │ │ │ └── spec_file_with_void_warning_spec.rb │ │ │ │ │ └── results.json │ │ │ │ └── with_lines_not_in_the_diff │ │ │ │ │ ├── example.rb │ │ │ │ │ └── results.json │ │ │ └── rubocop_results_filter_spec.rb │ │ │ ├── branch_mergeability_checker_spec.rb │ │ │ └── github_pr_commenter │ │ │ └── diff_filename_checker_spec.rb │ ├── batch_job_monitor_spec.rb │ ├── pull_request_monitor_handlers │ │ ├── wip_labeler_spec.rb │ │ └── merge_target_titler_spec.rb │ ├── concerns │ │ ├── batch_job_worker_mixin_spec.rb │ │ ├── batch_entry_worker_mixin_spec.rb │ │ └── code_analysis_mixin_spec.rb │ ├── github_notification_monitor_worker_spec.rb │ ├── commit_monitor_spec.rb │ ├── pr_mergeability_checker_spec.rb │ └── pull_request_monitor_spec.rb ├── factories │ ├── repo.rb │ └── branch.rb ├── support │ ├── batch_entry_worker_helper.rb │ ├── sidekiq_spec_helper.rb │ ├── services_helper.rb │ ├── shared_examples │ │ ├── state_predicates.rb │ │ └── threadsafe_service_mixin_examples.rb │ ├── settings_helper.rb │ └── rubocop_spec_helper.rb ├── lib │ ├── linter │ │ ├── base_spec.rb │ │ ├── rubocop_spec.rb │ │ └── haml_spec.rb │ ├── github_service │ │ ├── commands │ │ │ ├── assign_spec.rb │ │ │ ├── add_reviewer_spec.rb │ │ │ ├── remove_reviewer_spec.rb │ │ │ └── unassign_spec.rb │ │ └── command_dispatcher_spec.rb │ ├── message_collector_spec.rb │ ├── offense_message_spec.rb │ ├── github_service_spec.rb │ ├── offense_message │ │ └── entry_spec.rb │ └── github_notification_monitor_spec.rb ├── miq_bot_spec.rb ├── models │ └── batch_entry_spec.rb └── spec_helper.rb ├── .rubocop.yml ├── .rubocop_cc.yml ├── bin ├── rake ├── bundle ├── rails ├── before_install └── setup ├── .rubocop_local.yml ├── renovate.json ├── config.ru ├── db ├── migrate │ ├── 20151207193108_remove_path_from_repo.rb │ ├── 20150814233450_add_state_to_batch_jobs.rb │ ├── 20160713200646_add_pr_title_to_branch.rb │ ├── 20150814183817_rename_commit_monitor_repos_to_repos.rb │ ├── 20150814191457_rename_branches_foreign_key.rb │ ├── 20171006050814_add_linter_offense_count_to_branch.rb │ ├── 20250305205548_add_uniqueness_constraint_on_branches.rb │ ├── 20140215060546_add_mergeable_to_commit_monitor_branch.rb │ ├── 20150814183834_rename_commit_monitor_branches_to_branches.rb │ ├── 20140117063400_add_upstream_user_to_commit_monitor_repo.rb │ ├── 20140117055336_add_pull_request_to_commit_monitor_branch.rb │ ├── 20140214211340_add_commits_list_to_commit_monitor_branches.rb │ ├── 20140115031432_create_commit_monitor_repos.rb │ ├── 20150806034945_add_on_complete_class_and_args_to_batch_jobs.rb │ ├── 20140116232751_create_commit_monitor_branches.rb │ ├── 20160119143158_add_merge_head_to_branch.rb │ ├── 20150730043503_create_batch_tables.rb │ ├── 20151204171252_update_branch_name_for_pr_branches.rb │ ├── 20140128174930_replace_timestamps_with_last_checked_on_and_last_changed_on_for_commit_monitor_branches.rb │ ├── 20150827194533_collapse_repo_upstream_user_into_name.rb │ └── 20170127173128_fix_limit_in_schema.rb ├── seeds.rb └── schema.rb ├── Procfile.tmpl ├── Rakefile ├── container-assets ├── container_env └── entrypoint ├── .codeclimate.yml ├── .gitignore ├── .github └── workflows │ ├── build_and_deploy.yml │ └── ci.yaml ├── Gemfile └── Dockerfile /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /repos/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /bundler.d/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/settings/development.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.production_env: -------------------------------------------------------------------------------- 1 | RAILS_ENV=production 2 | START_PORT=3002 3 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --require spec_helper 2 | --color 3 | --order random 4 | -------------------------------------------------------------------------------- /.whitesource: -------------------------------------------------------------------------------- 1 | { 2 | "settingsInheritedFrom": "ManageIQ/whitesource-config@master" 3 | } -------------------------------------------------------------------------------- /lib/git_service/error.rb: -------------------------------------------------------------------------------- 1 | module GitService 2 | Error = Class.new(StandardError) 3 | end 4 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/.rubocop_local.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/initializers/config.rb: -------------------------------------------------------------------------------- 1 | Config.setup do |config| 2 | config.const_name = "Settings" 3 | end 4 | -------------------------------------------------------------------------------- /lib/git_service/unmergeable_error.rb: -------------------------------------------------------------------------------- 1 | module GitService 2 | UnmergeableError = Class.new(Error) 3 | end 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | manageiq-style: ".rubocop_base.yml" 3 | inherit_from: 4 | - ".rubocop_local.yml" 5 | -------------------------------------------------------------------------------- /.rubocop_cc.yml: -------------------------------------------------------------------------------- 1 | inherit_from: 2 | - ".rubocop_base.yml" 3 | - ".rubocop_cc_base.yml" 4 | - ".rubocop_local.yml" 5 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /.rubocop_local.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | Exclude: 3 | - spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/**/* 4 | -------------------------------------------------------------------------------- /spec/factories/repo.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :repo do 3 | sequence(:name) { |n| "SomeUser/repo_#{n}" } 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/views/layouts/_footer.html.erb: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /spec/support/batch_entry_worker_helper.rb: -------------------------------------------------------------------------------- 1 | def stub_job_completion 2 | allow_any_instance_of(described_class).to receive(:check_job_complete) 3 | end 4 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_without_column_numbers_and_cop_names/example.haml: -------------------------------------------------------------------------------- 1 | %html 2 | -puts 3 | -------------------------------------------------------------------------------- /lib/tasks/commit_monitor.rake: -------------------------------------------------------------------------------- 1 | namespace :commit_monitor do 2 | task :poll_single => :environment do 3 | CommitMonitor.perform_async 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_haml_file_using_haml-lint/example.haml: -------------------------------------------------------------------------------- 1 | %html 2 | - if smth 3 | - else 4 | - end 5 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /lib/tasks/pull_request_monitor.rake: -------------------------------------------------------------------------------- 1 | namespace :pull_request_monitor do 2 | task :poll_single => :environment do 3 | PullRequestMonitor.perform_async 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "inheritConfig": true, 4 | "inheritConfigRepoName": "manageiq/renovate-config" 5 | } 6 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_miq_bot_session' 4 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_gem: 2 | manageiq-style: ".rubocop_base.yml" 3 | inherit_from: 4 | - ".rubocop_local.yml" 5 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_with_offenses/no_offenses.rb: -------------------------------------------------------------------------------- 1 | class NoOffenses 2 | def initialize 3 | @x = 1 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /db/migrate/20151207193108_remove_path_from_repo.rb: -------------------------------------------------------------------------------- 1 | class RemovePathFromRepo < ActiveRecord::Migration[4.2] 2 | def change 3 | remove_column :repos, :path, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_with_no_offenses/no_offenses.rb: -------------------------------------------------------------------------------- 1 | class NoOffenses 2 | def initialize 3 | @x = 1 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /app/controllers/main_controller.rb: -------------------------------------------------------------------------------- 1 | class MainController < ApplicationController 2 | def index 3 | @branches = Branch.includes(:repo).sort_by { |b| [b.repo.name, b.name] } 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150814233450_add_state_to_batch_jobs.rb: -------------------------------------------------------------------------------- 1 | class AddStateToBatchJobs < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :batch_jobs, :state, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20160713200646_add_pr_title_to_branch.rb: -------------------------------------------------------------------------------- 1 | class AddPrTitleToBranch < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :branches, :pr_title, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_with_offenses/ruby_warning.rb: -------------------------------------------------------------------------------- 1 | class RubyWarning 2 | def some_method 3 | unused_variable = 1 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_with_offenses/ruby_syntax_error.rb: -------------------------------------------------------------------------------- 1 | class RubySyntaxError 2 | def initialize 3 | @x = 1 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /app/views/main/index.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Commit Monitor

4 |
5 | <%= render "commit_monitor" %> 6 |
7 | -------------------------------------------------------------------------------- /spec/support/sidekiq_spec_helper.rb: -------------------------------------------------------------------------------- 1 | def stub_sidekiq_logger(klass = nil) 2 | klass ||= described_class 3 | allow_any_instance_of(klass).to receive(:logger).and_return(double("logger").as_null_object) 4 | end 5 | -------------------------------------------------------------------------------- /db/migrate/20150814183817_rename_commit_monitor_repos_to_repos.rb: -------------------------------------------------------------------------------- 1 | class RenameCommitMonitorReposToRepos < ActiveRecord::Migration[4.2] 2 | def change 3 | rename_table :commit_monitor_repos, :repos 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150814191457_rename_branches_foreign_key.rb: -------------------------------------------------------------------------------- 1 | class RenameBranchesForeignKey < ActiveRecord::Migration[4.2] 2 | def change 3 | rename_column :branches, :commit_monitor_repo_id, :repo_id 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_void_warnings_in_spec_files/non_spec_file_with_void_warning.rb: -------------------------------------------------------------------------------- 1 | class NonSpecFileWithVoidWarning 2 | object_id == 1 3 | object_id == 2 4 | end 5 | -------------------------------------------------------------------------------- /Procfile.tmpl: -------------------------------------------------------------------------------- 1 | rails: bundle exec rails s -p $PORT 2 | sidekiq: bundle exec sidekiq -q miq_bot 3 | sidekiq_glacial: bundle exec sidekiq -q miq_bot_glacial 4 | #development.log: tail -f -n 0 log/development.log 5 | -------------------------------------------------------------------------------- /db/migrate/20171006050814_add_linter_offense_count_to_branch.rb: -------------------------------------------------------------------------------- 1 | class AddLinterOffenseCountToBranch < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :branches, :linter_offense_count, :integer 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20250305205548_add_uniqueness_constraint_on_branches.rb: -------------------------------------------------------------------------------- 1 | class AddUniquenessConstraintOnBranches < ActiveRecord::Migration[6.1] 2 | def up 3 | add_index(:branches, [:name, :repo_id], unique: true) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /lib/github_service/issue_comment.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | class IssueComment < SimpleDelegator 3 | # https://developer.github.com/v3/issues 4 | 5 | def author 6 | user.login 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20140215060546_add_mergeable_to_commit_monitor_branch.rb: -------------------------------------------------------------------------------- 1 | class AddMergeableToCommitMonitorBranch < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :commit_monitor_branches, :mergeable, :boolean 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150814183834_rename_commit_monitor_branches_to_branches.rb: -------------------------------------------------------------------------------- 1 | class RenameCommitMonitorBranchesToBranches < ActiveRecord::Migration[4.2] 2 | def change 3 | rename_table :commit_monitor_branches, :branches 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/wc/norobots.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /db/migrate/20140117063400_add_upstream_user_to_commit_monitor_repo.rb: -------------------------------------------------------------------------------- 1 | class AddUpstreamUserToCommitMonitorRepo < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :commit_monitor_repos, :upstream_user, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20140117055336_add_pull_request_to_commit_monitor_branch.rb: -------------------------------------------------------------------------------- 1 | class AddPullRequestToCommitMonitorBranch < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :commit_monitor_branches, :pull_request, :boolean 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20140214211340_add_commits_list_to_commit_monitor_branches.rb: -------------------------------------------------------------------------------- 1 | class AddCommitsListToCommitMonitorBranches < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :commit_monitor_branches, :commits_list, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_lines_not_in_the_diff/example.rb: -------------------------------------------------------------------------------- 1 | class CodingConvention 2 | HASH = { 3 | :keys => :values, 4 | :arent => :aligned, 5 | :kind_of_like => :this 6 | } 7 | end 8 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_void_warnings_in_spec_files/spec/non_spec_file_in_spec_dir_with_void_warning.rb: -------------------------------------------------------------------------------- 1 | class NonSpecFileInSpecDirWithVoidWarning 2 | object_id == 1 3 | object_id == 2 4 | end 5 | -------------------------------------------------------------------------------- /config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: miq_bot_production 11 | -------------------------------------------------------------------------------- /spec/support/services_helper.rb: -------------------------------------------------------------------------------- 1 | def stub_github_prs(*prs) 2 | prs.flatten! 3 | prs.collect! { |i| double("Github PR #{i}", :number => i) } if prs.first.kind_of?(Numeric) 4 | 5 | expect(GithubService).to receive(:pull_requests).and_return(prs) 6 | end 7 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | end 6 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_with_offenses/coding_convention.rb: -------------------------------------------------------------------------------- 1 | class CodingConvention 2 | HASH = { 3 | :keys => :values, 4 | :arent => :aligned, 5 | :kind_of_like => :this 6 | }.freeze 7 | end 8 | -------------------------------------------------------------------------------- /spec/workers/batch_job_monitor_spec.rb: -------------------------------------------------------------------------------- 1 | describe BatchJobMonitor do 2 | it "#perform_check" do 3 | BatchJob.create! 4 | 5 | expect_any_instance_of(BatchJob).to receive(:check_complete) 6 | 7 | described_class.new.perform_check 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_void_warnings_in_spec_files/spec/spec_file_with_void_warning_spec.rb: -------------------------------------------------------------------------------- 1 | describe "VoidWarning" do 2 | it "has a void warning" do 3 | 1.should == 1 4 | 2.should == 2 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | MiqBot::Application.load_tasks 7 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | def application_version 3 | MiqBot.version 4 | end 5 | 6 | def time_ago_in_words_with_nil_check(time) 7 | time.nil? ? "Never" : "#{time_ago_in_words(time).capitalize} ago" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/lib/linter/base_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Linter::Base do 2 | subject { described_class.new(double("branch")) } 3 | 4 | describe "#linter_env" do 5 | it "is an empty hash" do 6 | expect(subject.send(:linter_env)).to eq({}) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/lib/linter/rubocop_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Linter::Rubocop do 2 | subject { described_class.new(double("branch")) } 3 | 4 | describe "#linter_env" do 5 | it "is an empty hash" do 6 | expect(subject.send(:linter_env)).to eq({}) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /db/migrate/20140115031432_create_commit_monitor_repos.rb: -------------------------------------------------------------------------------- 1 | class CreateCommitMonitorRepos < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :commit_monitor_repos do |t| 4 | t.string :name 5 | t.string :path 6 | t.timestamps 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20150806034945_add_on_complete_class_and_args_to_batch_jobs.rb: -------------------------------------------------------------------------------- 1 | class AddOnCompleteClassAndArgsToBatchJobs < ActiveRecord::Migration[4.2] 2 | def change 3 | add_column :batch_jobs, :on_complete_class, :string 4 | add_column :batch_jobs, :on_complete_args, :text 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /lib/github_service/message_builder.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | class MessageBuilder < ::MessageCollector 3 | COMMENT_BODY_MAX_SIZE = 65_535 4 | 5 | def initialize(header = nil, continuation_header = nil) 6 | super(COMMENT_BODY_MAX_SIZE, header, continuation_header) 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /bin/before_install: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ -n "$CI" ]; then 4 | echo "== Installing system packages ==" 5 | sudo pip install yamllint 6 | echo 7 | 8 | echo "== Setup git user for specs ==" 9 | git config --global user.name "ManageIQ Bot" 10 | git config --global user.email "bot@manageiq.org" 11 | echo 12 | fi 13 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | require 'sidekiq/web' 2 | require 'sidecloq/web' 3 | 4 | Rails.application.routes.draw do 5 | # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html 6 | 7 | mount Sidekiq::Web, at: "/sidekiq" 8 | 9 | root 'main#index' 10 | 11 | get '/github_api_usage' => 'github_api_usage#index' 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/shared_examples/state_predicates.rb: -------------------------------------------------------------------------------- 1 | shared_examples "state predicates" do |method, matrix| 2 | describe "##{method}" do 3 | matrix.each do |state, expected| 4 | it "when #{state}" do 5 | record = described_class.new(:state => state) 6 | expect(record.public_send(method)).to be expected 7 | end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /db/migrate/20140116232751_create_commit_monitor_branches.rb: -------------------------------------------------------------------------------- 1 | class CreateCommitMonitorBranches < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :commit_monitor_branches do |t| 4 | t.string :name 5 | t.string :commit_uri 6 | t.string :last_commit 7 | t.belongs_to :commit_monitor_repo 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /db/migrate/20160119143158_add_merge_head_to_branch.rb: -------------------------------------------------------------------------------- 1 | class AddMergeHeadToBranch < ActiveRecord::Migration[4.2] 2 | class Branch < ActiveRecord::Base; end 3 | 4 | def up 5 | add_column :branches, :merge_target, :string 6 | 7 | Branch.update_all(:merge_target => "master") 8 | end 9 | 10 | def down 11 | remove_column :branches, :merge_target 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /config/settings/test.yml: -------------------------------------------------------------------------------- 1 | :labels: 2 | :unassignable: 3 | jansa/yes: jansa/yes? 4 | :unremovable: 5 | - jansa/no 6 | - jansa/yes 7 | # In test, turn everything on by default 8 | # NOTE: Using empty string is a HACK until we can get 9 | # https://github.com/danielsdeleo/deep_merge/pull/33 released via the 10 | # config gem. 11 | merge_target_titler: 12 | included_repos: '' 13 | -------------------------------------------------------------------------------- /config/initializers/config_listener.rb: -------------------------------------------------------------------------------- 1 | Listen.to(Rails.root.join("config"), :ignore => /github_notification_monitor\.yml/) do |*paths| 2 | begin 3 | Rails.logger.info "Reloading settings due to changes in #{paths.flatten.join(", ")}" 4 | Settings.reload! 5 | rescue => err 6 | Rails.logger.error "Unable to reload the settings!" 7 | Rails.logger.error err 8 | end 9 | end.start 10 | -------------------------------------------------------------------------------- /spec/lib/linter/haml_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Linter::Haml do 2 | subject { described_class.new(double("branch")) } 3 | let(:stub_dir) { File.expand_path(File.join(*%w[.. .. .. vendor stubs]), __dir__) } 4 | 5 | describe "#linter_env" do 6 | it "is an empty hash" do 7 | expect(subject.send(:linter_env)).to eq({"RUBYOPT" => "-I #{stub_dir}"}) 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /config/settings.yml: -------------------------------------------------------------------------------- 1 | # Service credentials 2 | github_credentials: 3 | username: 4 | password: 5 | 6 | # General settings 7 | labels: 8 | unassignable: {} 9 | unremovable: [] 10 | 11 | # Worker settings 12 | diff_content_checker: 13 | offenses: {} 14 | merge_target_titler: 15 | included_repos: [] 16 | path_based_labeler: 17 | rules: {} 18 | stale_issue_marker: 19 | pinned_labels: 20 | - pinned 21 | -------------------------------------------------------------------------------- /lib/threadsafe_service_mixin.rb: -------------------------------------------------------------------------------- 1 | module ThreadsafeServiceMixin 2 | extend ActiveSupport::Concern 3 | 4 | include ServiceMixin 5 | 6 | module ClassMethods 7 | def call(*args) 8 | synchronize { super } 9 | end 10 | 11 | private 12 | 13 | def mutex 14 | @mutex ||= Mutex.new 15 | end 16 | 17 | def synchronize 18 | mutex.synchronize { yield } 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/views/layouts/_navbar.html.erb: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /config/database.vagrant.yml: -------------------------------------------------------------------------------- 1 | --- 2 | base: &base 3 | adapter: postgresql 4 | encoding: utf8 5 | pool: 25 6 | wait_timeout: 5 7 | 8 | development: 9 | <<: *base 10 | database: miq_bot_development 11 | 12 | production: 13 | <<: *base 14 | database: miq_bot_production 15 | 16 | test: &test 17 | <<: *base 18 | database: miq_bot_test 19 | # Silence these: 'NOTICE: CREATE TABLE will create...' 20 | min_messages: warning 21 | -------------------------------------------------------------------------------- /db/migrate/20150730043503_create_batch_tables.rb: -------------------------------------------------------------------------------- 1 | class CreateBatchTables < ActiveRecord::Migration[4.2] 2 | def change 3 | create_table :batch_jobs do |t| 4 | t.timestamps 5 | t.timestamp :expires_at 6 | end 7 | 8 | create_table :batch_entries do |t| 9 | t.belongs_to :batch_job 10 | t.index :batch_job_id 11 | 12 | t.string :state 13 | t.text :result 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/workers/batch_job_monitor.rb: -------------------------------------------------------------------------------- 1 | class BatchJobMonitor 2 | include Sidekiq::Worker 3 | sidekiq_options :queue => :miq_bot, :retry => false 4 | 5 | include SidekiqWorkerMixin 6 | 7 | def perform 8 | if !first_unique_worker? 9 | logger.info "#{self.class} is already running, skipping" 10 | else 11 | perform_check 12 | end 13 | end 14 | 15 | def perform_check 16 | BatchJob.all.each(&:check_complete) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += [ 7 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 8 | ] 9 | -------------------------------------------------------------------------------- /config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /lib/github_service/commands/assign.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class Assign < Base 4 | private 5 | 6 | def _execute(issuer:, value:) 7 | user = value.strip.delete('@') 8 | 9 | if valid_assignee?(user) 10 | issue.assign(user) 11 | else 12 | issue.add_comment("@#{issuer} '#{user}' is an invalid assignee, ignoring...") 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /config/credentials.yml.enc: -------------------------------------------------------------------------------- 1 | lgVBdCk3JuT7xVJE9W+trX3XdOT8uLYF4pb/MaKjOQMGJSJ7pIEmvg9eqOhC/YvxTfk1KKhovfeCinzTQ0Hs9Qw8tj8ucKmUU8jUiCmTcTcH4VwRL+MZekbvhR7b/s3CMzz2/cyluyZy0S6kYZjKA3GXN0IgLTg1UU3MdpjoChR4MZ6REiCRT43CmyR8eLpHa3GTQB1bVV7L8ON33fbrVhFlMA76Yk8hAqM3uCSFmdF2iblaoprnoahPTTADniKGJ5h5IROsf0bag2AHm+kI8RSA57WLWUOrWr81p8NiA9ribIMETUNA5JPQ5tZsM9Iu9ZmzFUOlu/tULEZiIW5UttEobVkHXBAKB3F415jvAkFj4R9+kQxiPQyITPzHnxNkePyToRCvGu/VMkqkPe5hFbHYiyuey9JRnr5/--w8X2QuBiDTwYY8yC--dGMBDDAtAcdl+N89ZRQD9g== -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | 9 | require_relative 'seeds.local.rb' if File.exist?(File.expand_path('seeds.local.rb', __dir__)) 10 | -------------------------------------------------------------------------------- /lib/offense_message.rb: -------------------------------------------------------------------------------- 1 | class OffenseMessage 2 | attr_accessor :entries 3 | 4 | def initialize 5 | @entries = [] 6 | end 7 | 8 | def lines 9 | entries.sort.group_by(&:group).collect do |group, sub_entries| 10 | [ 11 | format_group(group), 12 | sub_entries.collect(&:to_s), 13 | "" 14 | ] 15 | end.flatten.compact[0...-1] 16 | end 17 | 18 | private 19 | 20 | def format_group(group) 21 | "**#{group}**" if group 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/support/settings_helper.rb: -------------------------------------------------------------------------------- 1 | def stub_settings(hash) 2 | settings = Config::Options.new 3 | settings.merge!(Settings.to_hash) 4 | settings.merge!(hash) 5 | stub_const("Settings", settings) 6 | end 7 | 8 | # Need a special stub for nil settings until https://github.com/danielsdeleo/deep_merge/pull/33 9 | # is released with the config gem 10 | def stub_nil_settings(hash) 11 | settings = Config::Options.new 12 | settings.merge!(hash) 13 | stub_const("Settings", settings) 14 | end 15 | -------------------------------------------------------------------------------- /config/database.yml.sample: -------------------------------------------------------------------------------- 1 | --- 2 | base: &base 3 | adapter: postgresql 4 | encoding: utf8 5 | host: localhost 6 | username: 7 | password: 8 | pool: 25 9 | wait_timeout: 5 10 | 11 | development: 12 | <<: *base 13 | database: miq_bot_development 14 | 15 | production: 16 | <<: *base 17 | database: miq_bot_production 18 | 19 | test: &test 20 | <<: *base 21 | database: miq_bot_test 22 | # Silence these: 'NOTICE: CREATE TABLE will create...' 23 | min_messages: warning 24 | -------------------------------------------------------------------------------- /lib/github_service/notification.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | class Notification < SimpleDelegator 3 | # https://developer.github.com/v3/activity/notifications/ 4 | 5 | def mark_thread_as_read 6 | GithubService.mark_thread_as_read(thread_id, "read" => false) 7 | end 8 | 9 | def issue_number 10 | subject.url&.match(/\/([0-9]+)\Z/)&.try(:[], 1) 11 | end 12 | 13 | private 14 | 15 | def thread_id 16 | url.match(/[0-9]+\Z/).to_s 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/sidecloq.yml: -------------------------------------------------------------------------------- 1 | BatchJobMonitor: 2 | class: BatchJobMonitor 3 | cron: "* * * * *" # minutely 4 | 5 | CommitMonitor: 6 | class: CommitMonitor 7 | cron: "*/5 * * * *" # every 5th minute 8 | 9 | GithubNotificationMonitorWorker: 10 | class: GithubNotificationMonitorWorker 11 | cron: "* * * * *" # minutely 12 | 13 | PullRequestMonitor: 14 | class: PullRequestMonitor 15 | cron: "*/5 * * * *" # every 5th minute 16 | 17 | StaleIssueMarker: 18 | class: StaleIssueMarker 19 | cron: "0 0 * * 1" # midnight every Monday 20 | -------------------------------------------------------------------------------- /app/workers/schedulers/stale_issue_marker.rb: -------------------------------------------------------------------------------- 1 | module Schedulers 2 | class StaleIssueMarker 3 | include Sidekiq::Worker 4 | sidekiq_options :queue => :miq_bot_glacial, :retry => false 5 | 6 | include SidekiqWorkerMixin 7 | 8 | def perform 9 | if !first_unique_worker? 10 | logger.info "#{self.class} is already running, skipping" 11 | else 12 | process_stale_issues 13 | end 14 | end 15 | 16 | def process_stale_issues 17 | StaleIssueMarker.perform_async 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/github_service/commands/close_issue.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class CloseIssue < Base 4 | restrict_to :organization 5 | 6 | private 7 | 8 | def _execute(issuer:, value:) 9 | if issue.pull_request? && issuer != issue.author 10 | issue.add_comment("@#{issuer} Only @#{issue.author} or a committer can close this pull request.") 11 | else 12 | GithubService.close_issue(issue.fq_repo_name, issue.number) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/factories/branch.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | 3 | FactoryBot.define do 4 | factory :branch do 5 | sequence(:name) { |n| "branch_#{n}" } 6 | commit_uri { "https://example.com/#{repo.name}/commit/$commit" } 7 | last_commit { SecureRandom.hex(40) } 8 | 9 | repo 10 | end 11 | 12 | factory :pr_branch, :parent => :branch do 13 | sequence(:name) { |n| "prs/#{n}/head" } 14 | sequence(:pr_title) { |n| "PR title #{n}" } 15 | merge_target { "master" } 16 | 17 | pull_request { true } 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /lib/linter/haml.rb: -------------------------------------------------------------------------------- 1 | module Linter 2 | class Haml < Base 3 | private 4 | 5 | def config_files 6 | [".haml-lint.yml"] + Linter::Rubocop::CONFIG_FILES 7 | end 8 | 9 | def linter_executable 10 | "haml-lint" 11 | end 12 | 13 | def linter_env 14 | parser_stub_path = Rails.root.join("vendor", "stubs").to_s 15 | {"RUBYOPT" => "-I #{parser_stub_path}"} 16 | end 17 | 18 | def options 19 | {:reporter => 'json'} 20 | end 21 | 22 | def filtered_files(files) 23 | files.select { |file| file.end_with?(".haml") } 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /db/migrate/20151204171252_update_branch_name_for_pr_branches.rb: -------------------------------------------------------------------------------- 1 | class UpdateBranchNameForPrBranches < ActiveRecord::Migration[4.2] 2 | class Branch < ActiveRecord::Base; end # Don't use the real model 3 | 4 | def up 5 | Branch.where(:pull_request => true).each do |branch| 6 | pr_number = branch.name.split("/").last 7 | branch.update(:name => "prs/#{pr_number}/head") 8 | end 9 | end 10 | 11 | def down 12 | Branch.where(:pull_request => true).each do |branch| 13 | pr_number = branch.name.split("/")[1] 14 | branch.update(:name => "pr/#{pr_number}") 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/console_methods.rb: -------------------------------------------------------------------------------- 1 | module ConsoleMethods 2 | def simulate_sidekiq(*queue_names, run_once: false) 3 | run = true 4 | while run do 5 | queue_names.each do |queue_name| 6 | puts "[#{Time.now}] Processing '#{queue_name}'..." 7 | Sidekiq::Queue.new(queue_name).each do |job| 8 | worker = job['class'].constantize 9 | args = job['args'] 10 | puts "\nProcessing #{worker} with args: #{args}" 11 | worker.new.perform(*job['args']) 12 | job.delete 13 | end 14 | end 15 | run = false if run_once 16 | sleep(2) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /container-assets/container_env: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | export RAILS_ENV=production 4 | export PATH=/opt/miq_bot:$PATH 5 | 6 | function urlescape() { 7 | PAYLOAD="$1" ruby -rcgi -e "puts CGI.escape(ENV['PAYLOAD'])" 8 | } 9 | 10 | safeuser=$(urlescape ${DATABASE_USER}) 11 | safepass=$(urlescape ${DATABASE_PASSWORD}) 12 | if [ -z "${safeuser}" -o -z "${safepass}" ]; then 13 | DATABASE_USERINFO="" 14 | else 15 | DATABASE_USERINFO="${safeuser}:${safepass}@" 16 | fi 17 | 18 | export DATABASE_URL="postgresql://${DATABASE_USERINFO}${DATABASE_HOSTNAME:-localhost}:${DATABASE_PORT:-5432}/${DATABASE_NAME:-miq_bot_production}?encoding=utf8&pool=25&wait_timeout=5" 19 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/branch_mergeability_checker_spec.rb: -------------------------------------------------------------------------------- 1 | describe CommitMonitorHandlers::CommitRange::BranchMergeabilityChecker do 2 | let!(:branch) { create(:branch) } 3 | let!(:pr_branch) { create(:pr_branch, :repo => branch.repo, :merge_target => branch.name) } 4 | let!(:pr_branch2) { create(:pr_branch, :repo => branch.repo) } 5 | 6 | before { stub_sidekiq_logger } 7 | 8 | it "queues up PrMergeabilityChecker for PRs targeting this branch" do 9 | expect(PrMergeabilityChecker).to receive(:perform_async).once.with(pr_branch.id) 10 | 11 | described_class.new.perform(branch.id, ["abcde123"]) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /app/models/batch_entry.rb: -------------------------------------------------------------------------------- 1 | class BatchEntry < ActiveRecord::Base 2 | serialize :result 3 | belongs_to :job, :class_name => "BatchJob", :foreign_key => :batch_job_id, :inverse_of => :entries 4 | 5 | validates :state, :inclusion => {:in => %w(started failed succeeded skipped), :allow_nil => true} 6 | 7 | def succeeded? 8 | state == "succeeded" 9 | end 10 | 11 | def failed? 12 | state == "failed" 13 | end 14 | 15 | def skipped? 16 | state == "skipped" 17 | end 18 | 19 | def complete? 20 | failed? || succeeded? || skipped? 21 | end 22 | 23 | def check_job_complete 24 | job.check_complete if complete? && job 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /container-assets/entrypoint: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | source /usr/local/bin/container_env 4 | 5 | mkdir /root/.ssh 6 | cp /root/ssh/* /root/.ssh/ 7 | chown 600 /root/.ssh/miq-bot 8 | 9 | pushd /opt/miq_bot/config 10 | ln -s /opt/miq_bot_data/github_notification_monitor.yml 11 | 12 | ln -s /run/secrets/config/master.key 13 | ln -s /run/secrets/config/settings.local.yml 14 | popd 15 | 16 | [[ -n $QUEUE_NAME ]] && COMMAND="sidekiq -q $QUEUE_NAME" 17 | [[ -z $COMMAND ]] && COMMAND="rails server" 18 | 19 | cd /opt/miq_bot 20 | 21 | bundle exec rake db:create 22 | bundle exec rake db:migrate 23 | bundle exec rake assets:precompile 24 | 25 | exec bundle exec ${COMMAND} 26 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/branch_mergeability_checker.rb: -------------------------------------------------------------------------------- 1 | class CommitMonitorHandlers::CommitRange::BranchMergeabilityChecker 2 | include Sidekiq::Worker 3 | sidekiq_options :queue => :miq_bot 4 | 5 | include BranchWorkerMixin 6 | 7 | def self.handled_branch_modes 8 | [:regular] 9 | end 10 | 11 | def perform(branch_id, _new_commits) 12 | return unless find_branch(branch_id, :regular) 13 | 14 | repo.pr_branches.where(:merge_target => branch.name).each do |pr| 15 | logger.info("Queueing PrMergeabilityChecker for PR #{pr.fq_pr_number}.") 16 | PrMergeabilityChecker.perform_async(pr.id) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_with_no_offenses/results.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "rubocop_version": "1.13.0", 4 | "ruby_engine": "ruby", 5 | "ruby_version": "2.7.2", 6 | "ruby_patchlevel": "137", 7 | "ruby_platform": "x86_64-darwin19" 8 | }, 9 | "files": [ 10 | { 11 | "path": "spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_with_no_offenses/no_offenses.rb", 12 | "offenses": [ 13 | 14 | ] 15 | } 16 | ], 17 | "summary": { 18 | "offense_count": 0, 19 | "target_file_count": 1, 20 | "inspected_file_count": 1 21 | } 22 | } -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | prepare: 2 | fetch: 3 | - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/.rubocop_base.yml 4 | path: ".rubocop_base.yml" 5 | - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/.rubocop_cc_base.yml 6 | path: ".rubocop_cc_base.yml" 7 | - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/styles/base.yml 8 | path: styles/base.yml 9 | - url: https://raw.githubusercontent.com/ManageIQ/manageiq-style/master/styles/cc_base.yml 10 | path: styles/cc_base.yml 11 | plugins: 12 | rubocop: 13 | enabled: true 14 | config: ".rubocop_cc.yml" 15 | channel: rubocop-1-56-3 16 | version: '2' 17 | -------------------------------------------------------------------------------- /lib/github_service/response/ratelimit_logger.rb: -------------------------------------------------------------------------------- 1 | require 'faraday' 2 | 3 | module GithubService 4 | module Response 5 | class RatelimitLogger < Faraday::Middleware 6 | attr_accessor :logger 7 | 8 | def initialize(app, logger = nil) 9 | super(app) 10 | @logger = logger || begin 11 | require 'logger' 12 | ::Logger.new(STDOUT) 13 | end 14 | end 15 | 16 | def on_complete(env) 17 | api_calls_remaining = env.response_headers['x-ratelimit-remaining'] 18 | logger.info { "Executed #{env.method.to_s.upcase} #{env.url}...api calls remaining #{api_calls_remaining}" } 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /app/workers/github_notification_monitor_worker.rb: -------------------------------------------------------------------------------- 1 | class GithubNotificationMonitorWorker 2 | include Sidekiq::Worker 3 | sidekiq_options :queue => :miq_bot, :retry => false 4 | 5 | include SidekiqWorkerMixin 6 | 7 | def perform 8 | if !first_unique_worker? 9 | logger.info "#{self.class} is already running, skipping" 10 | else 11 | process_repos 12 | end 13 | end 14 | 15 | def process_repos 16 | enabled_repos.each { |repo| process_repo(repo) } 17 | end 18 | 19 | def process_repo(repo) 20 | GithubNotificationMonitor.new(repo.name).process_notifications 21 | rescue => err 22 | logger.error err.message 23 | logger.error err.backtrace.join("\n") 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the top of the 9 | * compiled file, but it's generally better to create a new file per style scope. 10 | * 11 | *= require_self 12 | *= require_tree . 13 | */ 14 | 15 | .page-footer { 16 | padding-top: 9px; 17 | margin: 20px 0 40px; 18 | border-top: 1px solid #eeeeee; 19 | } 20 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require turbolinks 16 | //= require_tree . 17 | -------------------------------------------------------------------------------- /spec/support/shared_examples/threadsafe_service_mixin_examples.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "ThreadsafeServiceMixin service" do 2 | context ".new" do 3 | it "is private" do 4 | expect { described_class.new }.to raise_error(NoMethodError) 5 | end 6 | end 7 | 8 | context ".call" do 9 | it "will synchronize multiple callers" do 10 | t = Thread.new do 11 | with_service do |_service| 12 | Thread.current[:locked] = true 13 | sleep 0.01 until Thread.current[:release] 14 | end 15 | end 16 | t.abort_on_exception = true 17 | sleep 0.01 until t[:locked] 18 | 19 | expect(described_class.send(:mutex)).to be_locked 20 | 21 | t[:release] = true 22 | t.join 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/views/main/_commit_monitor.html.erb: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | <% @branches.each do |branch| %> 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | <% end %> 22 | 23 |
RepositoryBranchLast CommitLast CheckedLast Changed
<%= branch.fq_repo_name %><%= branch.name %><%= link_to(branch.last_commit[0, 8], branch.last_commit_uri, :target => "_blank") %><%= time_ago_in_words_with_nil_check(branch.last_checked_on) %><%= time_ago_in_words_with_nil_check(branch.last_changed_on) %>
24 |
25 | -------------------------------------------------------------------------------- /lib/linter/rubocop.rb: -------------------------------------------------------------------------------- 1 | require 'rubocop' 2 | 3 | module Linter 4 | class Rubocop < Base 5 | CONFIG_FILES = %w[.rubocop.yml .rubocop_base.yml .rubocop_local.yml].freeze 6 | 7 | private 8 | 9 | def config_files 10 | CONFIG_FILES 11 | end 12 | 13 | def linter_executable 14 | 'rubocop' 15 | end 16 | 17 | def options 18 | {:format => 'json', :no_display_cop_names => nil} 19 | end 20 | 21 | def filtered_files(files) 22 | files.select do |file| 23 | file.end_with?(".rb") || 24 | file.end_with?(".ru") || 25 | file.end_with?(".rake") || 26 | File.basename(file).in?(%w(Gemfile Rakefile)) 27 | end.reject do |file| 28 | file.end_with?("db/schema.rb") 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/github_service/commands/add_reviewer.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class AddReviewer < Base 4 | alias_as 'request_review' 5 | 6 | private 7 | 8 | def _execute(issuer:, value:) 9 | users = value.strip.delete('@').split(/\s*,\s*/) 10 | 11 | valid_users, invalid_users = users.partition { |u| valid_assignee?(u) } 12 | 13 | if valid_users.any? 14 | issue.add_reviewer(valid_users) 15 | end 16 | 17 | if invalid_users.any? 18 | message = "@#{issuer} Cannot add the following #{"reviewer".pluralize(invalid_users.size)} because they are not recognized: " 19 | message << invalid_users.join(", ") 20 | issue.add_comment(message) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/workers/concerns/batch_job_worker_mixin.rb: -------------------------------------------------------------------------------- 1 | module BatchJobWorkerMixin 2 | extend ActiveSupport::Concern 3 | 4 | module ClassMethods 5 | def perform_batch_async(*args) 6 | BatchJob.perform_async(batch_workers, args, 7 | :on_complete_class => self, 8 | :on_complete_args => args, 9 | :expires_at => 5.minutes.from_now 10 | ) 11 | end 12 | end 13 | 14 | attr_reader :batch_job 15 | 16 | def find_batch_job(batch_job_id) 17 | @batch_job = BatchJob.where(:id => batch_job_id).first 18 | 19 | if @batch_job.nil? 20 | logger.warn("BatchJob #{batch_job_id} no longer exists. Skipping.") 21 | return false 22 | end 23 | 24 | true 25 | end 26 | 27 | def complete_batch_job 28 | batch_job.destroy 29 | end 30 | alias skip_batch_job complete_batch_job 31 | end 32 | -------------------------------------------------------------------------------- /db/migrate/20140128174930_replace_timestamps_with_last_checked_on_and_last_changed_on_for_commit_monitor_branches.rb: -------------------------------------------------------------------------------- 1 | class ReplaceTimestampsWithLastCheckedOnAndLastChangedOnForCommitMonitorBranches < ActiveRecord::Migration[4.2] 2 | class CommitMonitorBranch < ActiveRecord::Base 3 | end 4 | 5 | def change 6 | add_column :commit_monitor_branches, :last_checked_on, :timestamp 7 | add_column :commit_monitor_branches, :last_changed_on, :timestamp 8 | 9 | say_with_time("Moving commit_monitor_branches.updated_at to commit_monitor_branches.last_changed_on") do 10 | CommitMonitorBranch.all.each do |b| 11 | b.update!(:last_changed_on => b.updated_at) 12 | end 13 | end 14 | 15 | remove_column :commit_monitor_branches, :created_at, :timestamp 16 | remove_column :commit_monitor_branches, :updated_at, :timestamp 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /db/migrate/20150827194533_collapse_repo_upstream_user_into_name.rb: -------------------------------------------------------------------------------- 1 | class CollapseRepoUpstreamUserIntoName < ActiveRecord::Migration[4.2] 2 | class Repo < ActiveRecord::Base 3 | end 4 | 5 | def up 6 | say_with_time("Collapse Repo upstream_user into name") do 7 | Repo.all.each do |r| 8 | r.update!(:name => [r.upstream_user, r.name].compact.join("/")) 9 | end 10 | end 11 | 12 | remove_column :repos, :upstream_user 13 | end 14 | 15 | def down 16 | add_column :repos, :upstream_user, :string 17 | 18 | say_with_time("Split out Repo upstream_user from name") do 19 | Repo.all.each do |r| 20 | r.update!(:upstream_user => name_parts(r.name).first, :name => name_parts(r.name).last) 21 | end 22 | end 23 | end 24 | 25 | def name_parts(name) 26 | name.split("/", 2).unshift(nil).last(2) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/github_service/commands/set_milestone.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class SetMilestone < Base 4 | private 5 | 6 | def _execute(issuer:, value:) 7 | milestone = value.strip 8 | 9 | if valid_milestone?(milestone) 10 | issue.set_milestone(milestone) 11 | else 12 | issue.add_comment("@#{issuer} Milestone #{milestone} is not recognized, ignoring...") 13 | end 14 | end 15 | 16 | def valid_milestone?(milestone) 17 | # First reload the cache if it's an invalid milestone 18 | GithubService.refresh_milestones(issue.fq_repo_name) unless GithubService.valid_milestone?(issue.fq_repo_name, milestone) 19 | 20 | # Then see if it's *still* invalid 21 | GithubService.valid_milestone?(issue.fq_repo_name, milestone) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /app/workers/concerns/code_analysis_mixin.rb: -------------------------------------------------------------------------------- 1 | module CodeAnalysisMixin 2 | def merged_linter_results 3 | results = { 4 | "files" => [], 5 | "summary" => { 6 | "inspected_file_count" => 0, 7 | "offense_count" => 0, 8 | "target_file_count" => 0, 9 | }, 10 | } 11 | 12 | run_all_linters.each do |result| 13 | %w(offense_count target_file_count inspected_file_count).each do |m| 14 | results['summary'][m] += result['summary'][m] 15 | end 16 | results['files'] += result['files'] 17 | end 18 | 19 | results 20 | end 21 | 22 | def run_all_linters 23 | unmerged_results = [] 24 | unmerged_results << Linter::Rubocop.new(branch).run 25 | unmerged_results << Linter::Haml.new(branch).run 26 | unmerged_results << Linter::Yaml.new(branch).run 27 | unmerged_results.tap(&:compact!) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | MIQ Bot 5 | <%= stylesheet_link_tag "application", media: "all", "data-turbolinks-track" => true %> 6 | <%= javascript_include_tag "application", "data-turbolinks-track" => true %> 7 | 8 | 9 | 10 | 11 | 12 | 13 | <%= csrf_meta_tags %> 14 | 15 | 16 |
17 | <%= render "layouts/navbar" %> 18 | <%= yield %> 19 | <%= render "layouts/footer" %> 20 |
21 | 22 | 23 | -------------------------------------------------------------------------------- /app/workers/concerns/batch_entry_worker_mixin.rb: -------------------------------------------------------------------------------- 1 | module BatchEntryWorkerMixin 2 | attr_reader :batch_entry 3 | 4 | def find_batch_entry(batch_entry_id) 5 | @batch_entry = BatchEntry.where(:id => batch_entry_id).first 6 | 7 | if @batch_entry.nil? 8 | logger.warn("BatchEntry #{batch_entry_id} no longer exists. Skipping.") 9 | return false 10 | end 11 | 12 | true 13 | end 14 | 15 | def batch_job 16 | batch_entry.job 17 | end 18 | 19 | def complete_batch_entry(updates = {}) 20 | update_batch_entry(updates) 21 | check_job_complete 22 | end 23 | 24 | def skip_batch_entry 25 | complete_batch_entry(:state => "skipped") 26 | end 27 | 28 | private 29 | 30 | def update_batch_entry(updates) 31 | updates = updates.reverse_merge(:state => "succeeded") 32 | batch_entry.update!(updates) 33 | end 34 | 35 | def check_job_complete 36 | batch_entry.check_job_complete 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/path_based_labeler.rb: -------------------------------------------------------------------------------- 1 | class CommitMonitorHandlers::CommitRange::PathBasedLabeler 2 | include Sidekiq::Worker 3 | sidekiq_options :queue => :miq_bot 4 | 5 | include BranchWorkerMixin 6 | 7 | def self.handled_branch_modes 8 | [:pr] 9 | end 10 | 11 | def perform(branch_id, _new_commits) 12 | return unless find_branch(branch_id, :pr) 13 | 14 | process_branch 15 | end 16 | 17 | private 18 | 19 | def process_branch 20 | labels = [] 21 | label_rules.each do |rule| 22 | if diff_file_names.any? { |file_name| file_name =~ rule.regex } 23 | labels << rule.label 24 | end 25 | end 26 | GithubService.add_labels_to_an_issue(fq_repo_name, pr_number, labels) if labels.present? 27 | rescue GitService::UnmergeableError 28 | # Ignore unmergeable PRs 29 | end 30 | 31 | def label_rules 32 | Settings.path_based_labeler.rules[fq_repo_name] || [] 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /app/workers/pull_request_monitor_handlers/merge_target_titler.rb: -------------------------------------------------------------------------------- 1 | module PullRequestMonitorHandlers 2 | class MergeTargetTitler 3 | include Sidekiq::Worker 4 | sidekiq_options :queue => :miq_bot 5 | 6 | include BranchWorkerMixin 7 | 8 | def perform(branch_id) 9 | return unless find_branch(branch_id, :pr) 10 | 11 | process_branch 12 | end 13 | 14 | private 15 | 16 | def process_branch 17 | apply_title if merge_target != "master" && !already_titled? 18 | end 19 | 20 | def already_titled? 21 | pr_title_tags.map(&:downcase).include?(merge_target.downcase) 22 | end 23 | 24 | def apply_title 25 | logger.info("Updating PR #{pr_number} with title change for merge target #{merge_target}.") 26 | GithubService.update_pull_request(fq_repo_name, pr_number, :title => new_pr_title) 27 | end 28 | 29 | def new_pr_title 30 | "[#{merge_target.upcase}] #{pr_title}" 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_without_column_numbers_and_cop_names/results.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "rubocop_version": "1.13.0", 4 | "ruby_engine": "ruby", 5 | "ruby_version": "2.7.2", 6 | "ruby_patchlevel": "137", 7 | "ruby_platform": "x86_64-darwin19" 8 | }, 9 | "files": [ 10 | { 11 | "path": "spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_results_without_column_numbers_and_cop_names/example.haml", 12 | "offenses": [ 13 | { 14 | "severity": "warning", 15 | "message": "The - symbol should have one space separating it from code", 16 | "location": { 17 | "line": 2 18 | }, 19 | "linter_name": "SpaceBeforeScript" 20 | } 21 | ] 22 | } 23 | ], 24 | "summary": { 25 | "offense_count": 1, 26 | "target_file_count": 1, 27 | "inspected_file_count": 1 28 | } 29 | } -------------------------------------------------------------------------------- /spec/workers/pull_request_monitor_handlers/wip_labeler_spec.rb: -------------------------------------------------------------------------------- 1 | describe PullRequestMonitorHandlers::WipLabeler do 2 | let(:branch) { create(:pr_branch) } 3 | 4 | before do 5 | stub_sidekiq_logger 6 | stub_settings(:wip_labeler => {:enabled_repos => [branch.repo.name]}) 7 | end 8 | 9 | context "when the PR title does not have [WIP]" do 10 | it "removes the wip label if it exists" do 11 | expect(GithubService).to receive(:remove_label).with(branch.repo.name, branch.pr_number, "wip") 12 | 13 | described_class.new.perform(branch.id) 14 | end 15 | end 16 | 17 | context "when the PR title has [WIP]" do 18 | it "adds the wip label if it does not exist" do 19 | branch.update(:pr_title => "[WIP] #{branch.pr_title}") 20 | 21 | expect(GithubService).to receive(:add_labels_to_an_issue).with(branch.repo.name, branch.pr_number, ["wip"]) 22 | 23 | described_class.new.perform(branch.id) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /vendor/stubs/parser/current.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Injects a "stub" 'parser/current' file (that should be appended to the Ruby 4 | # $LOADPATH), which avoids the annoying loading the original, which includes 5 | # the unskippable warnings like this: 6 | # 7 | # warning: parser/current is loading parser/ruby27, which recognizes 8 | # warning: 2.7.2-compliant syntax, but you are running 2.7.1. 9 | # warning: please see https://github.com/whitequark/parser#compatibility-with-ruby-mri. 10 | # 11 | # in STDERR, which are flagged by the bot as errors (when they aren't useful to 12 | # the end user). 13 | # 14 | # This stub will just define the `Parser::CurrentRuby` constant manually using 15 | # the conventions of the `parser` gem. 16 | # 17 | 18 | require "parser/ruby#{RbConfig::CONFIG["MAJOR"]}#{RbConfig::CONFIG["MINOR"]}" 19 | 20 | module Parser 21 | CurrentRuby = self.const_get("Ruby#{RbConfig::CONFIG["MAJOR"]}#{RbConfig::CONFIG["MINOR"]}") 22 | end 23 | -------------------------------------------------------------------------------- /lib/service_mixin.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/module/delegation" 2 | require "active_support/concern" 3 | 4 | module ServiceMixin 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | # Hide new in favor of using .call with block for consistency with No 9 | private_class_method :new 10 | 11 | class << self 12 | attr_accessor :credentials, :logger 13 | end 14 | delegate :credentials, :logger, :to => self 15 | end 16 | 17 | module ClassMethods 18 | def call(*options) 19 | raise "no block given" unless block_given? 20 | yield new(*options) 21 | end 22 | end 23 | 24 | private 25 | 26 | def delegate_to_service(method_name, *args) 27 | service.send(method_name, *args) 28 | end 29 | 30 | def method_missing(method_name, *args) # rubocop:disable Style/MethodMissing 31 | delegate_to_service(method_name, *args) 32 | end 33 | 34 | def respond_to_missing?(*args) 35 | service.respond_to?(*args) 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_haml_file_using_haml-lint/results.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "rubocop_version": "1.13.0", 4 | "ruby_engine": "ruby", 5 | "ruby_version": "2.7.2", 6 | "ruby_patchlevel": "137", 7 | "ruby_platform": "x86_64-darwin19" 8 | }, 9 | "files": [ 10 | { 11 | "path": "spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_haml_file_using_haml-lint/example.haml", 12 | "offenses": [ 13 | { 14 | "severity": "error", 15 | "message": "You don't need to use \"- end\" in Haml. Un-indent to close a block:\n- if foo?\n %strong Foo!\n- else\n Not foo.\n%p This line is un-indented, so it isn't part of the \"if\" block", 16 | "location": { 17 | "line": 3 18 | }, 19 | "linter_name": "Syntax" 20 | } 21 | ] 22 | } 23 | ], 24 | "summary": { 25 | "offense_count": 1, 26 | "target_file_count": 1, 27 | "inspected_file_count": 1 28 | } 29 | } -------------------------------------------------------------------------------- /lib/github_service/concerns/is_team_member.rb: -------------------------------------------------------------------------------- 1 | module IsTeamMember 2 | def triage_member?(username) 3 | IsTeamMember.triage_team_members.include?(username) 4 | end 5 | 6 | # List of usernames for the triage team 7 | # 8 | # Cache triage_team_members, and refresh cache every 24 hours 9 | # 10 | # Note: This is created as a class method 11 | # 12 | cache_with_timeout(:triage_team_members, 24 * 60 * 60) do 13 | if member_organization_name && triage_team_name 14 | team = GithubService.org_teams(member_organization_name) 15 | .detect { |t| t.name == triage_team_name } 16 | 17 | if team.nil? 18 | [] 19 | else 20 | GithubService.team_members(team.id).map(&:login) 21 | end 22 | else 23 | [] 24 | end 25 | end 26 | 27 | module_function 28 | 29 | def triage_team_name 30 | @triage_team_name ||= Settings.triage_team_name || nil 31 | end 32 | 33 | def member_organization_name 34 | @member_organization_name ||= Settings.member_organization_name || nil 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | .ruby-version 8 | .rubocop-* 9 | 10 | # Ignore bundler config. 11 | /.bundle 12 | 13 | # Ignore the default SQLite database. 14 | /db/*.sqlite3 15 | /db/*.sqlite3-journal 16 | 17 | # Ignore all logfiles and tempfiles. 18 | /log/**/*.log 19 | /tmp 20 | /spec/tmp 21 | 22 | # Ignore configuration files 23 | /config/data_backup 24 | /config/database.yml 25 | /config/github_notification_monitor.yml 26 | /config/settings.local.yml 27 | /config/settings/*.local.yml 28 | /config/environments/*.local.yml 29 | 30 | # Ignore development Procfile 31 | /Procfile 32 | 33 | # Ignore production artifacts 34 | /public/assets 35 | 36 | # Ignore local repos folder 37 | /repos 38 | 39 | # Ignore local seeds file 40 | db/seeds.local.rb 41 | 42 | /config/master.key 43 | -------------------------------------------------------------------------------- /app/workers/pull_request_monitor_handlers/wip_labeler.rb: -------------------------------------------------------------------------------- 1 | module PullRequestMonitorHandlers 2 | class WipLabeler 3 | include Sidekiq::Worker 4 | sidekiq_options :queue => :miq_bot 5 | 6 | include BranchWorkerMixin 7 | 8 | LABEL = "wip".freeze 9 | 10 | def perform(branch_id) 11 | return unless find_branch(branch_id, :pr) 12 | 13 | process_branch 14 | end 15 | 16 | private 17 | 18 | def process_branch 19 | if wip_in_title? 20 | apply_label 21 | else 22 | remove_label 23 | end 24 | end 25 | 26 | def wip_in_title? 27 | pr_title_tags.map(&:downcase).include?(LABEL) 28 | end 29 | 30 | def apply_label 31 | logger.info("Updating PR #{pr_number} with label #{LABEL.inspect}.") 32 | GithubService.add_labels_to_an_issue(fq_repo_name, pr_number, [LABEL]) 33 | end 34 | 35 | def remove_label 36 | logger.info("Updating PR #{pr_number} without label #{LABEL.inspect}.") 37 | GithubService.remove_label(fq_repo_name, pr_number, LABEL) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /db/migrate/20170127173128_fix_limit_in_schema.rb: -------------------------------------------------------------------------------- 1 | class FixLimitInSchema < ActiveRecord::Migration[4.2] 2 | def up 3 | change_column :batch_entries, :state, :string, :limit => 255 4 | change_column :batch_jobs, :on_complete_class, :string, :limit => 255 5 | change_column :batch_jobs, :state, :string, :limit => 255 6 | change_column :branches, :name, :string, :limit => 255 7 | change_column :branches, :commit_uri, :string, :limit => 255 8 | change_column :branches, :last_commit, :string, :limit => 255 9 | change_column :repos, :name, :string, :limit => 255 10 | end 11 | 12 | def down 13 | change_column :batch_entries, :state, :string, :limit => nil 14 | change_column :batch_jobs, :on_complete_class, :string, :limit => nil 15 | change_column :batch_jobs, :state, :string, :limit => nil 16 | change_column :branches, :name, :string, :limit => nil 17 | change_column :branches, :commit_uri, :string, :limit => nil 18 | change_column :branches, :last_commit, :string, :limit => nil 19 | change_column :repos, :name, :string, :limit => nil 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/lib/github_service/commands/assign_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe GithubService::Commands::Assign do 2 | subject { described_class.new(issue) } 3 | let(:issue) { double(:fq_repo_name => "foo/bar") } 4 | let(:command_issuer) { "chessbyte" } 5 | 6 | before do 7 | allow(GithubService).to receive(:valid_assignee?).with("foo/bar", "gooduser") { true } 8 | allow(GithubService).to receive(:valid_assignee?).with("foo/bar", "baduser") { false } 9 | end 10 | 11 | after do 12 | subject.execute!(:issuer => command_issuer, :value => command_value) 13 | end 14 | 15 | context "with a valid user" do 16 | let(:command_value) { "gooduser" } 17 | 18 | it "assigns to that user" do 19 | expect(issue).to receive(:assign).with("gooduser") 20 | end 21 | end 22 | 23 | context "with an invalid user" do 24 | let(:command_value) { "baduser" } 25 | 26 | it "does not assign, reports failure" do 27 | expect(issue).not_to receive(:assign) 28 | expect(issue).to receive(:add_comment).with("@#{command_issuer} 'baduser' is an invalid assignee, ignoring...") 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /.github/workflows/build_and_deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+' 6 | jobs: 7 | build_and_deploy: 8 | if: github.repository_owner == 'ManageIQ' 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v6 12 | - name: Set up Ruby 13 | uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: "3.1" 16 | bundler-cache: true 17 | - name: Docker login 18 | run: echo ${{ secrets.DOCKER_REGISTRY_PASSWORD }} | docker login docker.io --password-stdin --username ${{ secrets.DOCKER_REGISTRY_USERNAME }} 19 | - name: Build 20 | run: bundle exec rake production:release:build[${{ github.ref_name }}] 21 | - name: Set up kubectl 22 | uses: azure/setup-kubectl@v4 23 | - name: Set up ibmcloud CLI 24 | uses: IBM/actions-ibmcloud-cli@v1 25 | with: 26 | api_key: ${{ secrets.IBMCLOUD_API_KEY }} 27 | region: us-east 28 | group: manageiq 29 | plugins: container-service 30 | - name: Deploy 31 | run: bundle exec rake production:release:deploy[${{ github.ref_name }}] 32 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | puts "\n== Copying sample files ==" 21 | unless File.exist?("config/database.yml") 22 | FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket 23 | 24 | # Use rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /spec/miq_bot_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe MiqBot do 2 | describe ".version" do 3 | before { MiqBot.instance_variable_set(:@version, nil) } 4 | after { MiqBot.instance_variable_set(:@version, nil) } 5 | 6 | it "with git dir present" do 7 | expect(described_class).to receive(:`).with("GIT_DIR=#{File.expand_path("..", __dir__)}/.git git describe --tags").and_return("v0.21.2-91-g6800275\n") 8 | 9 | expect(described_class.version).to eq("v0.21.2-91-g6800275") 10 | end 11 | 12 | context "with git dir not present" do 13 | let!(:tmpdir) do 14 | Pathname.new(Dir.mktmpdir("fake_rails_root")).tap do |tmpdir| 15 | expect(Rails).to receive(:root).at_least(:once).and_return(tmpdir) 16 | end 17 | end 18 | after { FileUtils.rm_rf(tmpdir) } 19 | 20 | context "and VERSION file present" do 21 | before { tmpdir.join("VERSION").write("v0.21.2-91-g6800275\n") } 22 | 23 | it "returns the content of the VERSION file" do 24 | expect(described_class.version).to eq("v0.21.2-91-g6800275") 25 | end 26 | end 27 | 28 | context "and VERSION file not present" do 29 | it "returns nothing" do 30 | expect(described_class.version).to be_empty 31 | end 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/offense_message/entry.rb: -------------------------------------------------------------------------------- 1 | class OffenseMessage 2 | class Entry 3 | attr_reader :severity 4 | attr_reader :message 5 | attr_reader :group 6 | attr_reader :locator 7 | 8 | SEVERITY = { 9 | :error => ":bomb: :boom: :fire: :fire_engine:", 10 | :warn => ":warning:", 11 | :high => ":exclamation:", 12 | :low => ":grey_exclamation:", 13 | :unknown => ":grey_question:", 14 | }.freeze 15 | 16 | def initialize(severity, message, group = nil, locator = nil) 17 | raise ArgumentError, "severity must be one of #{SEVERITY.keys.join(", ")}" unless SEVERITY.key?(severity) 18 | raise ArgumentError, "message is required" if message.blank? 19 | @severity = severity 20 | @message = message 21 | @group = group 22 | @locator = locator 23 | end 24 | 25 | def <=>(other_line) 26 | sort_attributes <=> other_line.sort_attributes 27 | end 28 | 29 | def to_s 30 | ["- [ ] #{SEVERITY[severity]}", locator, message].compact.join(" - ") 31 | end 32 | 33 | protected 34 | 35 | def sort_attributes 36 | [group.to_s, order_severity, locator.to_s, message.to_s] 37 | end 38 | 39 | private 40 | 41 | def order_severity 42 | SEVERITY.keys.index(severity) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/git_service/repo.rb: -------------------------------------------------------------------------------- 1 | module GitService 2 | class Repo 3 | def initialize(repo) 4 | @repo = repo 5 | end 6 | 7 | def commit(sha) 8 | GitService::Commit.new(rugged_repo, sha) 9 | end 10 | 11 | def git_fetch 12 | require 'rugged' 13 | rugged_repo.remotes.each do |remote| 14 | fetch_options = {} 15 | username = uri_for_remote(remote.url).user 16 | hostname = uri_for_remote(remote.url).hostname 17 | credentials = Credentials.find_for_user_and_host(username, hostname) 18 | 19 | fetch_options[:credentials] = credentials if credentials 20 | 21 | rugged_repo.fetch(remote.name, **fetch_options) 22 | end 23 | end 24 | 25 | private 26 | 27 | def rugged_repo 28 | @rugged_repo ||= Rugged::Repository.new(@repo.path.to_s) 29 | end 30 | 31 | def uri_for_remote(url) 32 | @remote_uris ||= {} 33 | @remote_uris[url] ||= begin 34 | if url.start_with?("http", "ssh://") 35 | URI(url) 36 | elsif url.match?(/\A[-\w:.]+@.*:/) # exp: git@github.com:org/repo 37 | URI(url.sub(':', '/').prepend("ssh://")) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/message_collector.rb: -------------------------------------------------------------------------------- 1 | require 'stringio' 2 | 3 | class MessageCollector 4 | attr_accessor :max_size, :header, :continuation_header 5 | 6 | def initialize(max_size = nil, header = nil, continuation_header = nil) 7 | @max_size = max_size 8 | @header = header 9 | @continuation_header = continuation_header 10 | @lines = [] 11 | end 12 | 13 | def write(line) 14 | if max_size && line.length >= max_size 15 | raise ArgumentError, "line length must be less than #{max_size}" 16 | end 17 | @lines << line 18 | end 19 | 20 | def write_lines(lines) 21 | lines.each { |l| write(l) } 22 | end 23 | 24 | def comments 25 | build_comments 26 | @comments.collect(&:string) 27 | end 28 | 29 | private 30 | 31 | def build_comments 32 | @comments = [] 33 | start_new_comment(header) 34 | @lines.each { |line| add_to_comment(line) } 35 | end 36 | 37 | def start_new_comment(comment_header) 38 | @comment = StringIO.new 39 | @comments << @comment 40 | add_to_comment(comment_header) if comment_header 41 | end 42 | 43 | def add_to_comment(line) 44 | start_new_comment(continuation_header) if will_exceed_comment_max_size?(line) 45 | @comment.puts(line) 46 | end 47 | 48 | def will_exceed_comment_max_size?(line) 49 | max_size ? (@comment.length + line.length + 1 >= max_size) : false 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/rubocop_checker.rb: -------------------------------------------------------------------------------- 1 | require 'rugged' 2 | 3 | class CommitMonitorHandlers::CommitRange::RubocopChecker 4 | include Sidekiq::Worker 5 | sidekiq_options :queue => :miq_bot_glacial 6 | 7 | include BranchWorkerMixin 8 | include CodeAnalysisMixin 9 | 10 | def self.handled_branch_modes 11 | [:pr] 12 | end 13 | 14 | attr_reader :results 15 | 16 | def perform(branch_id, _new_commits) 17 | return unless find_branch(branch_id, :pr) 18 | 19 | process_branch 20 | end 21 | 22 | private 23 | 24 | def process_branch 25 | @results = merged_linter_results 26 | unless @results["files"].blank? 27 | diff_details = diff_details_for_merge 28 | @results = RubocopResultsFilter.new(results, diff_details).filtered 29 | end 30 | 31 | replace_rubocop_comments 32 | rescue GitService::UnmergeableError 33 | nil # Avoid working on unmergeable PRs 34 | end 35 | 36 | def rubocop_comments 37 | MessageBuilder.new(results, branch).comments 38 | end 39 | 40 | def replace_rubocop_comments 41 | logger.info("Updating PR #{pr_number} with rubocop comment.") 42 | GithubService.replace_comments(fq_repo_name, pr_number, rubocop_comments) do |old_comment| 43 | rubocop_comment?(old_comment) 44 | end 45 | end 46 | 47 | def rubocop_comment?(comment) 48 | comment.body.start_with?("") 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/github_service/commands/unassign.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class Unassign < Base 4 | private 5 | 6 | def _execute(issuer:, value:) 7 | users = value.strip.delete('@').split(/\s*,\s*/) 8 | 9 | assgined_users = list_assigned_users 10 | 11 | valid_users, invalid_users = users.partition { |u| assgined_users.include?(u) } 12 | 13 | if valid_users.any? 14 | octokit_remove_assignees(issue.fq_repo_name, issue.number, valid_users) 15 | end 16 | 17 | if invalid_users.any? 18 | message = "@#{issuer} #{"User".pluralize(invalid_users.size)} '#{invalid_users.join(", ")}' #{invalid_users.size == 1 ? "is" : "are"} not in the list of assignees, ignoring..." 19 | issue.add_comment(message) 20 | end 21 | end 22 | 23 | def list_assigned_users 24 | GithubService.issue(issue.fq_repo_name, issue.number).assignees.map(&:login) 25 | end 26 | 27 | # FIXME: NoMethodError on `remove_assignees` 28 | # https://github.com/octokit/octokit.rb/blob/master/lib/octokit/client/issues.rb#L346 29 | def octokit_remove_assignees(repo, number, assignees, options = {}) 30 | service = GithubService.instance_variable_get("@service") 31 | service.delete("repos/#{repo}/issues/#{number}/assignees", options.merge(:assignees => assignees)) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/rubocop_checker/rubocop_results_filter.rb: -------------------------------------------------------------------------------- 1 | module CommitMonitorHandlers 2 | module CommitRange 3 | class RubocopChecker 4 | class RubocopResultsFilter 5 | attr_reader :filtered 6 | 7 | def initialize(results, diff_details) 8 | @results = results 9 | @diff_details = diff_details 10 | @filtered = filter_rubocop_results 11 | end 12 | 13 | private 14 | 15 | def filter_rubocop_results 16 | filter_on_diff 17 | filter_void_warnings_in_spec_files 18 | 19 | @results["summary"]["offense_count"] = 20 | @results["files"].inject(0) { |sum, f| sum + f["offenses"].length } 21 | 22 | @results 23 | end 24 | 25 | def filter_on_diff 26 | @results["files"].each do |f| 27 | f["offenses"].select! do |o| 28 | o["severity"].in?(%w(error fatal)) || 29 | @diff_details[f["path"]].include?(o["location"]["line"]) 30 | end 31 | end 32 | end 33 | 34 | def filter_void_warnings_in_spec_files 35 | @results["files"].each do |f| 36 | next unless f["path"].match %r{(?:^|/)spec/.+_spec.rb} 37 | 38 | f["offenses"].reject! do |o| 39 | o["cop_name"].in?(%w(Void Lint/Void)) 40 | end 41 | end 42 | end 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/git_service/credentials.rb: -------------------------------------------------------------------------------- 1 | module GitService 2 | class Credentials 3 | # Example: 4 | # 5 | # GitService::Credentials.host_config = { 6 | # '*' => { 7 | # :username => 'git', 8 | # :private_key => '~/.ssh/id_rsa' 9 | # }, 10 | # 'github.com' => { 11 | # :username => 'git', 12 | # :private_key => '~/.ssh/id_rsa' 13 | # } 14 | # } 15 | # 16 | def self.host_config=(host_config = {}) 17 | @host_config = host_config.to_h 18 | end 19 | 20 | # Generic method for finding git credentials based on what is available 21 | # 22 | # Will use ssh_agent if all other options have been exhausted. 23 | # 24 | def self.find_for_user_and_host(username, hostname) 25 | from_ssh_config(username, hostname) || from_ssh_agent(username) 26 | end 27 | 28 | def self.from_ssh_config(username, hostname) 29 | ssh_config = Net::SSH::Config.for(hostname) 30 | 31 | return if ssh_config.empty? || ssh_config[:keys].nil? 32 | 33 | ssh_config[:username] = username || ssh_config[:user] # favor URL username if present 34 | ssh_config[:privatekey] = File.expand_path(ssh_config[:keys].first) # only use first key 35 | 36 | Rugged::Credentials::SshKey.new(ssh_config) 37 | end 38 | 39 | def self.from_ssh_agent(username) 40 | Rugged::Credentials::SshKeyFromAgent.new(:username => username) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/github_pr_commenter/diff_filename_checker.rb: -------------------------------------------------------------------------------- 1 | require 'rugged' 2 | 3 | module CommitMonitorHandlers::CommitRange 4 | class GithubPrCommenter::DiffFilenameChecker 5 | include Sidekiq::Worker 6 | sidekiq_options :queue => :miq_bot_glacial 7 | 8 | include BatchEntryWorkerMixin 9 | include BranchWorkerMixin 10 | 11 | def perform(batch_entry_id, branch_id, _new_commits) 12 | return unless find_batch_entry(batch_entry_id) 13 | return skip_batch_entry unless find_branch(branch_id, :pr) 14 | 15 | complete_batch_entry(:result => process_files) 16 | end 17 | 18 | private 19 | 20 | def process_files 21 | @offenses = [] 22 | 23 | check_diff_files 24 | 25 | @offenses 26 | end 27 | 28 | def check_diff_files 29 | branch.git_service.diff.new_files.each do |file| 30 | validate_migration_timestamp(file) 31 | end 32 | rescue GitService::UnmergeableError 33 | nil # Avoid working on unmergeable PRs 34 | end 35 | 36 | def validate_migration_timestamp(file) 37 | return unless file.include?("db/migrate/") 38 | ts = File.basename(file).split("_").first 39 | return if valid_timestamp?(ts) 40 | 41 | @offenses << OffenseMessage::Entry.new(:error, "Bad Migration Timestamp", file) 42 | end 43 | 44 | def valid_timestamp?(ts) 45 | Time.parse(ts) 46 | rescue ArgumentError 47 | false 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 48 | 49 | 50 | 51 | 52 |
53 |

We're sorry, but something went wrong.

54 |
55 |

If you are the application owner check the logs for more information.

56 | 57 | 58 | -------------------------------------------------------------------------------- /spec/workers/pull_request_monitor_handlers/merge_target_titler_spec.rb: -------------------------------------------------------------------------------- 1 | describe PullRequestMonitorHandlers::MergeTargetTitler do 2 | let(:branch) { create(:pr_branch) } 3 | 4 | before do 5 | stub_sidekiq_logger 6 | stub_settings(:merge_target_titler => {:enabled_repos => [branch.repo.name]}) 7 | end 8 | 9 | context "when the branch has a non-master merge target" do 10 | before do 11 | branch.update!(:merge_target => "darga") 12 | end 13 | 14 | it "and not already titled" do 15 | expect(GithubService).to receive(:update_pull_request) 16 | .with(branch.repo.name, branch.pr_number, a_hash_including(:title => "[DARGA] #{branch.pr_title}")) 17 | described_class.new.perform(branch.id) 18 | end 19 | 20 | it "and already titled" do 21 | branch.update!(:pr_title => "[DARGA] #{branch.pr_title}") 22 | 23 | expect(GithubService).to_not receive(:pull_requests) 24 | 25 | described_class.new.perform(branch.id) 26 | end 27 | 28 | it "and already titled, but not at the start" do 29 | branch.update!(:pr_title => "[WIP] [DARGA] #{branch.pr_title}") 30 | 31 | expect(GithubService).to_not receive(:pull_requests) 32 | 33 | described_class.new.perform(branch.id) 34 | end 35 | end 36 | 37 | it "when the branch has a master merge target" do 38 | branch.update!(:merge_target => "master") 39 | 40 | expect(GithubService).to_not receive(:pull_requests) 41 | 42 | described_class.new.perform(branch.id) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 48 | 49 | 50 | 51 | 52 |
53 |

The change you wanted was rejected.

54 |

Maybe you tried to change something you didn't have access to.

55 |
56 |

If you are the application owner check the logs for more information.

57 | 58 | 59 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module MiqBot 10 | class Application < Rails::Application 11 | # Initialize configuration defaults for originally generated Rails version. 12 | config.load_defaults 7.0 13 | 14 | # Configuration for the application, engines, and railties goes here. 15 | # 16 | # These settings can be overridden in specific environments using the files 17 | # in config/environments, which are processed later. 18 | # 19 | # config.time_zone = "Central Time (US & Canada)" 20 | # config.eager_load_paths << Rails.root.join("extras") 21 | 22 | config.eager_load_paths << Rails.root.join("app/workers/concerns").to_s 23 | config.eager_load_paths << Rails.root.join("lib/github_service/concerns").to_s 24 | config.eager_load_paths << Rails.root.join("lib").to_s 25 | 26 | # Use yaml_unsafe_load for column serialization to handle Symbols 27 | config.active_record.use_yaml_unsafe_load = true 28 | 29 | console do 30 | TOPLEVEL_BINDING.eval('self').extend(ConsoleMethods) 31 | end 32 | end 33 | 34 | def self.version 35 | @version ||= 36 | if Rails.root.join('.git').exist? 37 | `GIT_DIR=#{Rails.root.join('.git')} git describe --tags`.chomp 38 | elsif Rails.root.join("VERSION").exist? 39 | Rails.root.join("VERSION").read.chomp 40 | else 41 | "" 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 48 | 49 | 50 | 51 | 52 |
53 |

The page you were looking for doesn't exist.

54 |

You may have mistyped the address or the page may have moved.

55 |
56 |

If you are the application owner check the logs for more information.

57 | 58 | 59 | -------------------------------------------------------------------------------- /spec/lib/message_collector_spec.rb: -------------------------------------------------------------------------------- 1 | describe MessageCollector do 2 | let(:max_size) { 1000 } 3 | let(:header) { "header stuff\n\n" } 4 | let(:continuation_header) { "continued...\n\n" } 5 | subject { described_class.new(max_size, header, continuation_header) } 6 | 7 | describe "#write / #comments" do 8 | it "with simple line" do 9 | subject.write("a line") 10 | expect(subject.comments).to eq ["#{header}a line\n"] 11 | end 12 | 13 | it "with a line that's too long" do 14 | expect { subject.write("a" * max_size) }.to raise_error(ArgumentError) 15 | end 16 | 17 | it "with a line that will warp to a new comment" do 18 | line = "a" * (max_size - header.length) 19 | subject.write("a line") 20 | subject.write(line) 21 | expect(subject.comments).to eq [ 22 | "#{header}a line\n", 23 | "#{continuation_header}#{line}\n" 24 | ] 25 | end 26 | end 27 | 28 | describe "#write_lines / #comments" do 29 | it "with simple lines" do 30 | subject.write_lines(["a line", "another line"]) 31 | expect(subject.comments).to eq ["#{header}a line\nanother line\n"] 32 | end 33 | 34 | it "with a line that's too long" do 35 | expect { subject.write_lines(["a line", "a" * max_size]) }.to raise_error(ArgumentError) 36 | end 37 | 38 | it "with a line that will wrap to a new comment" do 39 | line = "a" * (max_size - header.length) 40 | subject.write_lines(["a line", line]) 41 | expect(subject.comments).to eq [ 42 | "#{header}a line\n", 43 | "#{continuation_header}#{line}\n" 44 | ] 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 0 * * 0' 8 | 9 | jobs: 10 | ci: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: 15 | - '3.1' 16 | services: 17 | postgres: 18 | image: manageiq/postgresql:13 19 | env: 20 | POSTGRESQL_USER: root 21 | POSTGRESQL_PASSWORD: smartvm 22 | POSTGRESQL_DATABASE: miq_bot_test 23 | options: --health-cmd pg_isready --health-interval 2s --health-timeout 5s --health-retries 5 24 | ports: 25 | - 5432:5432 26 | redis: 27 | image: redis 28 | options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5 29 | ports: 30 | - 6379:6379 31 | env: 32 | PGUSER: root 33 | PGPASSWORD: smartvm 34 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 35 | steps: 36 | - uses: actions/checkout@v6 37 | - name: Set up system 38 | run: bin/before_install 39 | - name: Set up Ruby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{ matrix.ruby-version }} 43 | bundler-cache: true 44 | timeout-minutes: 30 45 | - name: Prepare tests 46 | run: bin/setup 47 | - name: Run tests 48 | run: bundle exec rake 49 | - name: Report code coverage 50 | if: ${{ github.ref == 'refs/heads/master' && matrix.ruby-version == '3.1' }} 51 | continue-on-error: true 52 | uses: paambaati/codeclimate-action@v9 53 | - name: Container Build 54 | run: docker build . 55 | -------------------------------------------------------------------------------- /lib/github_service/commands/remove_reviewer.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class RemoveReviewer < Base 4 | private 5 | 6 | def _execute(issuer:, value:) 7 | user = value.strip.delete('@') 8 | 9 | if valid_assignee?(user) 10 | if requested_reviewers.include?(user) 11 | # FIXME: waiting for merge of https://github.com/octokit/octokit.rb/pull/990 12 | begin 13 | issue.remove_reviewer(user) 14 | rescue NoMethodError 15 | # TODO: Remove this exception handling after dependence merge. 16 | octokit_request_pull_request_review(issue.fq_repo_name, issue.number, "reviewers" => [user]) 17 | end 18 | else 19 | issue.add_comment("@#{issuer} '#{user}' is not in the list of requested reviewers, ignoring...") 20 | end 21 | else 22 | issue.add_comment("@#{issuer} '#{user}' is an invalid reviewer, ignoring...") 23 | end 24 | end 25 | 26 | # returns an array of user logins who were requested for a pull request review 27 | def requested_reviewers 28 | GithubService.pull_request_review_requests(issue.fq_repo_name, issue.number).users.map(&:login) 29 | end 30 | 31 | # TODO: Remove this. 32 | def octokit_request_pull_request_review(repo, id, reviewers, options = {}) 33 | service = GithubService.instance_variable_get("@service") 34 | options = options.merge(:reviewers => reviewers.values.flatten) 35 | service.delete("repos/#{repo}/pulls/#{id}/requested_reviewers", options) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/support/rubocop_spec_helper.rb: -------------------------------------------------------------------------------- 1 | def rubocop_results 2 | # To regenerate the results.json files, just delete them 3 | unless File.exist?(rubocop_json_file) 4 | rubocop = JSON.parse(`rubocop --config #{rubocop_check_config} --format=json --no-display-cop-names #{rubocop_check_path}`) 5 | hamllint = JSON.parse(`haml-lint --reporter=json #{rubocop_check_path}`) 6 | 7 | %w(offense_count target_file_count inspected_file_count).each do |m| 8 | rubocop['summary'][m] += hamllint['summary'][m] 9 | end 10 | rubocop['files'] += hamllint['files'] 11 | File.write(rubocop_json_file, JSON.pretty_generate(rubocop)) 12 | end 13 | JSON.parse(File.read(rubocop_json_file)) 14 | end 15 | 16 | def rubocop_check_config 17 | rubocop_check_path.join("..", ".rubocop.yml").expand_path 18 | end 19 | 20 | def rubocop_json_file 21 | rubocop_check_path.join("results.json") 22 | end 23 | 24 | def rubocop_check_path 25 | Pathname.new(@example.file_path).expand_path.dirname.join("data", rubocop_check_directory).relative_path_from(Rails.root) 26 | end 27 | 28 | def rubocop_check_path_file(file) 29 | rubocop_check_path.join(file) 30 | end 31 | 32 | def rubocop_check_directory 33 | @example.description.gsub(" ", "_") 34 | end 35 | 36 | def rubocop_version 37 | RuboCop::Version.version 38 | end 39 | 40 | def rubocop_version_short 41 | RuboCop::Version.document_version 42 | end 43 | 44 | def rubocop_rails_version_short 45 | RuboCop::Rails::Version.document_version 46 | end 47 | 48 | def hamllint_version 49 | HamlLint::VERSION 50 | end 51 | 52 | def yamllint_version 53 | _out, err, _ps = Open3.capture3("yamllint -v") 54 | err.split.last 55 | end 56 | -------------------------------------------------------------------------------- /spec/workers/concerns/batch_job_worker_mixin_spec.rb: -------------------------------------------------------------------------------- 1 | describe BatchJobWorkerMixin do 2 | let(:includer_class) do 3 | Class.new do 4 | include BatchJobWorkerMixin 5 | 6 | def self.batch_workers 7 | @batch_workers ||= [Class.new, Class.new] 8 | end 9 | 10 | def logger 11 | @logger ||= RSpec::Mocks::Double.new("logger") 12 | end 13 | end 14 | end 15 | 16 | subject { includer_class.new } 17 | let(:job) { BatchJob.create! } 18 | 19 | it ".perform_batch_async" do 20 | expect(BatchJob).to receive(:perform_async) do |workers, worker_args, job_attributes| 21 | expect(workers).to eq(includer_class.batch_workers) 22 | expect(worker_args).to eq(%w(arg1 arg2)) 23 | 24 | expect(job_attributes[:on_complete_class]).to eq(includer_class) 25 | expect(job_attributes[:on_complete_args]).to eq(%w(arg1 arg2)) 26 | expect(job_attributes[:expires_at]).to be_kind_of(Time) 27 | end 28 | 29 | includer_class.perform_batch_async("arg1", "arg2") 30 | end 31 | 32 | describe "#find_batch_job" do 33 | it "with an existing job" do 34 | expect(subject.find_batch_job(job.id)).to be true 35 | end 36 | 37 | it "with a missing job" do 38 | expect(subject.logger).to receive(:warn) do |message| 39 | expect(message).to match(/no longer exists/) 40 | end 41 | expect(subject.find_batch_job(-1)).to be false 42 | end 43 | end 44 | 45 | it "#complete_batch_job" do 46 | subject.find_batch_job(job.id) 47 | 48 | subject.complete_batch_job 49 | 50 | expect { job.reload }.to raise_error(ActiveRecord::RecordNotFound) 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /app/workers/concerns/branch_worker_mixin.rb: -------------------------------------------------------------------------------- 1 | module BranchWorkerMixin 2 | extend ActiveSupport::Concern 3 | include SidekiqWorkerMixin 4 | 5 | attr_accessor :branch 6 | 7 | delegate :repo, 8 | :fq_repo_name, 9 | :fq_branch_name, 10 | :git_service, 11 | :pr_number, 12 | :pr_title, 13 | :pr_title_tags, 14 | :merge_target, 15 | :to => :branch 16 | 17 | def find_branch(branch_id, required_mode = nil) 18 | @branch ||= Branch.where(:id => branch_id).first 19 | 20 | if branch.nil? 21 | logger.warn("Branch #{branch_id} no longer exists. Skipping.") 22 | return false 23 | end 24 | 25 | if required_mode && branch.mode != required_mode 26 | logger.error("Branch #{fq_branch_name} is not a #{required_mode} branch. Skipping.") 27 | return false 28 | end 29 | 30 | unless enabled_for?(repo) 31 | logger.error("Branch #{fq_branch_name} is not enabled. Skipping.") 32 | return false 33 | end 34 | 35 | true 36 | end 37 | 38 | def commits 39 | branch.commits_list 40 | end 41 | 42 | def commit_range 43 | [commits.first, commits.last] 44 | end 45 | 46 | def commit_range_text 47 | case commit_range.uniq.length 48 | when 1 then branch.commit_uri_to(commit_range.first) 49 | when 2 then branch.compare_uri_for(*commit_range) 50 | end 51 | end 52 | 53 | def diff_details_for_merge 54 | repo.with_git_service do |git| 55 | git.diff_details(branch.local_merge_target, commits.last) 56 | end 57 | end 58 | 59 | def diff_file_names 60 | @diff_file_names ||= git_service.diff.new_files 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/git_service/diff.rb: -------------------------------------------------------------------------------- 1 | module GitService 2 | class Diff 3 | attr_reader :raw_diff 4 | def initialize(raw_diff) 5 | @raw_diff = raw_diff 6 | end 7 | 8 | def new_files 9 | raw_diff.deltas.collect { |delta| delta.try(:new_file).try(:[], :path) }.compact 10 | end 11 | 12 | def with_each_patch 13 | raw_diff.patches.each { |patch| yield(patch) } 14 | end 15 | 16 | def with_each_hunk 17 | with_each_patch do |patch| 18 | patch.hunks.each { |hunk| yield(hunk, patch) } 19 | end 20 | end 21 | 22 | def with_each_line 23 | with_each_hunk do |hunk, parent_patch| 24 | hunk.lines.each { |line| yield(line, hunk, parent_patch) } 25 | end 26 | end 27 | 28 | def file_status 29 | raw_diff.patches.each_with_object({}) do |patch, h| 30 | if new_file = patch.delta.new_file.try(:[], :path) 31 | additions = h.fetch_path(new_file, :additions) || 0 32 | h.store_path(new_file, :additions, (additions + patch.additions)) 33 | end 34 | if old_file = patch.delta.old_file.try(:[], :path) 35 | deletions = h.fetch_path(old_file, :deletions) || 0 36 | h.store_path(new_file, :deletions, (deletions + patch.deletions)) 37 | end 38 | end 39 | end 40 | 41 | def status_summary 42 | changed, added, deleted = raw_diff.stat 43 | [ 44 | changed.positive? ? "#{changed} #{"file".pluralize(changed)} changed" : nil, 45 | added.positive? ? "#{added} #{"insertion".pluralize(added)}(+)" : nil, 46 | deleted.positive? ? "#{deleted} #{"deletion".pluralize(deleted)}(-)" : nil 47 | ].compact.join(", ") 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /app/models/batch_job.rb: -------------------------------------------------------------------------------- 1 | class BatchJob < ActiveRecord::Base 2 | has_many :entries, :class_name => "BatchEntry", :dependent => :destroy, :inverse_of => :job 3 | 4 | serialize :on_complete_args, Array 5 | 6 | validates :state, :inclusion => {:in => %w(finalizing), :allow_nil => true} 7 | 8 | SEMAPHORE = Mutex.new 9 | 10 | def self.perform_async(workers, worker_args, job_attributes) 11 | new_entries = workers.size.times.collect { BatchEntry.new } 12 | create!(job_attributes.merge(:entries => new_entries)) 13 | 14 | workers.zip(new_entries).each do |w, e| 15 | w.perform_async(e.id, *worker_args) 16 | end 17 | end 18 | 19 | def on_complete_class 20 | super.try(:constantize) 21 | end 22 | 23 | def on_complete_class=(klass) 24 | super(klass.to_s) 25 | end 26 | 27 | def finalizing? 28 | state == "finalizing" 29 | end 30 | 31 | def expired? 32 | expires_at && Time.now > expires_at 33 | end 34 | 35 | def entries_complete? 36 | entries.all?(&:complete?) && entries.any? 37 | end 38 | 39 | def check_complete 40 | # NOTE: The mutex may need to be upgraded to a database row lock 41 | # if we go multi-process 42 | SEMAPHORE.synchronize do 43 | begin 44 | reload 45 | rescue ActiveRecord::RecordNotFound 46 | return 47 | end 48 | 49 | return if finalizing? 50 | return unless expired? || entries_complete? 51 | finalize! 52 | end 53 | end 54 | 55 | private 56 | 57 | def finalize! 58 | if on_complete_class 59 | update!(:state => "finalizing") 60 | on_complete_class.perform_async(id, *on_complete_args) 61 | else 62 | destroy 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/lib/offense_message_spec.rb: -------------------------------------------------------------------------------- 1 | describe OffenseMessage do 2 | describe "#lines" do 3 | it "with full entries" do 4 | message = described_class.new 5 | message.entries = [ 6 | OffenseMessage::Entry.new(:high, "Message 4", "Group B", "Locator B"), 7 | OffenseMessage::Entry.new(:high, "Message 3", "Group B", "Locator A"), 8 | OffenseMessage::Entry.new(:high, "Message 2", "Group A", "Locator B"), 9 | OffenseMessage::Entry.new(:high, "Message 1", "Group A", "Locator A"), 10 | ] 11 | expect(message.lines).to eq([ 12 | "**Group A**", 13 | "- [ ] :exclamation: - Locator A - Message 1", 14 | "- [ ] :exclamation: - Locator B - Message 2", 15 | "", 16 | "**Group B**", 17 | "- [ ] :exclamation: - Locator A - Message 3", 18 | "- [ ] :exclamation: - Locator B - Message 4" 19 | ]) 20 | end 21 | 22 | it "with entries - with and without groups" do 23 | message = described_class.new 24 | message.entries = [ 25 | OffenseMessage::Entry.new(:high, "Message 4", "Group B", "Locator B"), 26 | OffenseMessage::Entry.new(:high, "Message 3", "Group B", "Locator A"), 27 | OffenseMessage::Entry.new(:high, "Message 2", nil, "Locator B"), 28 | OffenseMessage::Entry.new(:high, "Message 1", nil, "Locator A"), 29 | ] 30 | expect(message.lines).to eq([ 31 | "- [ ] :exclamation: - Locator A - Message 1", 32 | "- [ ] :exclamation: - Locator B - Message 2", 33 | "", 34 | "**Group B**", 35 | "- [ ] :exclamation: - Locator A - Message 3", 36 | "- [ ] :exclamation: - Locator B - Message 4" 37 | ]) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /app/workers/pull_request_monitor.rb: -------------------------------------------------------------------------------- 1 | class PullRequestMonitor 2 | include Sidekiq::Worker 3 | sidekiq_options :queue => :miq_bot_glacial, :retry => false 4 | 5 | include SidekiqWorkerMixin 6 | 7 | def self.enabled_repos 8 | super.includes(:branches).select(&:can_have_prs?) 9 | end 10 | 11 | def self.handlers 12 | @handlers ||= begin 13 | workers_path = Rails.root.join("app/workers") 14 | Dir.glob(workers_path.join("pull_request_monitor_handlers/*.rb")).collect do |f| 15 | path = Pathname.new(f).relative_path_from(workers_path).to_s 16 | path.chomp(".rb").classify.constantize 17 | end 18 | end 19 | end 20 | 21 | def self.handlers_for(repo) 22 | handlers.select { |h| h.enabled_for?(repo) } 23 | end 24 | 25 | def perform 26 | if !first_unique_worker? 27 | logger.info "#{self.class} is already running, skipping" 28 | else 29 | process_repos 30 | end 31 | end 32 | 33 | def process_repos 34 | enabled_repos.each { |repo| process_repo(repo) } 35 | end 36 | 37 | def process_repo(repo) 38 | return unless repo.can_have_prs? 39 | 40 | results = repo.synchronize_pr_branches(github_prs(repo)) 41 | 42 | branches = results[:updated] + results[:added] 43 | branches.product(self.class.handlers_for(repo)) { |b, h| h.perform_async(b.id) } 44 | end 45 | 46 | private 47 | 48 | def github_prs(repo) 49 | GithubService.pull_requests(repo.name).map do |github_pr| 50 | { 51 | :number => github_pr.number, 52 | :html_url => github_pr.head.repo.try(:html_url) || github_pr.base.repo.html_url, 53 | :merge_target => github_pr.base.ref, 54 | :pr_title => github_pr.title 55 | } 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/linter/yaml.rb: -------------------------------------------------------------------------------- 1 | module Linter 2 | class Yaml < Base 3 | private 4 | 5 | def parse_output(output) 6 | lines = output.chomp.split("\n") 7 | parsed = lines.collect { |line| line_to_hash(line) } 8 | grouped = parsed.group_by { |hash| hash["filename"] } 9 | file_count = parsed.collect { |hash| hash["filename"] }.uniq.count 10 | { 11 | "files" => grouped.collect do |filename, offenses| 12 | { 13 | "path" => filename.sub(%r{\A\./}, ""), 14 | "offenses" => offenses.collect { |offense_hash| offense_hash.except("filename") } 15 | } 16 | end, 17 | "summary" => { 18 | "offense_count" => lines.size, 19 | "target_file_count" => file_count, 20 | "inspected_file_count" => file_count 21 | } 22 | } 23 | end 24 | 25 | def linter_executable 26 | "yamllint" 27 | end 28 | 29 | def config_files 30 | [".yamllint"] 31 | end 32 | 33 | def options 34 | {:f => "parsable", nil => ["."]} 35 | end 36 | 37 | def filtered_files(files) 38 | files.select { |f| f.end_with?(".yml", ".yaml") } 39 | end 40 | 41 | def line_to_hash(line) 42 | filename, line, column, severity_message_cop = line.split(":", 4) 43 | severity_message, cop = severity_message_cop.split(/ \((.*)\)\Z/) 44 | severity, message = severity_message.match(/\[(.*)\] (.*)/).captures 45 | 46 | { 47 | "filename" => filename, 48 | "severity" => severity, 49 | "message" => message, 50 | "cop_name" => cop, 51 | "location" => { 52 | "line" => line.to_i, 53 | "column" => column.to_i 54 | } 55 | } 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /spec/lib/github_service/commands/add_reviewer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe GithubService::Commands::AddReviewer do 2 | subject { described_class.new(issue) } 3 | let(:issue) { double(:fq_repo_name => "org/repo") } 4 | let(:command_issuer) { "nickname" } 5 | 6 | before do 7 | allow(GithubService).to receive(:valid_assignee?).with("org/repo", "good_user") { true } 8 | allow(GithubService).to receive(:valid_assignee?).with("org/repo", "bad_user") { false } 9 | end 10 | 11 | after do 12 | subject.execute!(:issuer => command_issuer, :value => command_value) 13 | end 14 | 15 | context "with a valid user" do 16 | let(:command_value) { "good_user" } 17 | 18 | it "review request that user" do 19 | expect(issue).to receive(:add_reviewer).with(["good_user"]) 20 | end 21 | end 22 | 23 | context "with a valid users" do 24 | let(:command_value) { "good_user, good_user" } 25 | 26 | it "review request that user" do 27 | expect(issue).to receive(:add_reviewer).with(%w(good_user good_user)) 28 | end 29 | end 30 | 31 | context "with an invalid user" do 32 | let(:command_value) { "bad_user" } 33 | 34 | it "does not review request, reports failure" do 35 | expect(issue).not_to receive(:add_reviewer) 36 | expect(issue).to receive(:add_comment).with("@#{command_issuer} Cannot add the following reviewer because they are not recognized: bad_user") 37 | end 38 | end 39 | 40 | context "with an invalid users" do 41 | let(:command_value) { "bad_user, bad_user" } 42 | 43 | it "does not review request, reports failure" do 44 | expect(issue).not_to receive(:add_reviewer) 45 | expect(issue).to receive(:add_comment).with("@#{command_issuer} Cannot add the following reviewers because they are not recognized: bad_user, bad_user") 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/lib/github_service/commands/remove_reviewer_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe GithubService::Commands::RemoveReviewer do 2 | subject { described_class.new(issue) } 3 | let(:issue) { double(:fq_repo_name => "org/repo") } 4 | let(:command_issuer) { "nickname" } 5 | 6 | before do 7 | allow(GithubService).to receive(:valid_assignee?).with("org/repo", "listed_user").and_return(true) 8 | allow(GithubService).to receive(:valid_assignee?).with("org/repo", "good_user").and_return(true) 9 | allow(GithubService).to receive(:valid_assignee?).with("org/repo", "bad_user").and_return(false) 10 | 11 | allow(subject).to receive(:requested_reviewers).and_return(["listed_user"]) 12 | end 13 | 14 | after do 15 | subject.execute!(:issuer => command_issuer, :value => command_value) 16 | end 17 | 18 | context "with a valid user who is actually requested for a review" do 19 | let(:command_value) { "listed_user" } 20 | 21 | it "remove review request from that user" do 22 | expect(issue).to receive(:remove_reviewer).with("listed_user") 23 | end 24 | end 25 | 26 | context "with a valid user who is not actually requested for a review" do 27 | let(:command_value) { "good_user" } 28 | 29 | it "does not remove review request who is not actually requested" do 30 | expect(issue).not_to receive(:remove_reviewer) 31 | expect(issue).to receive(:add_comment).with("@#{command_issuer} 'good_user' is not in the list of requested reviewers, ignoring...") 32 | end 33 | end 34 | 35 | context "with an invalid user" do 36 | let(:command_value) { "bad_user" } 37 | 38 | it "does not remove review request, reports failure" do 39 | expect(issue).not_to receive(:remove_reviewer) 40 | expect(issue).to receive(:add_comment).with("@#{command_issuer} 'bad_user' is an invalid reviewer, ignoring...") 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # The Ruby version should match the lowest acceptable version of the application 4 | ruby "~> 3.1.4" 5 | 6 | plugin 'bundler-inject' 7 | require File.join(Bundler::Plugin.index.load_paths("bundler-inject")[0], "bundler-inject") rescue nil 8 | 9 | gem 'rails', '~> 7.0.8', '>= 7.0.8.7' 10 | gem 'concurrent-ruby', '< 1.3.5' # Temporary pin down as concurrent-ruby 1.3.5 breaks Rails 7.0, and rails-core doesn't 11 | # plan to ship a new 7.0 to fix it. See https://github.com/rails/rails/pull/54264 12 | 13 | # Use PostgreSQL as the database for Active Record 14 | gem 'pg' 15 | 16 | # Use SCSS for stylesheets 17 | gem 'sass-rails', '~> 5.1.0' 18 | 19 | # Use jquery as the JavaScript library 20 | gem 'jquery-rails' 21 | 22 | # Turbolinks makes following links in your web application faster. Read more: https://github.com/rails/turbolinks 23 | gem 'turbolinks' 24 | 25 | gem 'foreman' 26 | gem 'puma' 27 | 28 | gem 'config' 29 | gem 'listen' 30 | 31 | # Sidekiq specific gems 32 | gem 'sidecloq' 33 | gem 'sidekiq' 34 | 35 | # Services gems 36 | gem 'minigit', '~> 0.0.4' 37 | gem 'net-ssh', '~> 7.3.0' 38 | 39 | gem 'awesome_spawn', '~> 1.6' 40 | gem 'default_value_for', '~> 4.0' 41 | gem 'haml_lint', '~> 0.51', :require => false 42 | gem 'irb' 43 | gem 'manageiq-style', '~> 1.5', ">=1.5.6", :require => false 44 | gem 'more_core_extensions', '~> 4.4', :require => 'more_core_extensions/all' 45 | gem 'rugged', :require => false 46 | 47 | gem 'faraday' 48 | gem 'faraday-http-cache', '~> 2.5.0' 49 | gem 'octokit', '~> 4.25.0', :require => false 50 | 51 | group :development, :test do 52 | gem 'rspec' 53 | gem 'rspec-rails' 54 | gem 'simplecov', '>= 0.21.2' 55 | gem 'timecop' 56 | end 57 | 58 | group :test do 59 | gem 'factory_bot_rails' 60 | gem 'webmock' 61 | end 62 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/github_pr_commenter/diff_content_checker.rb: -------------------------------------------------------------------------------- 1 | require 'rugged' 2 | 3 | module CommitMonitorHandlers::CommitRange 4 | class GithubPrCommenter::DiffContentChecker 5 | include Sidekiq::Worker 6 | sidekiq_options :queue => :miq_bot 7 | 8 | include BatchEntryWorkerMixin 9 | include BranchWorkerMixin 10 | 11 | def perform(batch_entry_id, branch_id, _new_commits) 12 | return unless find_batch_entry(batch_entry_id) 13 | return skip_batch_entry unless find_branch(branch_id, :pr) 14 | 15 | complete_batch_entry(:result => process_lines) 16 | end 17 | 18 | private 19 | 20 | def process_lines 21 | @offenses = [] 22 | 23 | check_diff_lines 24 | 25 | @offenses 26 | end 27 | 28 | def check_diff_lines 29 | branch.git_service.diff.with_each_line do |line, _parent_hunk, parent_patch| 30 | next unless line.addition? 31 | check_line(line, parent_patch) 32 | end 33 | rescue GitService::UnmergeableError 34 | nil # Avoid working on unmergeable PRs 35 | end 36 | 37 | def check_line(line, patch) 38 | file_path = patch.delta.new_file[:path] 39 | Settings.diff_content_checker.offenses.each do |offender, options| 40 | next if options.except.try(:any?) { |except| file_path.start_with?(except) } 41 | 42 | regexp = options.type == :regexp ? Regexp.new(offender.to_s) : /\b#{Regexp.escape(offender.to_s)}\b/i 43 | add_offense(offender, options, file_path, line) if regexp.match(line.content) 44 | end 45 | end 46 | 47 | def add_offense(offender, options, file_path, line) 48 | line_number = line.new_lineno 49 | message = options.message || "Detected `#{offender}`" 50 | 51 | @offenses << OffenseMessage::Entry.new(options.severity, message, file_path, line_number) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/github_service/commands/move_issue.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class MoveIssue < Base 4 | restrict_to :organization 5 | 6 | private 7 | 8 | def _execute(issuer:, value:) 9 | @dest_organization_name, @dest_repo_name = value.split("/", 2).unshift(issue.organization_name).last(2) 10 | @dest_fq_repo_name = "#{@dest_organization_name}/#{@dest_repo_name}" 11 | 12 | validate 13 | 14 | if errors.present? 15 | issue.add_comment("@#{issuer} :x: `move_issue` failed. #{errors.to_sentence.capitalize}.") 16 | else 17 | new_issue = GithubService.create_issue(@dest_fq_repo_name, issue.title, new_issue_body) 18 | issue.add_comment("This issue has been moved to #{new_issue.html_url}") 19 | GithubService.close_issue(issue.fq_repo_name, issue.number) 20 | end 21 | end 22 | 23 | def errors 24 | @errors ||= [] 25 | end 26 | 27 | def validate 28 | if issue.repo_name.downcase == @dest_repo_name.downcase 29 | errors << "issue already exists on the '#{issue.repo_name}' repository" 30 | end 31 | if issue.pull_request? 32 | errors << "a pull request cannot be moved" 33 | end 34 | if issue.organization_name.downcase != @dest_organization_name.downcase 35 | errors << "cannot move issue to repository outside of the #{issue.organization_name} organization" 36 | end 37 | unless GithubService.repository?(@dest_fq_repo_name) 38 | errors << "repository does not exist or is unreachable" 39 | end 40 | end 41 | 42 | def new_issue_body 43 | <<-EOS 44 | #{issue.body} 45 | 46 | --- 47 | 48 | *This issue was moved to this repository from #{issue.html_url}, originally opened by @#{issue.author}* 49 | EOS 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/workers/concerns/batch_entry_worker_mixin_spec.rb: -------------------------------------------------------------------------------- 1 | describe BatchEntryWorkerMixin do 2 | subject do 3 | Class.new do 4 | include BatchEntryWorkerMixin 5 | 6 | def logger 7 | @logger ||= RSpec::Mocks::Double.new("logger") 8 | end 9 | end.new 10 | end 11 | let!(:job) { BatchJob.create!(:entries => [entry]) } 12 | let(:entry) { BatchEntry.new } 13 | 14 | describe "#find_batch_entry" do 15 | it "with an existing entry" do 16 | expect(subject.find_batch_entry(entry.id)).to be true 17 | end 18 | 19 | it "with a missing entry" do 20 | expect(subject.logger).to receive(:warn) do |message| 21 | expect(message).to match(/no longer exists/) 22 | end 23 | expect(subject.find_batch_entry(-1)).to be false 24 | end 25 | end 26 | 27 | it "#batch_job" do 28 | subject.find_batch_entry(entry.id) 29 | 30 | expect(subject.batch_job).to eq(job) 31 | end 32 | 33 | describe "#complete_batch_entry" do 34 | before do 35 | subject.find_batch_entry(entry.id) 36 | expect(subject.batch_entry).to receive(:check_job_complete) 37 | end 38 | 39 | it "with no changes" do 40 | subject.complete_batch_entry 41 | 42 | expect(subject.batch_entry.state).to eq("succeeded") 43 | expect(subject.batch_entry.result).to be_nil 44 | end 45 | 46 | it "with changes" do 47 | subject.complete_batch_entry(:result => ["something"]) 48 | 49 | expect(subject.batch_entry.state).to eq("succeeded") 50 | expect(subject.batch_entry.result).to eq(["something"]) 51 | end 52 | 53 | it "with changes to " do 54 | subject.complete_batch_entry(:state => "failed", :result => ["failure 1", "failure 2"]) 55 | 56 | expect(subject.batch_entry.state).to eq("failed") 57 | expect(subject.batch_entry.result).to eq(["failure 1", "failure 2"]) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT") { 3000 } 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/github_pr_commenter.rb: -------------------------------------------------------------------------------- 1 | module CommitMonitorHandlers::CommitRange 2 | class GithubPrCommenter 3 | include Sidekiq::Worker 4 | sidekiq_options :queue => :miq_bot_glacial 5 | 6 | include BatchJobWorkerMixin 7 | include BranchWorkerMixin 8 | 9 | def self.batch_workers 10 | [DiffContentChecker, DiffFilenameChecker, CommitMetadataChecker] 11 | end 12 | 13 | def self.handled_branch_modes 14 | [:pr] 15 | end 16 | 17 | def perform(batch_job_id, branch_id, _new_commits) 18 | return unless find_batch_job(batch_job_id) 19 | return skip_batch_job unless find_branch(branch_id, :pr) 20 | 21 | replace_batch_comments 22 | complete_batch_job 23 | end 24 | 25 | private 26 | 27 | def tag 28 | "" 29 | end 30 | 31 | def header 32 | "#{tag}Some comments on #{"commit".pluralize(commits.length)} #{commit_range_text}\n" 33 | end 34 | 35 | def continuation_header 36 | "#{tag}**...continued**\n" 37 | end 38 | 39 | def replace_batch_comments 40 | logger.info("Adding batch comment to PR #{pr_number}.") 41 | 42 | GithubService.replace_comments(fq_repo_name, pr_number, new_comments) do |old_comment| 43 | batch_comment?(old_comment) 44 | end 45 | end 46 | 47 | def batch_comment?(comment) 48 | comment.body.start_with?(tag) 49 | end 50 | 51 | def new_comments 52 | return [] unless merged_results.any? 53 | 54 | content = OffenseMessage.new 55 | content.entries = merged_results 56 | 57 | message_builder = GithubService::MessageBuilder.new(header, continuation_header) 58 | message_builder.write("") 59 | message_builder.write_lines(content.lines) 60 | message_builder.comments 61 | end 62 | 63 | def merged_results 64 | @merged_results ||= batch_job.entries.collect(&:result).flatten.compact 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /spec/models/batch_entry_spec.rb: -------------------------------------------------------------------------------- 1 | describe BatchEntry do 2 | include_examples "state predicates", :succeeded?, 3 | nil => false, 4 | "started" => false, 5 | "failed" => false, 6 | "skipped" => false, 7 | "succeeded" => true 8 | 9 | include_examples "state predicates", :failed?, 10 | nil => false, 11 | "started" => false, 12 | "failed" => true, 13 | "skipped" => false, 14 | "succeeded" => false 15 | 16 | include_examples "state predicates", :skipped?, 17 | nil => false, 18 | "started" => false, 19 | "failed" => false, 20 | "skipped" => true, 21 | "succeeded" => false 22 | 23 | include_examples "state predicates", :complete?, 24 | nil => false, 25 | "started" => false, 26 | "failed" => true, 27 | "skipped" => true, 28 | "succeeded" => true 29 | 30 | describe "#check_job_complete" do 31 | let(:job) { BatchJob.create! } 32 | let(:entry) { described_class.create!(:job => job) } 33 | 34 | context "when complete" do 35 | before do 36 | entry.update(:state => "succeeded") 37 | end 38 | 39 | it "with job still available" do 40 | expect(job).to receive(:check_complete) 41 | 42 | entry.check_job_complete 43 | end 44 | 45 | it "when job destroyed externally" do 46 | entry.reload # To remove job caching 47 | job.destroy 48 | 49 | expect { entry.check_job_complete }.to_not raise_error 50 | end 51 | end 52 | 53 | it "when not complete" do 54 | expect(job).to_not receive(:check_complete) 55 | 56 | entry.check_job_complete 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/github_service/command_dispatcher.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | class CommandDispatcher 3 | class << self 4 | def registry 5 | @registry ||= Hash.new do |h, k| 6 | normalized = k.to_s.tr("-", "_") # Support - or _ in command 7 | normalized.chop! if normalized.end_with?("s") # Support singular or plural 8 | h[normalized] if h.key?(normalized) 9 | end 10 | end 11 | 12 | def register_command(command_name, command_class) 13 | registry[command_name.to_s] = command_class 14 | end 15 | end 16 | 17 | attr_reader :issue 18 | 19 | def initialize(issue) 20 | @issue = issue.kind_of?(GithubService::Issue) ? issue : GithubService::Issue.new(issue) 21 | @fq_repo_name = @issue.fq_repo_name 22 | end 23 | 24 | def dispatch!(issuer:, text:) 25 | lines = text.split("\n") 26 | lines.each do |line| 27 | match = command_regex.match(line.strip) 28 | next unless match 29 | next if issuer == bot_name 30 | 31 | command = match[:command] 32 | command_value = match[:command_value] 33 | command_class = self.class.registry[command] 34 | 35 | if command_class.present? 36 | Rails.logger.info("Dispatching '#{command}' to #{command_class} on issue ##{issue.number} | issuer: #{issuer}, value: #{command_value}") 37 | command_class.new(issue).execute!(:issuer => issuer, :value => command_value) 38 | else 39 | message = <<-EOMSG 40 | @#{issuer} unrecognized command '#{command}', ignoring... 41 | 42 | Accepted commands are: #{self.class.registry.keys.join(", ")} 43 | EOMSG 44 | issue.add_comment(message) 45 | end 46 | end 47 | end 48 | 49 | private 50 | 51 | def command_regex 52 | /\A@#{bot_name}\s+(?[a-z_-]+)(?:\s*)(?.*)\z/i 53 | end 54 | 55 | def bot_name 56 | GithubService.bot_name 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/rubocop_results_filter_spec.rb: -------------------------------------------------------------------------------- 1 | describe CommitMonitorHandlers::CommitRange::RubocopChecker::RubocopResultsFilter do 2 | describe "#filtered" do 3 | subject { described_class.new(rubocop_results, @diff_details) } 4 | 5 | before { |example| @example = example } 6 | 7 | it "with lines not in the diff" do 8 | @diff_details = { 9 | rubocop_check_path_file("example.rb").to_s => [4] 10 | } 11 | 12 | filtered = subject.filtered 13 | 14 | expect(filtered["files"].length).to eq(1) 15 | expect(filtered["files"][0]["offenses"].length).to eq(1) 16 | expect(filtered["files"][0]["offenses"][0]["location"]["line"]).to eq(4) 17 | 18 | expect(filtered["summary"]["offense_count"]).to eq(1) 19 | end 20 | 21 | it "with void warnings in spec files" do 22 | @diff_details = { 23 | rubocop_check_path_file("non_spec_file_with_void_warning.rb").to_s => [2], 24 | rubocop_check_path_file("spec/non_spec_file_in_spec_dir_with_void_warning.rb").to_s => [2], 25 | rubocop_check_path_file("spec/spec_file_with_void_warning_spec.rb").to_s => [3] 26 | } 27 | 28 | filtered = subject.filtered 29 | 30 | expect(filtered["files"].length).to eq(3) 31 | expect(filtered["summary"]["offense_count"]).to eq(2) 32 | 33 | spec_file = filtered["files"].detect do |f| 34 | f["path"].include?("spec_file_with_void_warning_spec.rb") 35 | end 36 | expect(spec_file["offenses"]).to be_empty 37 | end 38 | 39 | it "with haml file using haml-lint" do 40 | @diff_details = { 41 | rubocop_check_path_file("example.haml").to_s => [4] 42 | } 43 | 44 | filtered = subject.filtered 45 | 46 | expect(filtered["files"].length).to eq(1) 47 | expect(filtered["files"][0]["offenses"].length).to eq(1) 48 | expect(filtered["files"][0]["offenses"][0]["location"]["line"]).to eq(3) 49 | 50 | expect(filtered["summary"]["offense_count"]).to eq(1) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/github_service/commands/remove_label.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class RemoveLabel < Base 4 | include IsTeamMember 5 | 6 | alias_as 'rm_label' 7 | 8 | private 9 | 10 | def _execute(issuer:, value:) 11 | unremovable = [] 12 | valid, invalid = extract_label_names(value) 13 | process_extracted_labels(issuer, valid, invalid, unremovable) 14 | 15 | if invalid.any? 16 | message = "@#{issuer} Cannot remove the following label#{"s" if invalid.length > 1} because they are not recognized: " 17 | message << invalid.join(", ") 18 | issue.add_comment(message) 19 | end 20 | 21 | if unremovable.any? 22 | labels = "label#{"s" if unremovable.length > 1}" 23 | triage_perms = "[triage team permissions](https://github.com/orgs/ManageIQ/teams/core-triage)" 24 | message = "@#{issuer} Cannot remove the following #{labels} since they require #{triage_perms}: " 25 | message << unremovable.join(", ") 26 | issue.add_comment(message) 27 | end 28 | 29 | valid.each do |l| 30 | issue.remove_label(l) if issue.applied_label?(l) 31 | end 32 | end 33 | 34 | def extract_label_names(value) 35 | label_names = value.split(",").map { |label| label.strip.downcase } 36 | validate_labels(label_names) 37 | end 38 | 39 | def process_extracted_labels(issuer, valid_labels, _invalid_labels, unremovable) 40 | unless triage_member?(issuer) 41 | valid_labels.each { |label| unremovable << label if Settings.labels.unremovable.include?(label) } 42 | unremovable.each { |label| valid_labels.delete(label) } 43 | end 44 | end 45 | 46 | def validate_labels(label_names) 47 | # First reload the cache if there are any invalid labels 48 | GithubService.refresh_labels(issue.fq_repo_name) unless label_names.all? { |l| GithubService.valid_label?(issue.fq_repo_name, l) } 49 | 50 | # Then see if any are *still* invalid and split the list 51 | label_names.partition { |l| GithubService.valid_label?(issue.fq_repo_name, l) } 52 | end 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /app/workers/pr_mergeability_checker.rb: -------------------------------------------------------------------------------- 1 | class PrMergeabilityChecker 2 | include Sidekiq::Worker 3 | sidekiq_options :queue => :miq_bot 4 | 5 | include BranchWorkerMixin 6 | 7 | LABEL = "unmergeable".freeze 8 | 9 | def perform(branch_id) 10 | return unless find_branch(branch_id, :pr) 11 | logger.info("Determining mergeability of PR #{branch.fq_pr_number}.") 12 | 13 | process_mergeability 14 | end 15 | 16 | private 17 | 18 | def tag 19 | "" 20 | end 21 | 22 | def unmergeable_comment 23 | "#{tag}This pull request is not mergeable. Please rebase and repush." 24 | end 25 | 26 | def process_mergeability 27 | was_mergeable = branch.mergeable? 28 | currently_mergeable = branch.git_service.mergeable? 29 | 30 | if was_mergeable && !currently_mergeable 31 | write_to_github 32 | apply_label 33 | elsif !was_mergeable && currently_mergeable 34 | remove_comments 35 | remove_label 36 | end 37 | 38 | # Update columns directly to avoid collisions wrt the serialized column issue 39 | branch.update_columns(:mergeable => currently_mergeable) 40 | end 41 | 42 | def remove_comments 43 | comment_ids = GithubService.issue_comments(fq_repo_name, branch.pr_number).select do |com| 44 | com.user.login == Settings.github_credentials.username && com.body.start_with?(tag) 45 | end.map(&:id) 46 | 47 | GithubService.delete_comments(fq_repo_name, comment_ids) 48 | end 49 | 50 | def write_to_github 51 | logger.info("Updating PR #{branch.fq_pr_number} with mergability comment.") 52 | 53 | GithubService.add_comment( 54 | fq_repo_name, 55 | branch.pr_number, 56 | unmergeable_comment 57 | ) 58 | end 59 | 60 | def apply_label 61 | logger.info("Updating PR #{branch.fq_pr_number} with label #{LABEL.inspect}.") 62 | 63 | GithubService.add_labels_to_an_issue(fq_repo_name, branch.pr_number, [LABEL]) 64 | end 65 | 66 | def remove_label 67 | logger.info("Updating PR #{branch.fq_pr_number} removing label #{LABEL.inspect}.") 68 | GithubService.remove_label(fq_repo_name, branch.pr_number, LABEL) 69 | rescue Octokit::NotFound 70 | # This label is not currently applied, skip 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_lines_not_in_the_diff/results.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "rubocop_version": "1.13.0", 4 | "ruby_engine": "ruby", 5 | "ruby_version": "2.7.2", 6 | "ruby_patchlevel": "137", 7 | "ruby_platform": "x86_64-darwin19" 8 | }, 9 | "files": [ 10 | { 11 | "path": "spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_lines_not_in_the_diff/example.rb", 12 | "offenses": [ 13 | { 14 | "severity": "convention", 15 | "message": "Freeze mutable objects assigned to constants.", 16 | "cop_name": "Style/MutableConstant", 17 | "corrected": false, 18 | "correctable": true, 19 | "location": { 20 | "start_line": 2, 21 | "start_column": 10, 22 | "last_line": 6, 23 | "last_column": 3, 24 | "length": 78, 25 | "line": 2, 26 | "column": 10 27 | } 28 | }, 29 | { 30 | "severity": "convention", 31 | "message": "Align the keys and values of a hash literal if they span more than one line.", 32 | "cop_name": "Layout/HashAlignment", 33 | "corrected": false, 34 | "correctable": true, 35 | "location": { 36 | "start_line": 3, 37 | "start_column": 5, 38 | "last_line": 3, 39 | "last_column": 20, 40 | "length": 16, 41 | "line": 3, 42 | "column": 5 43 | } 44 | }, 45 | { 46 | "severity": "convention", 47 | "message": "Align the keys and values of a hash literal if they span more than one line.", 48 | "cop_name": "Layout/HashAlignment", 49 | "corrected": false, 50 | "correctable": true, 51 | "location": { 52 | "start_line": 4, 53 | "start_column": 5, 54 | "last_line": 4, 55 | "last_column": 22, 56 | "length": 18, 57 | "line": 4, 58 | "column": 5 59 | } 60 | } 61 | ] 62 | } 63 | ], 64 | "summary": { 65 | "offense_count": 3, 66 | "target_file_count": 1, 67 | "inspected_file_count": 1 68 | } 69 | } -------------------------------------------------------------------------------- /spec/lib/github_service/commands/unassign_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.describe GithubService::Commands::Unassign do 4 | subject { described_class.new(issue) } 5 | let(:issue) { double("Issue", :fq_repo_name => "org/repo") } 6 | let(:command_issuer) { "nickname" } 7 | let(:assigned_users) { ["listed_user"] } 8 | 9 | before do 10 | allow(subject).to receive(:list_assigned_users).and_return(assigned_users) 11 | end 12 | 13 | after do 14 | subject.execute!(:issuer => command_issuer, :value => command_value) 15 | end 16 | 17 | context "with a user who is in the list of assignees" do 18 | let(:command_value) { "listed_user" } 19 | 20 | it "unassigns pull request or issue from that user" do 21 | allow(issue).to receive(:number).and_return(42) 22 | expect(subject).to receive(:octokit_remove_assignees).with("org/repo", 42, %w(listed_user)).once 23 | end 24 | end 25 | 26 | context "with a user who is not in the list of assignees" do 27 | let(:command_value) { "non_listed_user" } 28 | 29 | it "do not unassign pull request or issue from that user" do 30 | expect(issue).not_to receive(:octokit_remove_assignees) 31 | expect(issue).to receive(:add_comment).with("@#{command_issuer} User 'non_listed_user' is not in the list of assignees, ignoring...") 32 | end 33 | end 34 | 35 | context "with users who are in the list of assignees" do 36 | let(:assigned_users) { %w(listed_user1 listed_user2) } 37 | let(:command_value) { "listed_user1, listed_user2" } 38 | 39 | it "unassigns pull request or issue from these users" do 40 | allow(issue).to receive(:number).and_return(42) 41 | expect(subject).to receive(:octokit_remove_assignees).with("org/repo", 42, %w(listed_user1 listed_user2)).once 42 | end 43 | end 44 | 45 | context "with users who are not in the list of assignees" do 46 | let(:assigned_users) { %w(listed_user1 listed_user2) } 47 | let(:command_value) { "non_listed_user1, non_listed_user2" } 48 | 49 | it "do not unassign pull request or issue from these users" do 50 | expect(issue).not_to receive(:octokit_remove_assignees) 51 | expect(issue).to receive(:add_comment).with("@#{command_issuer} Users 'non_listed_user1, non_listed_user2' are not in the list of assignees, ignoring...") 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/workers/github_notification_monitor_worker_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe GithubNotificationMonitorWorker do 2 | before { stub_sidekiq_logger(described_class) } 3 | subject { described_class.new } 4 | 5 | def stub_github_notification_monitors(*org_repo_pairs) 6 | repo_names = org_repo_pairs.collect { |pair| pair.join("/") } 7 | stub_settings(:github_notification_monitor => {:repo_names => repo_names}) 8 | 9 | org_repo_pairs.collect.with_index do |(org, repo), i| 10 | fq_repo_name = "#{org}/#{repo}" 11 | create(:repo, :name => fq_repo_name) 12 | 13 | double("github notification monitor #{i}").tap do |notification_monitor| 14 | allow(GithubNotificationMonitor).to receive(:new).with(fq_repo_name).and_return(notification_monitor) 15 | end 16 | end 17 | end 18 | 19 | describe "#perform" do 20 | it "skips if the list of repo names is not provided" do 21 | stub_settings(:github_notification_monitor => {:repo_names => nil}) 22 | 23 | expect(GithubNotificationMonitor).to_not receive(:new) 24 | subject.perform 25 | end 26 | 27 | it "skips if the list of repo names is empty" do 28 | stub_settings(:github_notification_monitor => {:repo_names => []}) 29 | 30 | expect(GithubNotificationMonitor).to_not receive(:new) 31 | subject.perform 32 | end 33 | 34 | it "gets notifications from a notification monitor" do 35 | nm = stub_github_notification_monitors(["SomeOrg", "some_repo"]).first 36 | 37 | expect(nm).to receive(:process_notifications).once 38 | subject.perform 39 | end 40 | 41 | it "gets notifications from multiple notification monitors" do 42 | nm1, nm2 = stub_github_notification_monitors(["SomeOrg", "some_repo1"], ["SomeOrg", "some_repo2"]) 43 | 44 | expect(nm1).to receive(:process_notifications).once 45 | expect(nm2).to receive(:process_notifications).once 46 | subject.perform 47 | end 48 | 49 | it "handles errors raised by a notification monitor" do 50 | nm1, nm2 = stub_github_notification_monitors(["SomeOrg", "some_repo1"], ["SomeOrg", "some_repo2"]) 51 | 52 | expect(nm1).to receive(:process_notifications).once.and_raise("boom") 53 | expect(nm2).to receive(:process_notifications).once 54 | 55 | expect { subject.perform }.not_to raise_error 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.0].define(version: 2025_03_05_205548) do 14 | # These are extensions that must be enabled in order to support this database 15 | enable_extension "plpgsql" 16 | 17 | create_table "batch_entries", id: :serial, force: :cascade do |t| 18 | t.integer "batch_job_id" 19 | t.string "state", limit: 255 20 | t.text "result" 21 | t.index ["batch_job_id"], name: "index_batch_entries_on_batch_job_id" 22 | end 23 | 24 | create_table "batch_jobs", id: :serial, force: :cascade do |t| 25 | t.datetime "created_at", precision: nil 26 | t.datetime "updated_at", precision: nil 27 | t.datetime "expires_at", precision: nil 28 | t.string "on_complete_class", limit: 255 29 | t.text "on_complete_args" 30 | t.string "state", limit: 255 31 | end 32 | 33 | create_table "branches", id: :serial, force: :cascade do |t| 34 | t.string "name", limit: 255 35 | t.string "commit_uri", limit: 255 36 | t.string "last_commit", limit: 255 37 | t.integer "repo_id" 38 | t.boolean "pull_request" 39 | t.datetime "last_checked_on", precision: nil 40 | t.datetime "last_changed_on", precision: nil 41 | t.text "commits_list" 42 | t.boolean "mergeable" 43 | t.string "merge_target" 44 | t.string "pr_title" 45 | t.integer "linter_offense_count" 46 | t.index ["name", "repo_id"], name: "index_branches_on_name_and_repo_id", unique: true 47 | end 48 | 49 | create_table "repos", id: :serial, force: :cascade do |t| 50 | t.string "name", limit: 255 51 | t.datetime "created_at", precision: nil 52 | t.datetime "updated_at", precision: nil 53 | end 54 | 55 | end 56 | -------------------------------------------------------------------------------- /spec/lib/github_service_spec.rb: -------------------------------------------------------------------------------- 1 | describe GithubService do 2 | describe "#username_lookup" do 3 | let(:lookup_username) { "NickLaMuro" } 4 | let(:lookup_status) { 200 } 5 | 6 | before do 7 | # HTTP lookup 8 | stub_request(:head, "https://github.com/#{lookup_username}") 9 | .with(:headers => {'Accept' => '*/*', 'User-Agent' => 'Ruby'}) 10 | .to_return(:status => lookup_status, :body => "", :headers => {}) 11 | end 12 | 13 | after do 14 | lookup_cache.delete(lookup_username) 15 | end 16 | 17 | def lookup_cache 18 | described_class.send(:username_lookup_cache) 19 | end 20 | 21 | context "for a valid user" do 22 | before do 23 | github_service_add_stub :url => "/users/#{lookup_username}", 24 | :response_body => {'id' => 123}.to_json 25 | end 26 | 27 | it "looks up a user and stores the user's ID in the cache" do 28 | expect(described_class.username_lookup(lookup_username)).to eq(123) 29 | expect(lookup_cache).to eq("NickLaMuro" => 123) 30 | end 31 | end 32 | 33 | context "for a user that is not found" do 34 | let(:lookup_status) { 404 } 35 | 36 | it "looks up a user and stores the user's ID in the cache" do 37 | expect(described_class.username_lookup(lookup_username)).to eq(nil) 38 | expect(lookup_cache).to eq("NickLaMuro" => nil) 39 | end 40 | 41 | it "does a lookup call only once" do 42 | http_instance = Net::HTTP.new("github.com", 443) 43 | fake_not_found = Net::HTTPNotFound.new(nil, nil, nil) 44 | expect(Net::HTTP).to receive(:new).and_return(http_instance) 45 | expect(http_instance).to receive(:request_head).once.and_return(fake_not_found) 46 | 47 | expect(described_class.username_lookup(lookup_username)).to eq(nil) 48 | expect(described_class.username_lookup(lookup_username)).to eq(nil) 49 | end 50 | end 51 | 52 | context "when GitHub is having a bad time..." do 53 | let(:lookup_status) { 500 } 54 | 55 | it "looks up a user and does not stores the username in the cache" do 56 | expect do 57 | described_class.username_lookup(lookup_username) 58 | end.to raise_error(RuntimeError, "Error on GitHub with username lookup") 59 | expect(lookup_cache).to eq({}) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_spec.rb: -------------------------------------------------------------------------------- 1 | describe CommitMonitor do 2 | context "#compare_commits_list (private)" do 3 | let(:left) { ["b35cfe137e193239d887a87182af971c6d1c7f07", "71eddc02941a4a8e08985202f49f8c88251b1bc1", "67b120c9ebf4467819fbfd329e06ad288621c53c"] } 4 | let(:right) { ["467ece9fe399a8e77ce6287a851acc62b6f9b5f6", "2bff87929646d40aca36649fc640310162b774f0", "e04b64754a32a6bedffc56d92e4a9f85b052296f"] } 5 | 6 | it "with matching lists" do 7 | expect(described_class.new.send(:compare_commits_list, left, left)).to eq( 8 | {:same => left, :left_only => [], :right_only => []} 9 | ) 10 | end 11 | 12 | it "with non-matching lists" do 13 | expect(described_class.new.send(:compare_commits_list, left, right)).to eq( 14 | {:same => [], :left_only => left, :right_only => right} 15 | ) 16 | end 17 | 18 | it "with partial matching lists" do 19 | l = left.dup << right[0] 20 | r = left.dup << right[1] 21 | 22 | expect(described_class.new.send(:compare_commits_list, l, r)).to eq( 23 | {:same => left, :left_only => [right[0]], :right_only => [right[1]]} 24 | ) 25 | end 26 | 27 | it "with partial matching lists and left list longer" do 28 | l = left.dup << right[0] 29 | r = left.dup 30 | 31 | expect(described_class.new.send(:compare_commits_list, l, r)).to eq( 32 | {:same => left, :left_only => [right[0]], :right_only => []} 33 | ) 34 | end 35 | 36 | it "with partial matching lists and right list longer" do 37 | l = left.dup 38 | r = left.dup << right[0] 39 | 40 | expect(described_class.new.send(:compare_commits_list, l, r)).to eq( 41 | {:same => left, :left_only => [], :right_only => [right[0]]} 42 | ) 43 | end 44 | 45 | it "with empty lists" do 46 | expect(described_class.new.send(:compare_commits_list, [], [])).to eq( 47 | {:same => [], :left_only => [], :right_only => []} 48 | ) 49 | end 50 | 51 | it "with empty left list" do 52 | expect(described_class.new.send(:compare_commits_list, [], right)).to eq( 53 | {:same => [], :left_only => [], :right_only => right} 54 | ) 55 | end 56 | 57 | it "with empty right list" do 58 | expect(described_class.new.send(:compare_commits_list, left, [])).to eq( 59 | {:same => [], :left_only => left, :right_only => []} 60 | ) 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/workers/pr_mergeability_checker_spec.rb: -------------------------------------------------------------------------------- 1 | describe PrMergeabilityChecker do 2 | before(:each) do 3 | stub_sidekiq_logger 4 | end 5 | 6 | let(:pr_branch) { create(:pr_branch, :name => 'prs/1/head', :mergeable => true) } 7 | let(:repo_name) { pr_branch.repo.name } 8 | 9 | context 'when PR was mergeable and becomes unmergeable' do 10 | it 'comments on the PR' do 11 | git_service = instance_double('GitService::Branch', :mergeable? => false) 12 | allow(GitService::Branch).to receive(:new) { git_service } 13 | allow(GithubService).to receive(:add_labels_to_an_issue) 14 | 15 | expect(GithubService).to receive(:add_comment) 16 | .with(repo_name, 1, a_string_including("not mergeable")) 17 | 18 | described_class.new.perform(pr_branch.id) 19 | end 20 | 21 | it "adds an 'unmergeable' label to the PR" do 22 | git_service = instance_double('GitService::Branch', :mergeable? => false) 23 | allow(GitService::Branch).to receive(:new) { git_service } 24 | allow(GithubService).to receive(:add_comment) 25 | 26 | expect(GithubService).to receive(:add_labels_to_an_issue) 27 | .with(repo_name, 1, ['unmergeable']) 28 | 29 | described_class.new.perform(pr_branch.id) 30 | end 31 | end 32 | 33 | context 'when PR was unmergeable and becomes mergeable' do 34 | let(:username) { "tux" } 35 | let(:user) { double("User", :id => 42, :login => username) } 36 | let(:body) { "This pull request is not mergeable. Please rebase and repush." } 37 | let(:comment) { double("Comment", :id => 9, :body => body, :user => user) } 38 | let(:pr_branch) { create(:pr_branch, :name => 'prs/1/head', :mergeable => false) } 39 | 40 | before do 41 | stub_settings(Hash(:github_credentials => {:username => username})) 42 | end 43 | 44 | it "removes an 'unmergeable' label and comments from the PR" do 45 | git_service = instance_double('GitService::Branch', :mergeable? => true) 46 | 47 | allow(GitService::Branch).to receive(:new) { git_service } 48 | allow(GithubService).to receive(:add_comment) 49 | 50 | expect(GithubService).to receive(:remove_label).with(repo_name, 1, 'unmergeable') 51 | expect(GithubService).to receive(:issue_comments).with(repo_name, 1).and_return([comment, comment]) 52 | expect(GithubService).to receive(:delete_comments).with(repo_name, [9, 9]).once 53 | 54 | described_class.new.perform(pr_branch.id) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/lib/offense_message/entry_spec.rb: -------------------------------------------------------------------------------- 1 | describe OffenseMessage::Entry do 2 | context "#to_s" do 3 | it "basic entry" do 4 | entry = described_class.new(:high, "Message") 5 | expect(entry.to_s).to eq("- [ ] :exclamation: - Message") 6 | end 7 | 8 | it "basic entry with locator" do 9 | entry = described_class.new(:high, "Message", nil, "Locator") 10 | expect(entry.to_s).to eq("- [ ] :exclamation: - Locator - Message") 11 | end 12 | end 13 | 14 | context "#<=>" do 15 | shared_examples_for "sortable" do 16 | it "should be sortable" do 17 | expect(entry1 <=> entry2).to eq(-1) 18 | expect(entry1 <=> entry1).to eq(0) 19 | expect(entry2 <=> entry1).to eq(1) 20 | end 21 | end 22 | 23 | context "by group" do 24 | context "with groups" do 25 | let(:entry1) { described_class.new(:high, "Message", "Group A") } 26 | let(:entry2) { described_class.new(:high, "Message", "Group B") } 27 | include_examples "sortable" 28 | end 29 | 30 | context "with and without group" do 31 | let(:entry1) { described_class.new(:high, "Message") } 32 | let(:entry2) { described_class.new(:high, "Message", "Group B") } 33 | include_examples "sortable" 34 | end 35 | end 36 | 37 | context "by severity" do 38 | let(:entry1) { described_class.new(:high, "Message") } 39 | let(:entry2) { described_class.new(:low, "Message") } 40 | include_examples "sortable" 41 | end 42 | 43 | context "by locator" do 44 | context "with locators" do 45 | let(:entry1) { described_class.new(:high, "Message", "Group A", "Locator A") } 46 | let(:entry2) { described_class.new(:high, "Message", "Group A", "Locator B") } 47 | include_examples "sortable" 48 | end 49 | 50 | context "with and without locator" do 51 | let(:entry1) { described_class.new(:high, "Message", "Group A") } 52 | let(:entry2) { described_class.new(:high, "Message", "Group A", "Locator B") } 53 | include_examples "sortable" 54 | end 55 | end 56 | 57 | context "by message" do 58 | let(:entry1) { described_class.new(:high, "Message A") } 59 | let(:entry2) { described_class.new(:high, "Message B") } 60 | include_examples "sortable" 61 | end 62 | 63 | it "as part of an Array" do 64 | entry1 = described_class.new(:low, "Message") 65 | entry2 = described_class.new(:high, "Message") 66 | expect([entry1, entry2].sort).to eq([entry2, entry1]) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_void_warnings_in_spec_files/results.json: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "rubocop_version": "1.13.0", 4 | "ruby_engine": "ruby", 5 | "ruby_version": "2.7.2", 6 | "ruby_patchlevel": "137", 7 | "ruby_platform": "x86_64-darwin19" 8 | }, 9 | "files": [ 10 | { 11 | "path": "spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_void_warnings_in_spec_files/non_spec_file_with_void_warning.rb", 12 | "offenses": [ 13 | { 14 | "severity": "warning", 15 | "message": "Operator `==` used in void context.", 16 | "cop_name": "Lint/Void", 17 | "corrected": false, 18 | "correctable": false, 19 | "location": { 20 | "start_line": 2, 21 | "start_column": 13, 22 | "last_line": 2, 23 | "last_column": 14, 24 | "length": 2, 25 | "line": 2, 26 | "column": 13 27 | } 28 | } 29 | ] 30 | }, 31 | { 32 | "path": "spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_void_warnings_in_spec_files/spec/non_spec_file_in_spec_dir_with_void_warning.rb", 33 | "offenses": [ 34 | { 35 | "severity": "warning", 36 | "message": "Operator `==` used in void context.", 37 | "cop_name": "Lint/Void", 38 | "corrected": false, 39 | "correctable": false, 40 | "location": { 41 | "start_line": 2, 42 | "start_column": 13, 43 | "last_line": 2, 44 | "last_column": 14, 45 | "length": 2, 46 | "line": 2, 47 | "column": 13 48 | } 49 | } 50 | ] 51 | }, 52 | { 53 | "path": "spec/workers/commit_monitor_handlers/commit_range/rubocop_checker/data/with_void_warnings_in_spec_files/spec/spec_file_with_void_warning_spec.rb", 54 | "offenses": [ 55 | { 56 | "severity": "warning", 57 | "message": "Operator `==` used in void context.", 58 | "cop_name": "Lint/Void", 59 | "corrected": false, 60 | "correctable": false, 61 | "location": { 62 | "start_line": 3, 63 | "start_column": 14, 64 | "last_line": 3, 65 | "last_column": 15, 66 | "length": 2, 67 | "line": 3, 68 | "column": 14 69 | } 70 | } 71 | ] 72 | } 73 | ], 74 | "summary": { 75 | "offense_count": 3, 76 | "target_file_count": 3, 77 | "inspected_file_count": 3 78 | } 79 | } -------------------------------------------------------------------------------- /lib/github_service/commands/base.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class Base 4 | class << self 5 | def inherited(subclass) 6 | subclass.extend(CommandMethods) 7 | 8 | class_name = subclass.to_s.demodulize 9 | subclass.register_as(class_name.underscore) 10 | end 11 | end 12 | 13 | class_attribute :restriction 14 | 15 | attr_reader :issue 16 | 17 | def initialize(issue) 18 | @issue = issue 19 | end 20 | 21 | ## 22 | # Public interface to Command classes 23 | # Subclasses of Commands::Base should implement #_execute with 24 | # the following keyword arguments: 25 | # 26 | # issuer - The username of the user that issued the command 27 | # value - The value of the command given 28 | # 29 | # No callers should ever use _execute directly, using execute! instead. 30 | # 31 | def execute!(issuer:, value:) 32 | if user_permitted?(issuer) 33 | _execute(:issuer => issuer, :value => value) 34 | end 35 | end 36 | 37 | private 38 | 39 | def _execute 40 | raise NotImplementedError 41 | end 42 | 43 | def user_permitted?(issuer) 44 | case self.class.restriction 45 | when nil 46 | true 47 | when :organization 48 | if GithubService.organization_member?(issue.organization_name, issuer) 49 | true 50 | else 51 | issue.add_comment("@#{issuer} Only members of the #{issue.organization_name} organization may use this command.") 52 | false 53 | end 54 | end 55 | end 56 | 57 | def valid_assignee?(user) 58 | # First reload the cache if it's an invalid assignee 59 | GithubService.refresh_assignees(issue.fq_repo_name) unless GithubService.valid_assignee?(issue.fq_repo_name, user) 60 | 61 | # Then see if it's *still* invalid 62 | GithubService.valid_assignee?(issue.fq_repo_name, user) 63 | end 64 | 65 | VALID_RESTRICTIONS = [:organization].freeze 66 | 67 | module CommandMethods 68 | def register_as(command_name) 69 | CommandDispatcher.register_command(command_name, self) 70 | end 71 | alias alias_as register_as 72 | 73 | def restrict_to(restriction) 74 | unless VALID_RESTRICTIONS.include?(restriction) 75 | raise RestrictionError, "'#{restriction}' is not a valid restriction" 76 | end 77 | self.restriction = restriction 78 | end 79 | end 80 | 81 | RestrictionError = Class.new(StandardError) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /app/workers/concerns/sidekiq_worker_mixin.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/module/delegation" 2 | require "active_support/concern" 3 | 4 | module SidekiqWorkerMixin 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | delegate :settings, :enabled_repos, :enabled_repo_names, :enabled_for?, :to => :class 9 | delegate :sidekiq_queue, :workers, :running?, :to => :class 10 | end 11 | 12 | module ClassMethods 13 | # 14 | # Settings helper methods 15 | # 16 | 17 | def settings_key 18 | @settings_key ||= name.split("::").last.underscore 19 | end 20 | private :settings_key 21 | 22 | def settings 23 | Settings[settings_key] || Config::Options.new 24 | end 25 | 26 | def included_and_excluded_repos 27 | i = settings.included_repos.try(:flatten) 28 | e = settings.excluded_repos.try(:flatten) 29 | raise "Do not specify both excluded_repos and included_repos in settings for #{settings_key.inspect}" if i && e 30 | return i, e 31 | end 32 | private :included_and_excluded_repos 33 | 34 | def enabled_repos 35 | i, e = included_and_excluded_repos 36 | 37 | if i && !e 38 | Repo.where(:name => i) 39 | elsif !i && e 40 | Repo.where.not(:name => e) 41 | elsif !i && !e 42 | Repo.all 43 | end 44 | end 45 | 46 | def enabled_repo_names 47 | enabled_repos.collect(&:name) 48 | end 49 | 50 | def enabled_for?(repo) 51 | i, e = included_and_excluded_repos 52 | 53 | if i && !e 54 | i.include?(repo.name) 55 | elsif !i && e 56 | !e.include?(repo.name) 57 | elsif !i && !e 58 | true 59 | end 60 | end 61 | 62 | # 63 | # Sidekiq Helper methods 64 | # 65 | 66 | def sidekiq_queue 67 | sidekiq_options unless get_sidekiq_options # init the sidekiq_options_hash 68 | sidekiq_options_hash["queue"] 69 | end 70 | 71 | def workers 72 | queue = sidekiq_queue.to_s 73 | 74 | workers = Sidekiq::Workers.new 75 | workers = workers.select do |_processid, _threadid, work| 76 | work["queue"] == queue && work.fetch_path("payload", "class") == name 77 | end 78 | workers.sort_by! { |_processid, _threadid, work| work.fetch_path("payload", "enqueued_at") } 79 | 80 | workers 81 | end 82 | 83 | def running?(workers = nil) 84 | (workers || self.workers).any? 85 | end 86 | end 87 | 88 | # 89 | # Sidekiq Helper methods 90 | # 91 | 92 | def first_unique_worker?(workers = nil) 93 | _processid, _threadid, work = (workers || self.workers).first 94 | work.nil? || work.fetch_path("payload", "jid") == jid 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | ENV["RAILS_ENV"] ||= 'test' 3 | require File.expand_path("../../config/environment", __FILE__) 4 | 5 | require 'rspec/rails' 6 | require 'webmock/rspec' 7 | 8 | # Requires supporting ruby files with custom matchers and macros, etc, 9 | # in spec/support/ and its subdirectories. 10 | 11 | Dir[Rails.root.join('spec', 'support', '**', '*.rb')].each { |f| require f } 12 | 13 | # Checks for pending migrations before tests are run. 14 | # If you are not using ActiveRecord, you can remove this line. 15 | ActiveRecord::Migration.check_pending! if defined?(ActiveRecord::Migration) 16 | 17 | RSpec.configure do |config| 18 | # ## Mock Framework 19 | # 20 | # If you prefer to use mocha, flexmock or RR, uncomment the appropriate line: 21 | # 22 | # config.mock_with :mocha 23 | # config.mock_with :flexmock 24 | # config.mock_with :rr 25 | 26 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 27 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 28 | 29 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 30 | # examples within a transaction, remove the following line or assign false 31 | # instead of true. 32 | config.use_transactional_fixtures = true 33 | 34 | # If true, the base class of anonymous controllers will be inferred 35 | # automatically. This will be the default behavior in future versions of 36 | # rspec-rails. 37 | config.infer_base_class_for_anonymous_controllers = false 38 | 39 | # Run specs in random order to surface order dependencies. If you find an 40 | # order dependency and want to debug it, you can fix the order by providing 41 | # the seed, which is printed after each run. 42 | # --seed 1234 43 | config.order = "random" 44 | 45 | config.expect_with(:rspec) { |c| c.syntax = [:should, :expect] } 46 | 47 | config.include FactoryBot::Syntax::Methods 48 | 49 | require "awesome_spawn/spec_helper" 50 | config.include AwesomeSpawn::SpecHelper 51 | 52 | config.before do 53 | allow_any_instance_of(MinigitService).to receive(:service) 54 | .and_raise("Live execution is not allowed in specs. Use stubs/expectations on service instead.") 55 | end 56 | 57 | config.after do 58 | Module.clear_all_cache_with_timeout 59 | 60 | # Disable rubocop check because .empty? doesn't exist on a Sidekiq Queue 61 | raise "miq_bot queue is not empty" unless Sidekiq::Queue.new("miq_bot").size == 0 # rubocop:disable Style/ZeroLengthPredicate 62 | raise "miq_bot_glacial queue is not empty" unless Sidekiq::Queue.new("miq_bot_glacial").size == 0 # rubocop:disable Style/ZeroLengthPredicate 63 | end 64 | end 65 | 66 | WebMock.disable_net_connect!(:allow_localhost => true) 67 | -------------------------------------------------------------------------------- /spec/workers/concerns/code_analysis_mixin_spec.rb: -------------------------------------------------------------------------------- 1 | describe CodeAnalysisMixin do 2 | let(:example_rubocop_result) { {"metadata"=>{"rubocop_version"=>"0.47.1", "ruby_engine"=>"ruby", "ruby_version"=>"2.3.3", "ruby_patchlevel"=>"222", "ruby_platform"=>"x86_64-linux"}, "files"=>[{"path"=>"app/helpers/application_helper/button/mixins/discovery_mixin.rb", "offenses"=>[]}, {"path"=>"app/helpers/application_helper/button/button_new_discover.rb", "offenses"=>[{"severity"=>"warning", "message"=>"Method `ApplicationHelper::Button::ButtonNewDiscover#visible?` is defined at both /tmp/d20171201-9050-1m4n90/app/helpers/application_helper/button/button_new_discover.rb:5 and /tmp/d20171201-9050-1m4n90/app/helpers/application_helper/button/button_new_discover.rb:9.", "cop_name"=>"Lint/DuplicateMethods", "corrected"=>nil, "location"=>{"line"=>9, "column"=>3, "length"=>3}}]}], "summary"=>{"offense_count"=>1, "target_file_count"=>2, "inspected_file_count"=>2}} } 3 | let(:test_class) do 4 | Class.new do 5 | include CodeAnalysisMixin 6 | attr_reader :branch 7 | end 8 | end 9 | subject { test_class.new } 10 | 11 | context "#merged_linter_results" do 12 | it "should always return a hash with a 'files' and 'summary' key, even with no cops running" do 13 | expect(Linter::Rubocop).to receive(:new).and_return(double(:run => nil)) 14 | expect(Linter::Haml).to receive(:new).and_return(double(:run => nil)) 15 | expect(Linter::Yaml).to receive(:new).and_return(double(:run => nil)) 16 | 17 | expect(subject.merged_linter_results).to eq("files"=>[], "summary"=>{"inspected_file_count"=>0, "offense_count"=>0, "target_file_count"=>0}) 18 | end 19 | 20 | it "merges together with one result" do 21 | expect(Linter::Rubocop).to receive(:new).and_return(double(:run => example_rubocop_result)) 22 | expect(Linter::Haml).to receive(:new).and_return(double(:run => nil)) 23 | expect(Linter::Yaml).to receive(:new).and_return(double(:run => nil)) 24 | 25 | expect(subject.merged_linter_results).to eq( 26 | "files" => example_rubocop_result["files"], 27 | "summary" => {"inspected_file_count"=>2, "offense_count"=>1, "target_file_count"=>2} 28 | ) 29 | end 30 | 31 | it "merges together with one result" do 32 | expect(Linter::Rubocop).to receive(:new).and_return(double(:run => example_rubocop_result)) 33 | expect(Linter::Haml).to receive(:new).and_return(double(:run => example_rubocop_result)) 34 | expect(Linter::Yaml).to receive(:new).and_return(double(:run => nil)) 35 | 36 | results = subject.merged_linter_results 37 | expect(results["files"]).to include(*example_rubocop_result["files"]) 38 | expect(results["summary"]).to eq("inspected_file_count"=>4, "offense_count"=>2, "target_file_count"=>4) 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # NOTE: This is enabled in order to autoload some of the dynamically loaded 12 | # classes in development, such as the GithubService::Commands classes 13 | config.eager_load = true 14 | 15 | # Show full error reports. 16 | config.consider_all_requests_local = true 17 | 18 | # Enable server timing 19 | config.server_timing = true 20 | 21 | # Enable/disable caching. By default caching is disabled. 22 | # Run rails dev:cache to toggle caching. 23 | if Rails.root.join("tmp/caching-dev.txt").exist? 24 | config.action_controller.perform_caching = true 25 | config.action_controller.enable_fragment_cache_logging = true 26 | 27 | config.cache_store = :memory_store 28 | config.public_file_server.headers = { 29 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 30 | } 31 | else 32 | config.action_controller.perform_caching = false 33 | 34 | config.cache_store = :null_store 35 | end 36 | 37 | # Store uploaded files on the local file system (see config/storage.yml for options). 38 | config.active_storage.service = :local 39 | 40 | # Don't care if the mailer can't send. 41 | config.action_mailer.raise_delivery_errors = false 42 | 43 | config.action_mailer.perform_caching = false 44 | 45 | # Print deprecation notices to the Rails logger. 46 | config.active_support.deprecation = :log 47 | 48 | # Raise exceptions for disallowed deprecations. 49 | config.active_support.disallowed_deprecation = :raise 50 | 51 | # Tell Active Support which deprecation messages to disallow. 52 | config.active_support.disallowed_deprecation_warnings = [] 53 | 54 | # Raise an error on page load if there are pending migrations. 55 | config.active_record.migration_error = :page_load 56 | 57 | # Highlight code that triggered database queries in logs. 58 | config.active_record.verbose_query_logs = true 59 | 60 | # Suppress logger output for asset requests. 61 | config.assets.quiet = true 62 | 63 | # Raises error for missing translations. 64 | # config.i18n.raise_on_missing_translations = true 65 | 66 | # Annotate rendered view with file names. 67 | # config.action_view.annotate_rendered_view_with_filenames = true 68 | 69 | # Uncomment if you wish to allow Action Cable access from any origin. 70 | # config.action_cable.disable_request_forgery_protection = true 71 | end 72 | -------------------------------------------------------------------------------- /app/workers/stale_issue_marker.rb: -------------------------------------------------------------------------------- 1 | class StaleIssueMarker 2 | include Sidekiq::Worker 3 | sidekiq_options :queue => :miq_bot_glacial, :retry => false 4 | 5 | include SidekiqWorkerMixin 6 | 7 | SEARCH_SORTING = {:sort => :updated, :direction => :asc}.freeze 8 | 9 | def perform 10 | if !first_unique_worker? 11 | logger.info("#{self.class} is already running, skipping") 12 | else 13 | process_stale_issues 14 | end 15 | end 16 | 17 | private 18 | 19 | # If an issue/pr has any of these labels, it will not be marked as stale or closed 20 | def pinned_labels 21 | Array(settings.pinned_labels || ["pinned"]) 22 | end 23 | 24 | # Triage logic: 25 | # 26 | # After 3 month of no activity: 27 | # - add stale tag (if it is no there) 28 | # - add comment 29 | # 30 | def process_stale_issues 31 | handle_newly_stale_issues 32 | end 33 | 34 | def handle_newly_stale_issues 35 | query = "is:open archived:false updated:<#{stale_date.strftime('%Y-%m-%d')}" 36 | query << " " << enabled_repos_query_filter 37 | query << " " << unpinned_query_filter 38 | 39 | GithubService.search_issues(query, **SEARCH_SORTING).each do |issue| 40 | comment_as_stale(issue) 41 | end 42 | end 43 | 44 | def stale_date 45 | @stale_date ||= 3.months.ago 46 | end 47 | 48 | def stale_label 49 | GithubService::Issue::STALE_LABEL 50 | end 51 | 52 | def enabled_repos_query_filter 53 | enabled_repo_names.map { |repo| %(repo:"#{repo}") }.join(" ") 54 | end 55 | 56 | def unpinned_query_filter 57 | pinned_labels.map { |label| %(-label:"#{label}") }.join(" ") 58 | end 59 | 60 | def validate_repo_has_stale_label(repo) 61 | unless GithubService.valid_label?(repo, stale_label) 62 | raise "The label #{stale_label} does not exist on #{repo}" 63 | end 64 | end 65 | 66 | def mark_as_stale(issue) 67 | return if issue.stale? 68 | 69 | validate_repo_has_stale_label(issue.fq_repo_name) 70 | issue.add_labels([stale_label]) 71 | end 72 | 73 | def comment_as_stale(issue) 74 | mark_as_stale(issue) 75 | 76 | message = "This #{issue.type} has been automatically marked as stale " \ 77 | "because it has not been updated for at least 3 months.\n\n" 78 | message << 79 | if issue.pull_request? 80 | "If these changes are still valid, please remove the " \ 81 | "`stale` label, make any changes requested by reviewers " \ 82 | "(if any), and ensure that this issue is being looked " \ 83 | "at by the assigned/reviewer(s)." 84 | else 85 | "If you can still reproduce this issue on the current " \ 86 | "release or on `master`, please reply with all of the " \ 87 | "information you have about it in order to keep the " \ 88 | "issue open." 89 | end 90 | 91 | logger.info("[#{Time.now.utc}] - Marking issue #{issue.fq_repo_name}##{issue.number} as stale") 92 | issue.add_comment(message) 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /app/workers/commit_monitor_handlers/commit_range/github_pr_commenter/commit_metadata_checker.rb: -------------------------------------------------------------------------------- 1 | require 'rugged' 2 | 3 | module CommitMonitorHandlers::CommitRange 4 | class GithubPrCommenter::CommitMetadataChecker 5 | include Sidekiq::Worker 6 | sidekiq_options :queue => :miq_bot_glacial 7 | 8 | include BatchEntryWorkerMixin 9 | include BranchWorkerMixin 10 | 11 | def perform(batch_entry_id, branch_id, new_commits) 12 | return unless find_batch_entry(batch_entry_id) 13 | return skip_batch_entry unless find_branch(branch_id, :pr) 14 | 15 | complete_batch_entry(:result => process_commits(new_commits)) 16 | end 17 | 18 | private 19 | 20 | def process_commits(new_commits) 21 | @offenses = [] 22 | 23 | new_commits.each do |commit_sha, data| 24 | check_for_usernames_in(commit_sha, data["message"]) 25 | check_for_merge_commit(commit_sha, data["merge_commit?"]) 26 | end 27 | 28 | @offenses 29 | end 30 | 31 | # From https://github.com/join 32 | # 33 | # "Username may only contain alphanumeric characters or single hyphens, 34 | # and cannot begin or end with a hyphen." 35 | # 36 | # To check for the start of a username we do a positive lookbehind to get 37 | # the `@` (but only if it is surrounded by whitespace), and a positive 38 | # lookhead at the end to confirm there is a whitespace char following the 39 | # "var" (isn't an instance variable with a trailing `.`, has an `_`, or is 40 | # actually an email address) 41 | # 42 | # Since there can't be underscores in Github usernames, this makes it so we 43 | # rule out partial matches of variables (@database_records having a 44 | # username lookup of `database`), but still catch full variable names 45 | # without underscores (`@foobarbaz`). 46 | # 47 | USERNAME_REGEXP = / 48 | (?<=^@|\s@) # must start with a '@' (don't capture) 49 | [a-zA-Z0-9] # first character must be alphanumeric 50 | [a-zA-Z0-9\-]* # middle chars may be alphanumeric or hyphens 51 | [a-zA-Z0-9] # last character must be alphanumeric 52 | (?=[\s]) # allow only variables without "_" (not captured) 53 | /x.freeze 54 | 55 | def check_for_usernames_in(commit, message) 56 | message.scan(USERNAME_REGEXP).each do |potential_username| 57 | next unless GithubService.username_lookup(potential_username) 58 | 59 | group = ::Branch.github_commit_uri(fq_repo_name, commit) 60 | message = "Username `@#{potential_username}` detected in commit message. Consider removing." 61 | @offenses << OffenseMessage::Entry.new(:low, message, group) 62 | end 63 | end 64 | 65 | def check_for_merge_commit(commit, merge_commit) 66 | return unless merge_commit 67 | 68 | group = ::Branch.github_commit_uri(fq_repo_name, commit) 69 | message = "Merge commit #{commit} detected. Consider rebasing." 70 | @offenses << OffenseMessage::Entry.new(:warn, message, group) 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/workers/pull_request_monitor_spec.rb: -------------------------------------------------------------------------------- 1 | describe PullRequestMonitor do 2 | describe "#process_repo" do 3 | let(:repo) { create(:repo) } 4 | 5 | let(:github_pr_head_repo) { double("Github repo", :html_url => "https://github.com/SomeUser/some_repo") } 6 | let(:github_pr) do 7 | double("Github PR", 8 | :number => 1, 9 | :title => "PR number 1", 10 | 11 | :base => double("Github PR base", 12 | :ref => "master", 13 | :repo => double("Github repo", :html_url => "https://github.com/#{repo.name}") 14 | ), 15 | 16 | :head => double("Github PR head", 17 | :repo => github_pr_head_repo 18 | ) 19 | ) 20 | end 21 | 22 | def stub_git_service 23 | expect(repo).to receive(:git_fetch) 24 | double("Git service", :merge_base => "123abc").tap do |git_service| 25 | allow_any_instance_of(Branch).to receive(:git_service).and_return(git_service) 26 | end 27 | end 28 | 29 | it "ignores a repo that can't have PRs (because of no upstream_user)" do 30 | repo.update!(:name => "foo") 31 | 32 | expect(repo).to_not receive(:synchronize_pr_branches) 33 | 34 | described_class.new.process_repo(repo) 35 | end 36 | 37 | it "with Github PRs" do 38 | stub_github_prs(github_pr) 39 | stub_git_service 40 | 41 | expect(repo).to receive(:synchronize_pr_branches).with([{ 42 | :number => 1, 43 | :html_url => "https://github.com/SomeUser/some_repo", 44 | :merge_target => "master", 45 | :pr_title => "PR number 1" 46 | }]).and_call_original 47 | PullRequestMonitorHandlers.constants.each do |c| 48 | expect(PullRequestMonitorHandlers.const_get(c)).to receive(:perform_async) 49 | end 50 | 51 | described_class.new.process_repo(repo) 52 | end 53 | 54 | context "when the Github PR head.repo is nil" do 55 | let(:github_pr_head_repo) { nil } 56 | 57 | it "creates a PR branch" do 58 | stub_github_prs(github_pr) 59 | stub_git_service 60 | 61 | expect(repo).to receive(:synchronize_pr_branches).with([{ 62 | :number => 1, 63 | :html_url => "https://github.com/#{repo.name}", 64 | :merge_target => "master", 65 | :pr_title => "PR number 1" 66 | }]).and_call_original 67 | PullRequestMonitorHandlers.constants.each do |c| 68 | expect(PullRequestMonitorHandlers.const_get(c)).to receive(:perform_async) 69 | end 70 | 71 | described_class.new.process_repo(repo) 72 | end 73 | end 74 | 75 | it "when there are no Github PRs" do 76 | stub_github_prs([]) 77 | stub_git_service 78 | 79 | expect(repo).to receive(:synchronize_pr_branches).with([]).and_call_original 80 | PullRequestMonitorHandlers.constants.each do |c| 81 | expect(PullRequestMonitorHandlers.const_get(c)).to_not receive(:perform_async) 82 | end 83 | 84 | described_class.new.process_repo(repo) 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/lib/github_service/command_dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe GithubService::CommandDispatcher do 2 | subject(:command_dispatcher) { described_class.new(issue) } 3 | 4 | let(:bot_name) { "miq-bot" } 5 | let(:command_issuer) { "chessbyte" } 6 | let(:fq_repo_name) { "foo/bar" } 7 | let(:issue) do 8 | double('issue', 9 | :user => double(:login => "chrisarcand"), 10 | :body => "Opened this issue", 11 | :number => 1, 12 | :labels => [], 13 | :repository_url => "https://api.fakegithub.com/repos/#{fq_repo_name}") 14 | end 15 | 16 | describe "#dispatch!" do 17 | before do 18 | allow(Settings).to receive(:github_credentials).and_return(double(:username => bot_name)) 19 | end 20 | 21 | after do 22 | command_dispatcher.dispatch!(:issuer => command_issuer, :text => text) 23 | end 24 | 25 | context "when 'assign' command is given" do 26 | let(:text) { "@#{bot_name} assign chrisarcand" } 27 | let(:command_class) { double } 28 | 29 | it "dispatches to Assign" do 30 | expect(GithubService::Commands::Assign).to receive(:new).and_return(command_class) 31 | expect(command_class).to receive(:execute!) 32 | .with(:issuer => command_issuer, :value => "chrisarcand") 33 | end 34 | 35 | context "if the bot is the target" do 36 | let(:command_issuer) { bot_name } 37 | 38 | it "does nothing" do 39 | expect(GithubService::Commands::Assign).to receive(:new).never 40 | expect(command_class).to receive(:execute!).never 41 | end 42 | end 43 | end 44 | 45 | context "when 'add_labels' command is given" do 46 | let(:text) { "@#{bot_name} add-label question, wontfix" } 47 | let(:command_class) { double } 48 | 49 | it "dispatches to AddLabel" do 50 | expect(GithubService::Commands::AddLabel).to receive(:new).and_return(command_class) 51 | expect(command_class).to receive(:execute!) 52 | .with(:issuer => command_issuer, :value => "question, wontfix") 53 | end 54 | 55 | context "with extra space in command" do 56 | let(:text) { "@#{bot_name} add label question, wontfix" } 57 | 58 | it "doesn't dispatch and comments on error" do 59 | expect(GithubService::Commands::AddLabel).to_not receive(:new) 60 | expect(command_dispatcher.issue).to receive(:add_comment) 61 | .with(a_string_including("@#{command_issuer} unrecognized command 'add'")) 62 | end 63 | end 64 | end 65 | 66 | context "when 'remove_labels' command is given" do 67 | let(:text) { "@#{bot_name} remove-label question" } 68 | let(:command_class) { double } 69 | 70 | it "dispatches to RemoveLabel" do 71 | expect(GithubService::Commands::RemoveLabel).to receive(:new).and_return(command_class) 72 | expect(command_class).to receive(:execute!) 73 | .with(:issuer => command_issuer, :value => "question") 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/git_service/commit.rb: -------------------------------------------------------------------------------- 1 | module GitService 2 | class Commit 3 | attr_reader :commit_oid, :rugged_repo 4 | def initialize(rugged_repo, commit_oid) 5 | @commit_oid = commit_oid 6 | @rugged_repo = rugged_repo 7 | end 8 | 9 | def diff(other_ref = parent_oids.first) 10 | Diff.new(rugged_diff(other_ref)) 11 | end 12 | 13 | def parent_oids 14 | @parent_oids ||= rugged_commit.parent_oids 15 | end 16 | 17 | def rugged_commit 18 | @rugged_commit ||= Rugged::Commit.lookup(rugged_repo, commit_oid) 19 | end 20 | 21 | def rugged_diff(other_ref = parent_oids.first) 22 | other_commit = Rugged::Commit.lookup(rugged_repo, other_ref) 23 | other_commit.diff(rugged_commit) 24 | end 25 | 26 | def formatted_author 27 | "#{rugged_commit.author[:name]} <#{rugged_commit.author[:email]}>" 28 | end 29 | 30 | def formatted_author_date 31 | rugged_commit.author[:time].to_time.strftime("%c %z") 32 | end 33 | 34 | def formatted_committer 35 | "#{rugged_commit.committer[:name]} <#{rugged_commit.committer[:email]}>" 36 | end 37 | 38 | def formatted_committer_date 39 | rugged_commit.committer[:time].to_time.strftime("%c %z") 40 | end 41 | 42 | # Note: really not needed, but keeps it consistent 43 | def formatted_commit_message 44 | rugged_commit.message 45 | end 46 | 47 | def formatted_commit_stats 48 | diff.file_status.map do |file, stats| 49 | file_stats = file.dup 50 | file_stats << " | " 51 | file_stats << (stats[:additions].to_i + stats[:deletions].to_i).to_s 52 | file_stats << " " 53 | file_stats << "+" if stats[:additions].positive? 54 | file_stats << "-" if stats[:deletions].positive? 55 | file_stats 56 | end 57 | end 58 | 59 | def full_message 60 | message = "commit #{commit_oid}\n" 61 | message << "Merge: #{parent_oids.join(" ")}\n" if parent_oids.length > 1 62 | message << "Author: #{formatted_author}\n" 63 | message << "AuthorDate: #{formatted_author_date}\n" 64 | message << "Commit: #{formatted_committer}\n" 65 | message << "CommitDate: #{formatted_committer_date}\n" 66 | message << "\n" 67 | message << formatted_commit_message.indent(4) 68 | message << "\n" 69 | message << formatted_commit_stats.join("\n").indent(1) 70 | message << "\n #{diff.status_summary}" 71 | message 72 | end 73 | 74 | def details_hash 75 | { 76 | "sha" => commit_oid, 77 | "parent_oids" => parent_oids, 78 | "merge_commit?" => parent_oids.length > 1, 79 | "author" => formatted_author, 80 | "author_date" => formatted_author_date, 81 | "commit" => formatted_committer, 82 | "commit_date" => formatted_committer_date, 83 | "message" => formatted_commit_message, 84 | "files" => diff.file_status.keys, 85 | "stats" => formatted_commit_stats, 86 | "status_summary" => diff.status_summary 87 | } 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/workers/commit_monitor_handlers/commit_range/github_pr_commenter/diff_filename_checker_spec.rb: -------------------------------------------------------------------------------- 1 | describe CommitMonitorHandlers::CommitRange::GithubPrCommenter::DiffFilenameChecker do 2 | let(:batch_entry) { BatchEntry.create!(:job => BatchJob.create!) } 3 | let(:branch) { create(:pr_branch) } 4 | let(:git_service) { double("GitService", :diff => double("RuggedDiff", :new_files => new_files)) } 5 | 6 | before do 7 | stub_sidekiq_logger 8 | stub_job_completion 9 | expect_any_instance_of(Branch).to receive(:git_service).and_return(git_service) 10 | end 11 | 12 | context "Migration Timestamp" do 13 | context "with bad migration dates" do 14 | let(:new_files) do 15 | [ 16 | "db/migrate/20151435234623_do_some_stuff.rb", # bad 17 | "db/migrate/20150821123456_do_some_stuff.rb", # good 18 | "blah.rb" # ignored 19 | ] 20 | end 21 | 22 | it "with one bad, one good, one ignored" do 23 | described_class.new.perform(batch_entry.id, branch.id, nil) 24 | 25 | batch_entry.reload 26 | expect(batch_entry.result.length).to eq(1) 27 | expect(batch_entry.result.first).to have_attributes(:group => "db/migrate/20151435234623_do_some_stuff.rb", :message => "Bad Migration Timestamp") 28 | end 29 | end 30 | 31 | context "with multiple bad migration dates" do 32 | let(:new_files) do 33 | [ 34 | "db/migrate/20151435234623_do_some_stuff.rb", # bad 35 | "db/migrate/20151435234624_do_some_stuff.rb", # bad 36 | "db/migrate/20150821123456_do_some_stuff.rb", # good 37 | "blah.rb" # ignored 38 | ] 39 | end 40 | 41 | it "with two bad, one good, one ignored" do 42 | described_class.new.perform(batch_entry.id, branch.id, nil) 43 | 44 | batch_entry.reload 45 | expect(batch_entry.result.length).to eq(2) 46 | results = batch_entry.result.sort! 47 | expect(results.first).to have_attributes(:group => "db/migrate/20151435234623_do_some_stuff.rb", :message => "Bad Migration Timestamp") 48 | expect(results.last).to have_attributes(:group => "db/migrate/20151435234624_do_some_stuff.rb", :message => "Bad Migration Timestamp") 49 | end 50 | end 51 | 52 | context "with no bad migration dates" do 53 | let(:new_files) do 54 | [ 55 | "db/migrate/20150821123456_do_some_stuff.rb", # good 56 | "blah.rb" # ignored 57 | ] 58 | end 59 | 60 | it "one good, one ignored" do 61 | described_class.new.perform(batch_entry.id, branch.id, nil) 62 | 63 | expect(batch_entry.reload.result).to eq([]) 64 | end 65 | end 66 | 67 | context "with no migrations" do 68 | let(:new_files) do 69 | [ 70 | "blah.rb" # ignored 71 | ] 72 | end 73 | 74 | it "one ignored" do 75 | described_class.new.perform(batch_entry.id, branch.id, nil) 76 | 77 | expect(batch_entry.reload.result).to eq([]) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/minigit_service.rb: -------------------------------------------------------------------------------- 1 | class MinigitService 2 | include ThreadsafeServiceMixin 3 | 4 | def self.clone(*args) 5 | require 'awesome_spawn' 6 | STDERR.puts "+ #{AwesomeSpawn.build_command_line("git clone", args)}" 7 | STDERR.puts AwesomeSpawn.run!("git clone", :params => args).output 8 | true 9 | rescue AwesomeSpawn::CommandResultError => err 10 | require 'minigit' 11 | raise MiniGit::GitError.new(["clone"], err.result.error.chomp) 12 | end 13 | 14 | # All MiniGit methods return stdout which always has a trailing newline 15 | # that is never wanted, so remove it always. 16 | def delegate_to_service(method_name, *args) 17 | super.chomp 18 | end 19 | 20 | attr_reader :path_to_repo 21 | 22 | def initialize(path_to_repo) 23 | @path_to_repo = path_to_repo 24 | service # initialize the service 25 | end 26 | 27 | def service 28 | @service ||= begin 29 | require 'minigit' 30 | MiniGit.debug = true 31 | MiniGit::Capturing.new(File.expand_path(path_to_repo)) 32 | end 33 | end 34 | 35 | def new_commits(since_commit, ref = "HEAD") 36 | rev_list({:reverse => true}, "#{since_commit}..#{ref}").split("\n") 37 | end 38 | 39 | def commit_message(commit) 40 | show({:pretty => "fuller"}, "--stat", "--summary", commit) 41 | end 42 | 43 | def current_ref 44 | rev_parse("HEAD") 45 | end 46 | 47 | def diff_details(commit1, commit2 = nil) 48 | if commit2.nil? 49 | commit2 = commit1 50 | commit1 = "#{commit1}~" 51 | end 52 | output = diff("--patience", "-U0", "--no-color", "#{commit1}...#{commit2}") 53 | 54 | ret = Hash.new { |h, k| h[k] = [] } 55 | path = line_number = nil 56 | output.each_line do |line| 57 | # Note: We are intentionally ignoring deletes "-" for now 58 | case line 59 | when /^--- (?:a\/)?/ 60 | next 61 | when /^\+\+\+ (?:b\/)?(.+)/ 62 | path = $1.chomp 63 | when /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/ 64 | line_number = $1.to_i 65 | when /^[ +]/ 66 | ret[path] << line_number 67 | line_number += 1 68 | end 69 | end 70 | ret 71 | end 72 | 73 | def diff_file_names(commit1, commit2 = nil) 74 | if commit2.nil? 75 | commit2 = commit1 76 | commit1 = "#{commit1}~" 77 | end 78 | diff("--name-only", "#{commit1}...#{commit2}").split 79 | end 80 | 81 | # 82 | # Pull Request specific methods 83 | # 84 | 85 | def self.pr_branch(pr_number) 86 | "prs/#{pr_number}/head" 87 | end 88 | delegate :pr_branch, :to => :class 89 | 90 | def self.pr_number(branch) 91 | branch.split("/")[1].to_i 92 | end 93 | delegate :pr_number, :to => :class 94 | 95 | def remotes 96 | remote.split("\n").uniq.compact 97 | end 98 | 99 | def fetches(remote) 100 | config("--get-all", "remote.#{remote}.fetch").split("\n").compact 101 | end 102 | 103 | def ensure_prs_refs 104 | remotes.each do |remote_name| 105 | config("--add", "remote.#{remote_name}.fetch", "+refs/pull/*:refs/prs/*") unless fetches(remote_name).include?("+refs/pull/*:refs/prs/*") 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/lib/github_notification_monitor_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe GithubNotificationMonitor do 2 | subject(:notification_monitor) { described_class.new(fq_repo_name) } 3 | 4 | let(:notification) { double('notification', :issue_number => issue.number) } 5 | let(:issue) do 6 | double('issue', 7 | :author => "notchrisarcand", 8 | :body => "Opened this issue", 9 | :number => 1, 10 | :created_at => 15.minutes.ago, 11 | :labels => [], 12 | :repository_url => "https://api.fakegithub.com/repos/#{fq_repo_name}") 13 | end 14 | let(:comments) do 15 | [ 16 | double('comment', 17 | :author => "Commenter One", 18 | :updated_at => 14.minutes.ago, 19 | :body => "This is an old comment."), 20 | double('comment', 21 | :author => "Commenter Two", 22 | :updated_at => 8.minutes.ago, 23 | :body => "This is a new comment."), 24 | double('comment', 25 | :author => "Commenter Three", 26 | :updated_at => 3.minutes.ago, 27 | :body => "This is also a new comment.") 28 | ] 29 | end 30 | let(:username) { "miq-bot" } 31 | let(:fq_repo_name) { "foo/bar" } 32 | let(:command_dispatcher) { double } 33 | 34 | describe "#process_notifications" do 35 | before do 36 | allow(Settings).to receive(:github_credentials).and_return(double(:username => username)) 37 | allow(File).to receive(:write) 38 | .with(described_class::GITHUB_NOTIFICATION_MONITOR_YAML_FILE, anything) 39 | allow(YAML).to receive(:load_file) 40 | .with(described_class::GITHUB_NOTIFICATION_MONITOR_YAML_FILE, {:permitted_classes=>[Date, Time]}) do 41 | { "timestamps" => { fq_repo_name => { issue.number => 10.minutes.ago } } } 42 | end 43 | allow(GithubService).to receive(:repository_notifications) 44 | .with(fq_repo_name, a_hash_including("all" => false)).and_return([notification]) 45 | allow(GithubService).to receive(:issue) 46 | .with(fq_repo_name, notification.issue_number).and_return(issue) 47 | allow(GithubService).to receive(:issue_comments) 48 | .with(fq_repo_name, issue.number).and_return(comments) 49 | end 50 | 51 | after do 52 | notification_monitor.process_notifications 53 | end 54 | 55 | it "calls the command dispatcher for new comments and marks notification as read" do 56 | expect(GithubService::CommandDispatcher).to receive(:new).with(issue).and_return(command_dispatcher) 57 | expect(command_dispatcher).not_to receive(:dispatch!) 58 | .with(:issuer => "notchrisarcand", :text => issue.body) 59 | expect(command_dispatcher).not_to receive(:dispatch!) 60 | .with(:issuer => "Commenter One", :text => "This is an old comment.") 61 | expect(command_dispatcher).to receive(:dispatch!) 62 | .with(:issuer => "Commenter Two", :text => "This is a new comment.") 63 | expect(command_dispatcher).to receive(:dispatch!) 64 | .with(:issuer => "Commenter Three", :text => "This is also a new comment.") 65 | expect(notification).to receive(:mark_thread_as_read) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM registry.access.redhat.com/ubi9/ubi:latest 2 | 3 | ARG REF=master 4 | 5 | ENV TERM=xterm \ 6 | APP_ROOT=/opt/miq_bot 7 | 8 | LABEL name="miq-bot" \ 9 | vendor="ManageIQ" \ 10 | url="https://manageiq.org/" \ 11 | summary="ManageIQ Bot application image" \ 12 | description="ManageIQ Bot is a developer automation tool." \ 13 | io.k8s.display-name="ManageIQ Bot" \ 14 | io.k8s.description="ManageIQ Bot is a developer automation tool." \ 15 | io.openshift.tags="ManageIQ,miq-bot" 16 | 17 | RUN curl -L -o /usr/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 && \ 18 | chmod +x /usr/bin/dumb-init 19 | 20 | RUN ARCH=$(uname -m) && \ 21 | dnf -y --setopt=protected_packages= remove redhat-release && \ 22 | dnf -y install \ 23 | http://mirror.stream.centos.org/9-stream/BaseOS/${ARCH}/os/Packages/centos-stream-release-9.0-26.el9.noarch.rpm \ 24 | http://mirror.stream.centos.org/9-stream/BaseOS/${ARCH}/os/Packages/centos-stream-repos-9.0-26.el9.noarch.rpm \ 25 | http://mirror.stream.centos.org/9-stream/BaseOS/${ARCH}/os/Packages/centos-gpg-keys-9.0-26.el9.noarch.rpm && \ 26 | dnf -y install \ 27 | https://dl.fedoraproject.org/pub/epel/epel-release-latest-9.noarch.rpm && \ 28 | dnf -y --disablerepo=ubi-9-baseos-rpms swap openssl-fips-provider openssl-libs && \ 29 | dnf -y --disableplugin=subscription-manager module enable nodejs:18 && \ 30 | dnf -y module enable ruby:3.1 && \ 31 | dnf -y update && \ 32 | dnf clean all && \ 33 | rm -rf /var/cache/dnf 34 | 35 | RUN dnf -y --disableplugin=subscription-manager --setopt=tsflags=nodocs install \ 36 | @development \ 37 | cmake \ 38 | git \ 39 | libcurl-devel \ 40 | libffi-devel \ 41 | libssh2-devel \ 42 | libyaml-devel \ 43 | libxml2-devel \ 44 | openssl \ 45 | openssl-devel \ 46 | postgresql-devel \ 47 | ruby \ 48 | ruby-devel \ 49 | shared-mime-info \ 50 | sqlite-devel \ 51 | yamllint && \ 52 | dnf -y update libarchive && \ 53 | # Clean up all the things 54 | dnf clean all && \ 55 | rm -rf /var/cache/dnf && \ 56 | rm -rf /var/lib/dnf/history* && \ 57 | rm -rf /var/log/dnf*.log && \ 58 | rm -rf /var/log/hawkey.log && \ 59 | rm -rf /var/lib/rpm/__db.* 60 | 61 | RUN mkdir -p $APP_ROOT && \ 62 | curl -L https://github.com/ManageIQ/miq_bot/archive/$REF.tar.gz | tar xz -C $APP_ROOT --strip 1 && \ 63 | chgrp -R 0 $APP_ROOT && \ 64 | chmod -R g=u $APP_ROOT && \ 65 | cp $APP_ROOT/container-assets/container_env /usr/local/bin && \ 66 | cp $APP_ROOT/container-assets/entrypoint /usr/local/bin && \ 67 | echo "$REF" > $APP_ROOT/VERSION 68 | 69 | WORKDIR $APP_ROOT 70 | 71 | RUN echo "gem: --no-document" > ~/.gemrc && \ 72 | bundle config set --local build.rugged --with-ssh && \ 73 | bundle install --jobs=3 --retry=3 && \ 74 | # Clean up all the things 75 | rm -rf /usr/share/gems/cache/* && \ 76 | rm -rf /usr/share/gems/gems/rugged-*/vendor && \ 77 | find /usr/share/gems/gems/ -name *.o -type f -delete && \ 78 | find /usr/share/gems/gems/ -maxdepth 2 -name docs -type d -exec rm -r {} + && \ 79 | find /usr/share/gems/gems/ -maxdepth 2 -name spec -type d -exec rm -r {} + && \ 80 | find /usr/share/gems/gems/ -maxdepth 2 -name test -type d -exec rm -r {} + 81 | 82 | ENTRYPOINT ["/usr/bin/dumb-init", "--single-child", "--"] 83 | 84 | CMD ["entrypoint"] 85 | -------------------------------------------------------------------------------- /app/models/branch.rb: -------------------------------------------------------------------------------- 1 | class Branch < ActiveRecord::Base 2 | belongs_to :repo 3 | 4 | validates :name, :presence => true, :uniqueness => {:scope => :repo} 5 | validates :commit_uri, :presence => true 6 | validates :last_commit, :presence => true 7 | validates :repo, :presence => true 8 | 9 | serialize :commits_list, Array 10 | 11 | default_value_for(:commits_list) { [] } 12 | default_value_for :mergeable, true 13 | default_value_for :pull_request, false 14 | 15 | after_initialize(:unless => :commit_uri) { self.commit_uri = self.class.github_commit_uri(repo.try(:name)) } 16 | 17 | scope :regular_branches, -> { where(:pull_request => [false, nil]) } 18 | scope :pr_branches, -> { where(:pull_request => true) } 19 | 20 | def self.create_all_from_master(name) 21 | Repo.all.each do |repo| 22 | next if repo.branches.exists?(:name => name) 23 | 24 | b = repo.branches.new(:name => name) 25 | next unless b.git_service.exists? 26 | 27 | b.last_commit = b.git_service.merge_base("master") 28 | b.save! 29 | puts "Created #{name} on #{repo.name}" 30 | end 31 | end 32 | 33 | def self.with_branch_or_pr_number(n) 34 | n = MinigitService.pr_branch(n) if n.kind_of?(Integer) 35 | where(:name => n) 36 | end 37 | 38 | def self.github_commit_uri(repo_name, sha = "$commit") 39 | "https://github.com/#{repo_name}/commit/#{sha}" 40 | end 41 | 42 | def self.github_compare_uri(repo_name, sha1 = "$commit1", sha2 ="$commit2") 43 | "https://github.com/#{repo_name}/compare/#{sha1}~...#{sha2}" 44 | end 45 | 46 | def self.github_pr_uri(repo_name, pr_number = "$pr_number") 47 | "https://github.com/#{repo_name}/pull/#{pr_number}" 48 | end 49 | 50 | def last_commit=(val) 51 | super 52 | self.last_changed_on = Time.now.utc if last_commit_changed? 53 | end 54 | 55 | def commit_uri_to(commit) 56 | commit_uri.gsub("$commit", commit) 57 | end 58 | 59 | def last_commit_uri 60 | commit_uri_to(last_commit) 61 | end 62 | 63 | def local_merge_target 64 | "origin/#{merge_target}" 65 | end 66 | 67 | def compare_uri_for(commit1, commit2) 68 | # TODO: This needs use a different URI than the commit_uri 69 | commit_uri 70 | .gsub("/commit/", "/compare/") 71 | .gsub("$commit", "#{commit1}~...#{commit2}") 72 | end 73 | 74 | def mode 75 | pull_request? ? :pr : :regular 76 | end 77 | 78 | def pr_number 79 | MinigitService.pr_number(name) if pull_request? 80 | end 81 | 82 | def fq_pr_number 83 | "#{fq_repo_name}##{pr_number}" if pull_request? 84 | end 85 | 86 | def pr_title_tags 87 | pr_title.to_s.match(/^(?:\s*\[[\w-]+\])+/).to_s.gsub("[", " [").split.map { |s| s[1...-1] } 88 | end 89 | 90 | def github_pr_uri 91 | self.class.github_pr_uri(repo.name, pr_number) if pull_request? 92 | end 93 | 94 | def write_github_comment(header, continuation_header = nil, message = nil) 95 | raise ArgumentError, "Cannot comment on non-PR branch #{name}." unless pull_request? 96 | 97 | message_builder = GithubService::MessageBuilder.new(header, continuation_header) 98 | message_builder.write(message) if message 99 | 100 | logger.info("Writing comment with header: #{header}") 101 | GithubService.add_comments(repo.name, pr_number, message_builder.comments) 102 | end 103 | 104 | def fq_repo_name 105 | repo.name 106 | end 107 | 108 | def fq_branch_name 109 | "#{fq_repo_name}@#{name}" 110 | end 111 | 112 | def git_service 113 | GitService::Branch.new(self) 114 | end 115 | end 116 | -------------------------------------------------------------------------------- /lib/github_service/commands/add_label.rb: -------------------------------------------------------------------------------- 1 | module GithubService 2 | module Commands 3 | class AddLabel < Base 4 | include IsTeamMember 5 | 6 | def unassignable_labels 7 | @unassignable_labels ||= Settings.labels.unassignable.to_h.stringify_keys 8 | end 9 | 10 | private 11 | 12 | def _execute(issuer:, value:) 13 | valid, invalid = extract_label_names(value) 14 | process_extracted_labels(issuer, valid, invalid) 15 | 16 | if invalid.any? 17 | issue.add_comment(invalid_label_message(issuer, invalid)) 18 | end 19 | 20 | if valid.any? 21 | valid.reject! { |l| issue.applied_label?(l) } 22 | issue.add_labels(valid) if valid.any? 23 | end 24 | end 25 | 26 | def extract_label_names(value) 27 | label_names = value.split(",").map { |label| label.strip.downcase } 28 | validate_labels(label_names) 29 | end 30 | 31 | def validate_labels(label_names) 32 | # First reload the cache if there are any invalid labels 33 | GithubService.refresh_labels(issue.fq_repo_name) unless label_names.all? { |l| GithubService.valid_label?(issue.fq_repo_name, l) } 34 | 35 | # Then see if any are *still* invalid and split the list 36 | label_names.partition { |l| GithubService.valid_label?(issue.fq_repo_name, l) } 37 | end 38 | 39 | def process_extracted_labels(issuer, valid_labels, invalid_labels) 40 | correct_invalid_labels(valid_labels, invalid_labels) 41 | handle_unassignable_labels(valid_labels) unless triage_member?(issuer) 42 | 43 | [valid_labels, invalid_labels] 44 | end 45 | 46 | def correct_invalid_labels(valid_labels, invalid_labels) 47 | available_labels = GithubService.labels(issue.fq_repo_name) 48 | 49 | invalid_labels.reject! do |label| 50 | corrections = DidYouMean::SpellChecker.new(:dictionary => available_labels).correct(label) 51 | 52 | if corrections.count == 1 53 | valid_labels << corrections.first 54 | end 55 | end 56 | end 57 | 58 | def handle_unassignable_labels(valid_labels) 59 | valid_labels.map! do |label| 60 | unassignable_labels.key?(label) ? unassignable_labels[label] : label 61 | end 62 | end 63 | 64 | def invalid_label_message(issuer, invalid_labels) 65 | message = issuer == GithubService.bot_name ? "" : "@#{issuer} " 66 | message << "Cannot apply the following label" 67 | message << "s" if invalid_labels.length > 1 68 | message << " because they are not recognized:\n" 69 | 70 | labels = GithubService.labels(issue.fq_repo_name) 71 | invalid_labels.each do |bad_label| 72 | corrections = DidYouMean::SpellChecker.new(:dictionary => labels).correct(bad_label) 73 | possibilities = corrections.map { |l| "`#{l}`" }.join(", ") 74 | 75 | message << "* `#{bad_label}` " 76 | message << "(Did you mean? #{possibilities})" if corrections.any? 77 | message << "\n" 78 | end 79 | message << "\nAll labels for `#{issue.fq_repo_name}`: https://github.com/#{issue.fq_repo_name}/labels" 80 | end 81 | end 82 | end 83 | end 84 | 85 | # HACK: CI / `bundle install --path ...` compat 86 | begin 87 | retry_require_dym = false 88 | require 'did_you_mean' 89 | rescue LoadError => error 90 | if retry_require_dym 91 | raise error 92 | else 93 | $LOAD_PATH.push(*Dir[File.join(Gem.default_dir, "gems", "did_you_mean*", "lib")]) 94 | $LOAD_PATH.uniq! 95 | retry_require_dym = true 96 | retry 97 | end 98 | end 99 | --------------------------------------------------------------------------------