Commit c93986fe authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch '216875-use-timezones-for-danger-reviewer-roulette-implementation' into 'master'

Use timezones for Danger Reviewer Roulette implementation

Closes #216875

See merge request gitlab-org/gitlab!34862
parents f55d74fc 985317d2
...@@ -12,17 +12,19 @@ MARKDOWN ...@@ -12,17 +12,19 @@ MARKDOWN
CATEGORY_TABLE_HEADER = <<MARKDOWN CATEGORY_TABLE_HEADER = <<MARKDOWN
To spread load more evenly across eligible reviewers, Danger has randomly picked To spread load more evenly across eligible reviewers, Danger has picked a candidate for each
a candidate for each review slot. Feel free to review slot, based on their timezone. Feel free to
[override these selections](https://about.gitlab.com/handbook/engineering/projects/#gitlab) [override these selections](https://about.gitlab.com/handbook/engineering/projects/#gitlab)
if you think someone else would be better-suited, or the chosen person is unavailable. if you think someone else would be better-suited, or the chosen person is unavailable.
To read more on how to use the reviewer roulette, please take a look at the To read more on how to use the reviewer roulette, please take a look at the
[Engineering workflow](https://about.gitlab.com/handbook/engineering/workflow/#basics) [Engineering workflow](https://about.gitlab.com/handbook/engineering/workflow/#basics)
and [code review guidelines](https://docs.gitlab.com/ee/development/code_review.html). and [code review guidelines](https://docs.gitlab.com/ee/development/code_review.html).
Please consider assigning a reviewer or maintainer who is a
[domain expert](https://about.gitlab.com/handbook/engineering/projects/#gitlab) in the area of the merge request.
Once you've decided who will review this merge request, mention them as you Once you've decided who will review this merge request, mention them as you
normally would! Danger does not (yet?) automatically notify them for you. normally would! Danger does not automatically notify them for you.
| Category | Reviewer | Maintainer | | Category | Reviewer | Maintainer |
| -------- | -------- | ---------- | | -------- | -------- | ---------- |
...@@ -38,6 +40,7 @@ MARKDOWN ...@@ -38,6 +40,7 @@ MARKDOWN
OPTIONAL_REVIEW_TEMPLATE = "%{role} review is optional for %{category}".freeze OPTIONAL_REVIEW_TEMPLATE = "%{role} review is optional for %{category}".freeze
NOT_AVAILABLE_TEMPLATE = 'No %{role} available'.freeze NOT_AVAILABLE_TEMPLATE = 'No %{role} available'.freeze
TIMEZONE_EXPERIMENT = true
def mr_author def mr_author
roulette.team.find { |person| person.username == gitlab.mr_author } roulette.team.find { |person| person.username == gitlab.mr_author }
...@@ -48,7 +51,7 @@ def note_for_category_role(spin, role) ...@@ -48,7 +51,7 @@ def note_for_category_role(spin, role)
return OPTIONAL_REVIEW_TEMPLATE % { role: role.capitalize, category: helper.label_for_category(spin.category) } return OPTIONAL_REVIEW_TEMPLATE % { role: role.capitalize, category: helper.label_for_category(spin.category) }
end end
spin.public_send(role)&.markdown_name || NOT_AVAILABLE_TEMPLATE % { role: role } # rubocop:disable GitlabSecurity/PublicSend spin.public_send(role)&.markdown_name(timezone_experiment: TIMEZONE_EXPERIMENT, author: mr_author) || NOT_AVAILABLE_TEMPLATE % { role: role } # rubocop:disable GitlabSecurity/PublicSend
end end
def markdown_row_for_spin(spin) def markdown_row_for_spin(spin)
...@@ -73,7 +76,9 @@ if changes.any? ...@@ -73,7 +76,9 @@ if changes.any?
project = helper.project_name project = helper.project_name
branch_name = gitlab.mr_json['source_branch'] branch_name = gitlab.mr_json['source_branch']
roulette_spins = roulette.spin(project, categories, branch_name) markdown(MESSAGE)
roulette_spins = roulette.spin(project, categories, branch_name, timezone_experiment: TIMEZONE_EXPERIMENT)
rows = roulette_spins.map do |spin| rows = roulette_spins.map do |spin|
# MR includes QA changes, but also other changes, and author isn't an SET # MR includes QA changes, but also other changes, and author isn't an SET
if spin.category == :qa && categories.size > 1 && !mr_author.reviewer?(project, spin.category, []) if spin.category == :qa && categories.size > 1 && !mr_author.reviewer?(project, spin.category, [])
...@@ -85,9 +90,8 @@ if changes.any? ...@@ -85,9 +90,8 @@ if changes.any?
markdown_row_for_spin(spin) markdown_row_for_spin(spin)
end end
unknown = changes.fetch(:unknown, [])
markdown(MESSAGE)
markdown(CATEGORY_TABLE_HEADER + rows.join("\n")) unless rows.empty? markdown(CATEGORY_TABLE_HEADER + rows.join("\n")) unless rows.empty?
unknown = changes.fetch(:unknown, [])
markdown(UNKNOWN_FILES_MESSAGE + helper.markdown_list(unknown)) unless unknown.empty? markdown(UNKNOWN_FILES_MESSAGE + helper.markdown_list(unknown)) unless unknown.empty?
end end
...@@ -128,10 +128,18 @@ Here is a (non-exhaustive) list of the kinds of things Danger has been used for ...@@ -128,10 +128,18 @@ Here is a (non-exhaustive) list of the kinds of things Danger has been used for
at GitLab so far: at GitLab so far:
- Coding style - Coding style
- Database review workflow - Database review
- Documentation review workflow - Documentation review
- Merge request metrics - Merge request metrics
- Reviewer roulette workflow - Reviewer roulette. Reviewers and maintainers are chosen based on:
- Their roles (backend, frontend, database, etc).
- Their availability:
- No "OOO"/"PTO"/"Parental Leave" in their GitLab or Slack status.
- No `:red_circle:`/`:palm_tree:`/`:beach:`/`:beach_umbrella:`/`:beach_with_umbrella:` emojis in GitLab or Slack status.
- [Experimental] Their timezone: people for which the local hour is between
6 AM and 2 PM are eligible to be picked. This is to ensure they have a good
chance to get to perform a review during their current work day. The experimentation is tracked in
[this issue](https://gitlab.com/gitlab-org/quality/team-tasks/-/issues/563)
- Single codebase effort - Single codebase effort
## Limitations ## Limitations
......
...@@ -6,6 +6,7 @@ module Gitlab ...@@ -6,6 +6,7 @@ module Gitlab
module Danger module Danger
module Roulette module Roulette
ROULETTE_DATA_URL = 'https://gitlab-org.gitlab.io/gitlab-roulette/roulette.json' ROULETTE_DATA_URL = 'https://gitlab-org.gitlab.io/gitlab-roulette/roulette.json'
HOURS_WHEN_PERSON_CAN_BE_PICKED = (6..14).freeze
Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role) Spin = Struct.new(:category, :reviewer, :maintainer, :optional_role)
...@@ -13,7 +14,7 @@ module Gitlab ...@@ -13,7 +14,7 @@ module Gitlab
# for each change category that a Merge Request contains. # for each change category that a Merge Request contains.
# #
# @return [Array<Spin>] # @return [Array<Spin>]
def spin(project, categories, branch_name) def spin(project, categories, branch_name, timezone_experiment: false)
team = team =
begin begin
project_team(project) project_team(project)
...@@ -25,7 +26,7 @@ module Gitlab ...@@ -25,7 +26,7 @@ module Gitlab
canonical_branch_name = canonical_branch_name(branch_name) canonical_branch_name = canonical_branch_name(branch_name)
spin_per_category = categories.each_with_object({}) do |category, memo| spin_per_category = categories.each_with_object({}) do |category, memo|
memo[category] = spin_for_category(team, project, category, canonical_branch_name) memo[category] = spin_for_category(team, project, category, canonical_branch_name, timezone_experiment: timezone_experiment)
end end
spin_per_category.map do |category, spin| spin_per_category.map do |category, spin|
...@@ -79,9 +80,14 @@ module Gitlab ...@@ -79,9 +80,14 @@ module Gitlab
# Known issue: If someone is rejected due to OOO, and then becomes not OOO, the # Known issue: If someone is rejected due to OOO, and then becomes not OOO, the
# selection will change on next spin # selection will change on next spin
# @param [Array<Teammate>] people # @param [Array<Teammate>] people
def spin_for_person(people, random:) def spin_for_person(people, random:, timezone_experiment: false)
people.shuffle(random: random) shuffled_people = people.shuffle(random: random)
.find(&method(:valid_person?))
if timezone_experiment
shuffled_people.find(&method(:valid_person_with_timezone?))
else
shuffled_people.find(&method(:valid_person?))
end
end end
private private
...@@ -89,7 +95,13 @@ module Gitlab ...@@ -89,7 +95,13 @@ module Gitlab
# @param [Teammate] person # @param [Teammate] person
# @return [Boolean] # @return [Boolean]
def valid_person?(person) def valid_person?(person)
!mr_author?(person) && person.available && person.has_capacity !mr_author?(person) && person.available
end
# @param [Teammate] person
# @return [Boolean]
def valid_person_with_timezone?(person)
valid_person?(person) && HOURS_WHEN_PERSON_CAN_BE_PICKED.cover?(person.local_hour)
end end
# @param [Teammate] person # @param [Teammate] person
...@@ -104,7 +116,7 @@ module Gitlab ...@@ -104,7 +116,7 @@ module Gitlab
end end
end end
def spin_for_category(team, project, category, branch_name) def spin_for_category(team, project, category, branch_name, timezone_experiment: false)
reviewers, traintainers, maintainers = reviewers, traintainers, maintainers =
%i[reviewer traintainer maintainer].map do |role| %i[reviewer traintainer maintainer].map do |role|
spin_role_for_category(team, role, project, category) spin_role_for_category(team, role, project, category)
...@@ -115,8 +127,8 @@ module Gitlab ...@@ -115,8 +127,8 @@ module Gitlab
# Make traintainers have triple the chance to be picked as a reviewer # Make traintainers have triple the chance to be picked as a reviewer
random = new_random(branch_name) random = new_random(branch_name)
reviewer = spin_for_person(reviewers + traintainers + traintainers, random: random) reviewer = spin_for_person(reviewers + traintainers + traintainers, random: random, timezone_experiment: timezone_experiment)
maintainer = spin_for_person(maintainers, random: random) maintainer = spin_for_person(maintainers, random: random, timezone_experiment: timezone_experiment)
Spin.new(category, reviewer, maintainer) Spin.new(category, reviewer, maintainer)
end end
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
module Gitlab module Gitlab
module Danger module Danger
class Teammate class Teammate
attr_reader :username, :name, :markdown_name, :role, :projects, :available, :has_capacity attr_reader :username, :name, :role, :projects, :available, :tz_offset_hours
# The options data are produced by https://gitlab.com/gitlab-org/gitlab-roulette/-/blob/master/lib/team_member.rb # The options data are produced by https://gitlab.com/gitlab-org/gitlab-roulette/-/blob/master/lib/team_member.rb
def initialize(options = {}) def initialize(options = {})
...@@ -13,7 +13,7 @@ module Gitlab ...@@ -13,7 +13,7 @@ module Gitlab
@role = options['role'] @role = options['role']
@projects = options['projects'] @projects = options['projects']
@available = options['available'] @available = options['available']
@has_capacity = options['has_capacity'] @tz_offset_hours = options['tz_offset_hours']
end end
def in_project?(name) def in_project?(name)
...@@ -34,8 +34,48 @@ module Gitlab ...@@ -34,8 +34,48 @@ module Gitlab
has_capability?(project, category, :maintainer, labels) has_capability?(project, category, :maintainer, labels)
end end
def markdown_name(timezone_experiment: false, author: nil)
return @markdown_name unless timezone_experiment
"#{@markdown_name} (#{utc_offset_text(author)})"
end
def local_hour
(Time.now.utc + tz_offset_hours * 3600).hour
end
protected
def floored_offset_hours
floored_offset = tz_offset_hours.floor(0)
floored_offset == tz_offset_hours ? floored_offset : tz_offset_hours
end
private private
def utc_offset_text(author = nil)
offset_text =
if floored_offset_hours >= 0
"UTC+#{floored_offset_hours}"
else
"UTC#{floored_offset_hours}"
end
return offset_text unless author
"#{offset_text}, #{offset_diff_compared_to_author(author)}"
end
def offset_diff_compared_to_author(author)
diff = floored_offset_hours - author.floored_offset_hours
return "same timezone as `@#{author.username}`" if diff.zero?
ahead_or_behind = diff < 0 ? 'behind' : 'ahead'
"#{diff.abs} hours #{ahead_or_behind} `@#{author.username}`"
end
def has_capability?(project, category, kind, labels) def has_capability?(project, category, kind, labels)
case category case category
when :test when :test
......
This diff is collapsed.
...@@ -2,14 +2,27 @@ ...@@ -2,14 +2,27 @@
require 'fast_spec_helper' require 'fast_spec_helper'
require 'timecop'
require 'rspec-parameterized' require 'rspec-parameterized'
require 'gitlab/danger/teammate' require 'gitlab/danger/teammate'
RSpec.describe Gitlab::Danger::Teammate do RSpec.describe Gitlab::Danger::Teammate do
using RSpec::Parameterized::TableSyntax
subject { described_class.new(options.stringify_keys) } subject { described_class.new(options.stringify_keys) }
let(:options) { { username: 'luigi', projects: projects, role: role } } let(:tz_offset_hours) { 2.0 }
let(:options) do
{
username: 'luigi',
projects: projects,
role: role,
markdown_name: '[Luigi](https://gitlab.com/luigi) (`@luigi`)',
tz_offset_hours: tz_offset_hours
}
end
let(:capabilities) { ['reviewer backend'] }
let(:projects) { { project => capabilities } } let(:projects) { { project => capabilities } }
let(:role) { 'Engineer, Manage' } let(:role) { 'Engineer, Manage' }
let(:labels) { [] } let(:labels) { [] }
...@@ -114,4 +127,71 @@ RSpec.describe Gitlab::Danger::Teammate do ...@@ -114,4 +127,71 @@ RSpec.describe Gitlab::Danger::Teammate do
expect(subject.maintainer?(project, :frontend, labels)).to be_falsey expect(subject.maintainer?(project, :frontend, labels)).to be_falsey
end end
end end
describe '#local_hour' do
around do |example|
Timecop.freeze(Time.utc(2020, 6, 23, 10)) { example.run }
end
context 'when author is given' do
where(:tz_offset_hours, :expected_local_hour) do
-12 | 22
-10 | 0
2 | 12
4 | 14
12 | 22
end
with_them do
it 'returns the correct local_hour' do
expect(subject.local_hour).to eq(expected_local_hour)
end
end
end
end
describe '#markdown_name' do
context 'when timezone_experiment == false' do
it 'returns markdown name as-is' do
expect(subject.markdown_name).to eq(options[:markdown_name])
expect(subject.markdown_name(timezone_experiment: false)).to eq(options[:markdown_name])
end
end
context 'when timezone_experiment == true' do
it 'returns markdown name with timezone info' do
expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options[:markdown_name]} (UTC+2)")
end
context 'when offset is 1.5' do
let(:tz_offset_hours) { 1.5 }
it 'returns markdown name with timezone info, not truncated' do
expect(subject.markdown_name(timezone_experiment: true)).to eq("#{options[:markdown_name]} (UTC+1.5)")
end
end
context 'when author is given' do
where(:tz_offset_hours, :author_offset, :diff_text) do
-12 | -10 | "2 hours behind `@mario`"
-10 | -12 | "2 hours ahead `@mario`"
-10 | 2 | "12 hours behind `@mario`"
2 | 4 | "2 hours behind `@mario`"
4 | 2 | "2 hours ahead `@mario`"
2 | 2 | "same timezone as `@mario`"
end
with_them do
it 'returns markdown name with timezone info' do
author = described_class.new(options.merge(username: 'mario', tz_offset_hours: author_offset).stringify_keys)
floored_offset_hours = subject.__send__(:floored_offset_hours)
utc_offset = floored_offset_hours >= 0 ? "+#{floored_offset_hours}" : floored_offset_hours
expect(subject.markdown_name(timezone_experiment: true, author: author)).to eq("#{options[:markdown_name]} (UTC#{utc_offset}, #{diff_text})")
end
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