Commit c5ec8f98 authored by Nick Thomas's avatar Nick Thomas

Merge branch 'extract-danger-roulette' into 'master'

Make single codebase roulette respect OOO status

See merge request gitlab-org/gitlab-ce!28877
parents 84a39659 ceb0636c
# frozen_string_literal: true # frozen_string_literal: true
danger.import_plugin('danger/plugins/helper.rb') danger.import_plugin('danger/plugins/helper.rb')
danger.import_plugin('danger/plugins/roulette.rb')
unless helper.release_automation? unless helper.release_automation?
danger.import_dangerfile(path: 'danger/metadata') danger.import_dangerfile(path: 'danger/metadata')
......
# frozen_string_literal: true # frozen_string_literal: true
require 'net/http'
require 'yaml'
require_relative '../../lib/gitlab/danger/helper' require_relative '../../lib/gitlab/danger/helper'
module Danger module Danger
......
# frozen_string_literal: true
require_relative '../../lib/gitlab/danger/roulette'
module Danger
class Roulette < Plugin
# Put the helper code somewhere it can be tested
include Gitlab::Danger::Roulette
end
end
...@@ -32,7 +32,7 @@ for them. ...@@ -32,7 +32,7 @@ for them.
MARKDOWN MARKDOWN
def spin_for_category(team, project, category, branch_name) def spin_for_category(team, project, category, branch_name)
rng = Random.new(Digest::MD5.hexdigest(branch_name).to_i(16)) random = roulette.new_random(branch_name)
reviewers = team.select { |member| member.reviewer?(project, category) } reviewers = team.select { |member| member.reviewer?(project, category) }
traintainers = team.select { |member| member.traintainer?(project, category) } traintainers = team.select { |member| member.traintainer?(project, category) }
...@@ -42,43 +42,12 @@ def spin_for_category(team, project, category, branch_name) ...@@ -42,43 +42,12 @@ def spin_for_category(team, project, category, branch_name)
# https://gitlab.com/gitlab-org/gitlab-ce/issues/57653 # https://gitlab.com/gitlab-org/gitlab-ce/issues/57653
# Make traintainers have triple the chance to be picked as a reviewer # Make traintainers have triple the chance to be picked as a reviewer
reviewer = spin_for_person(reviewers + traintainers + traintainers, random: rng) reviewer = roulette.spin_for_person(reviewers + traintainers + traintainers, random: random)
maintainer = spin_for_person(maintainers, random: rng) maintainer = roulette.spin_for_person(maintainers, random: random)
"| #{helper.label_for_category(category)} | #{reviewer&.markdown_name} | #{maintainer&.markdown_name} |" "| #{helper.label_for_category(category)} | #{reviewer&.markdown_name} | #{maintainer&.markdown_name} |"
end end
# Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
# selection will change on next spin
def spin_for_person(people, random:)
person = nil
people = people.dup
people.size.times do
person = people.sample(random: random)
break person unless out_of_office?(person)
people -= [person]
end
person
end
def out_of_office?(person)
username = CGI.escape(person.username)
api_endpoint = "https://gitlab.com/api/v4/users/#{username}/status"
response = HTTParty.get(api_endpoint) # rubocop:disable Gitlab/HTTParty
if response.code == 200
response["message"]&.match(/OOO/i)
else
false # this is no worse than not checking for OOO
end
rescue
false
end
def build_list(items) def build_list(items)
list = items.map { |filename| "* `#{filename}`" }.join("\n") list = items.map { |filename| "* `#{filename}`" }.join("\n")
...@@ -101,14 +70,12 @@ categories = changes.keys - [:unknown] ...@@ -101,14 +70,12 @@ categories = changes.keys - [:unknown]
# disable the review roulette for such MRs. # disable the review roulette for such MRs.
if changes.any? && !gitlab.mr_labels.include?('single codebase') && !gitlab.mr_labels.include?('CSS cleanup') if changes.any? && !gitlab.mr_labels.include?('single codebase') && !gitlab.mr_labels.include?('CSS cleanup')
# Strip leading and trailing CE/EE markers # Strip leading and trailing CE/EE markers
canonical_branch_name = gitlab canonical_branch_name =
.mr_json['source_branch'] roulette.canonical_branch_name(gitlab.mr_json['source_branch'])
.gsub(/^[ce]e-/, '')
.gsub(/-[ce]e$/, '')
team = team =
begin begin
helper.project_team roulette.project_team(helper.project_name)
rescue => err rescue => err
warn("Reviewer roulette failed to load team data: #{err.message}") warn("Reviewer roulette failed to load team data: #{err.message}")
[] []
......
def new_teammates(usernames)
usernames.map { |u| ::Gitlab::Danger::Teammate.new('username' => u) }
end
def mention_single_codebase_approvers def mention_single_codebase_approvers
frontend_maintainers = %w(@filipa @iamphill) canonical_branch_name =
backend_maintainers = %w(@rspeicher @rymai @yorickpeterse @godfat) roulette.canonical_branch_name(gitlab.mr_json['source_branch'])
random = roulette.new_random(canonical_branch_name)
frontend_maintainers = new_teammates(%w[filipa iamphill])
backend_maintainers = new_teammates(%w[rspeicher rymai yorickpeterse godfat])
rows = [] rows = []
users = []
if gitlab.mr_labels.include?('frontend') if gitlab.mr_labels.include?('frontend')
frontend_maintainer = frontend_maintainers.sample frontend_maintainer =
roulette.spin_for_person(frontend_maintainers, random: random)
rows << "| ~frontend | `#{frontend_maintainer}`" rows << "| ~frontend | #{frontend_maintainer.markdown_name}"
users << frontend_maintainer
end end
if gitlab.mr_labels.include?('backend') if gitlab.mr_labels.include?('backend')
backend_maintainer = backend_maintainers.sample backend_maintainer =
roulette.spin_for_person(backend_maintainers, random: random)
rows << "| ~backend | `#{backend_maintainer}`" rows << "| ~backend | #{backend_maintainer.markdown_name}"
users << backend_maintainer
end end
if rows.empty? if rows.empty?
backup_maintainer = backend_maintainers.sample backup_maintainer = backend_maintainers.sample
rows << "| ~frontend / ~backend | `#{backup_maintainer}`" rows << "| ~frontend / ~backend | #{backup_maintainer.markdown_name}"
users << backup_maintainer
end end
markdown(<<~MARKDOWN.strip) markdown(<<~MARKDOWN.strip)
......
# frozen_string_literal: true # frozen_string_literal: true
require 'net/http'
require 'json'
require_relative 'teammate' require_relative 'teammate'
...@@ -8,7 +6,6 @@ module Gitlab ...@@ -8,7 +6,6 @@ module Gitlab
module Danger module Danger
module Helper module Helper
RELEASE_TOOLS_BOT = 'gitlab-release-tools-bot' RELEASE_TOOLS_BOT = 'gitlab-release-tools-bot'
ROULETTE_DATA_URL = URI.parse('https://about.gitlab.com/roulette.json').freeze
# Returns a list of all files that have been added, modified or renamed. # Returns a list of all files that have been added, modified or renamed.
# `git.modified_files` might contain paths that already have been renamed, # `git.modified_files` might contain paths that already have been renamed,
...@@ -49,32 +46,6 @@ module Gitlab ...@@ -49,32 +46,6 @@ module Gitlab
ee? ? 'gitlab-ee' : 'gitlab-ce' ee? ? 'gitlab-ee' : 'gitlab-ce'
end end
# Looks up the current list of GitLab team members and parses it into a
# useful form
#
# @return [Array<Teammate>]
def team
@team ||=
begin
rsp = Net::HTTP.get_response(ROULETTE_DATA_URL)
raise "Failed to read #{ROULETTE_DATA_URL}: #{rsp.code} #{rsp.message}" unless
rsp.is_a?(Net::HTTPSuccess)
data = JSON.parse(rsp.body)
data.map { |hash| ::Gitlab::Danger::Teammate.new(hash) }
rescue JSON::ParserError
raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
end
end
# Like +team+, but only returns teammates in the current project, based on
# project_name.
#
# @return [Array<Teammate>]
def project_team
team.select { |member| member.in_project?(project_name) }
end
# @return [Hash<String,Array<String>>] # @return [Hash<String,Array<String>>]
def changes_by_category def changes_by_category
all_changed_files.each_with_object(Hash.new { |h, k| h[k] = [] }) do |file, hash| all_changed_files.each_with_object(Hash.new { |h, k| h[k] = [] }) do |file, hash|
......
# frozen_string_literal: true
require 'net/http'
require 'json'
require 'cgi'
require_relative 'teammate'
module Gitlab
module Danger
module Roulette
ROULETTE_DATA_URL = 'https://about.gitlab.com/roulette.json'
HTTPError = Class.new(RuntimeError)
# Looks up the current list of GitLab team members and parses it into a
# useful form
#
# @return [Array<Teammate>]
def team
@team ||=
begin
data = http_get_json(ROULETTE_DATA_URL)
data.map { |hash| ::Gitlab::Danger::Teammate.new(hash) }
rescue JSON::ParserError
raise "Failed to parse JSON response from #{ROULETTE_DATA_URL}"
end
end
# Like +team+, but only returns teammates in the current project, based on
# project_name.
#
# @return [Array<Teammate>]
def project_team(project_name)
team.select { |member| member.in_project?(project_name) }
end
def canonical_branch_name(branch_name)
branch_name.gsub(/^[ce]e-|-[ce]e$/, '')
end
def new_random(seed)
Random.new(Digest::MD5.hexdigest(seed).to_i(16))
end
# Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
# selection will change on next spin
def spin_for_person(people, random:)
person = nil
people = people.dup
people.size.times do
person = people.sample(random: random)
break person unless out_of_office?(person)
people -= [person]
end
person
end
private
def out_of_office?(person)
username = CGI.escape(person.username)
api_endpoint = "https://gitlab.com/api/v4/users/#{username}/status"
response = http_get_json(api_endpoint)
response["message"]&.match?(/OOO/i)
rescue HTTPError, JSON::ParserError
false # this is no worse than not checking for OOO
end
def http_get_json(url)
rsp = Net::HTTP.get_response(URI.parse(url))
unless rsp.is_a?(Net::HTTPSuccess)
raise HTTPError, "Failed to read #{url}: #{rsp.code} #{rsp.message}"
end
JSON.parse(rsp.body)
end
end
end
end
...@@ -6,8 +6,8 @@ module Gitlab ...@@ -6,8 +6,8 @@ module Gitlab
attr_reader :name, :username, :projects attr_reader :name, :username, :projects
def initialize(options = {}) def initialize(options = {})
@name = options['name']
@username = options['username'] @username = options['username']
@name = options['name'] || @username
@projects = options['projects'] @projects = options['projects']
end end
......
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
require 'fast_spec_helper' require 'fast_spec_helper'
require 'rspec-parameterized' require 'rspec-parameterized'
require 'webmock/rspec'
require 'gitlab/danger/helper' require 'gitlab/danger/helper'
...@@ -19,39 +18,6 @@ describe Gitlab::Danger::Helper do ...@@ -19,39 +18,6 @@ describe Gitlab::Danger::Helper do
end end
end end
let(:teammate_json) do
<<~JSON
[
{
"username": "in-gitlab-ce",
"name": "CE maintainer",
"projects":{ "gitlab-ce": "maintainer backend" }
},
{
"username": "in-gitlab-ee",
"name": "EE reviewer",
"projects":{ "gitlab-ee": "reviewer frontend" }
}
]
JSON
end
let(:ce_teammate_matcher) do
satisfy do |teammate|
teammate.username == 'in-gitlab-ce' &&
teammate.name == 'CE maintainer' &&
teammate.projects == { 'gitlab-ce' => 'maintainer backend' }
end
end
let(:ee_teammate_matcher) do
satisfy do |teammate|
teammate.username == 'in-gitlab-ee' &&
teammate.name == 'EE reviewer' &&
teammate.projects == { 'gitlab-ee' => 'reviewer frontend' }
end
end
let(:fake_git) { double('fake-git') } let(:fake_git) { double('fake-git') }
subject(:helper) { FakeDanger.new(git: fake_git) } subject(:helper) { FakeDanger.new(git: fake_git) }
...@@ -119,69 +85,6 @@ describe Gitlab::Danger::Helper do ...@@ -119,69 +85,6 @@ describe Gitlab::Danger::Helper do
end end
end end
describe '#team' do
subject(:team) { helper.team }
context 'HTTP failure' do
before do
WebMock
.stub_request(:get, 'https://about.gitlab.com/roulette.json')
.to_return(status: 404)
end
it 'raises a pretty error' do
expect { team }.to raise_error(/Failed to read/)
end
end
context 'JSON failure' do
before do
WebMock
.stub_request(:get, 'https://about.gitlab.com/roulette.json')
.to_return(body: 'INVALID JSON')
end
it 'raises a pretty error' do
expect { team }.to raise_error(/Failed to parse/)
end
end
context 'success' do
before do
WebMock
.stub_request(:get, 'https://about.gitlab.com/roulette.json')
.to_return(body: teammate_json)
end
it 'returns an array of teammates' do
is_expected.to contain_exactly(ce_teammate_matcher, ee_teammate_matcher)
end
it 'memoizes the result' do
expect(team.object_id).to eq(helper.team.object_id)
end
end
end
describe '#project_team' do
subject { helper.project_team }
before do
WebMock
.stub_request(:get, 'https://about.gitlab.com/roulette.json')
.to_return(body: teammate_json)
end
it 'filters team by project_name' do
expect(helper)
.to receive(:project_name)
.at_least(:once)
.and_return('gitlab-ce')
is_expected.to contain_exactly(ce_teammate_matcher)
end
end
describe '#changes_by_category' do describe '#changes_by_category' do
it 'categorizes changed files' do it 'categorizes changed files' do
expect(fake_git).to receive(:added_files) { %w[foo foo.md foo.rb foo.js db/foo qa/foo ee/changelogs/foo.yml] } expect(fake_git).to receive(:added_files) { %w[foo foo.md foo.rb foo.js db/foo qa/foo ee/changelogs/foo.yml] }
......
# frozen_string_literal: true
require 'fast_spec_helper'
require 'webmock/rspec'
require 'gitlab/danger/roulette'
describe Gitlab::Danger::Roulette do
let(:teammate_json) do
<<~JSON
[
{
"username": "in-gitlab-ce",
"name": "CE maintainer",
"projects":{ "gitlab-ce": "maintainer backend" }
},
{
"username": "in-gitlab-ee",
"name": "EE reviewer",
"projects":{ "gitlab-ee": "reviewer frontend" }
}
]
JSON
end
let(:ce_teammate_matcher) do
satisfy do |teammate|
teammate.username == 'in-gitlab-ce' &&
teammate.name == 'CE maintainer' &&
teammate.projects == { 'gitlab-ce' => 'maintainer backend' }
end
end
let(:ee_teammate_matcher) do
satisfy do |teammate|
teammate.username == 'in-gitlab-ee' &&
teammate.name == 'EE reviewer' &&
teammate.projects == { 'gitlab-ee' => 'reviewer frontend' }
end
end
subject(:roulette) { Object.new.extend(described_class) }
describe '#team' do
subject(:team) { roulette.team }
context 'HTTP failure' do
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(status: 404)
end
it 'raises a pretty error' do
expect { team }.to raise_error(/Failed to read/)
end
end
context 'JSON failure' do
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(body: 'INVALID JSON')
end
it 'raises a pretty error' do
expect { team }.to raise_error(/Failed to parse/)
end
end
context 'success' do
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(body: teammate_json)
end
it 'returns an array of teammates' do
is_expected.to contain_exactly(ce_teammate_matcher, ee_teammate_matcher)
end
it 'memoizes the result' do
expect(team.object_id).to eq(roulette.team.object_id)
end
end
end
describe '#project_team' do
subject { roulette.project_team('gitlab-ce') }
before do
WebMock
.stub_request(:get, described_class::ROULETTE_DATA_URL)
.to_return(body: teammate_json)
end
it 'filters team by project_name' do
is_expected.to contain_exactly(ce_teammate_matcher)
end
end
end
# frozen_string_literal: true # frozen_string_literal: true
require 'fast_spec_helper'
require 'gitlab/danger/teammate'
describe Gitlab::Danger::Teammate do describe Gitlab::Danger::Teammate do
subject { described_class.new({ 'projects' => projects }) } subject { described_class.new({ 'projects' => projects }) }
let(:projects) { { project => capabilities } } let(:projects) { { project => capabilities } }
...@@ -9,15 +13,15 @@ describe Gitlab::Danger::Teammate do ...@@ -9,15 +13,15 @@ describe Gitlab::Danger::Teammate do
let(:capabilities) { ['reviewer backend', 'maintainer frontend', 'trainee_maintainer database'] } let(:capabilities) { ['reviewer backend', 'maintainer frontend', 'trainee_maintainer database'] }
it '#reviewer? supports multiple roles per project' do it '#reviewer? supports multiple roles per project' do
expect(subject.reviewer?(project, 'backend')).to be_truthy expect(subject.reviewer?(project, :backend)).to be_truthy
end end
it '#traintainer? supports multiple roles per project' do it '#traintainer? supports multiple roles per project' do
expect(subject.traintainer?(project, 'database')).to be_truthy expect(subject.traintainer?(project, :database)).to be_truthy
end end
it '#maintainer? supports multiple roles per project' do it '#maintainer? supports multiple roles per project' do
expect(subject.maintainer?(project, 'frontend')).to be_truthy expect(subject.maintainer?(project, :frontend)).to be_truthy
end end
end end
...@@ -25,15 +29,15 @@ describe Gitlab::Danger::Teammate do ...@@ -25,15 +29,15 @@ describe Gitlab::Danger::Teammate do
let(:capabilities) { 'reviewer backend' } let(:capabilities) { 'reviewer backend' }
it '#reviewer? supports one role per project' do it '#reviewer? supports one role per project' do
expect(subject.reviewer?(project, 'backend')).to be_truthy expect(subject.reviewer?(project, :backend)).to be_truthy
end end
it '#traintainer? supports one role per project' do it '#traintainer? supports one role per project' do
expect(subject.traintainer?(project, 'database')).to be_falsey expect(subject.traintainer?(project, :database)).to be_falsey
end end
it '#maintainer? supports one role per project' do it '#maintainer? supports one role per project' do
expect(subject.maintainer?(project, 'frontend')).to be_falsey expect(subject.maintainer?(project, :frontend)).to be_falsey
end end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment