Commit a511a122 authored by Douwe Maan's avatar Douwe Maan

Merge branch...

Merge branch '17227-upcoming-milestone-is-confusing-when-projects-have-different-milestones' into 'master'

Make upcoming milestone work across projects

Before: we took the next milestone due across all projects in the
search and found issues whose milestone title matched that
one. Problems:

1. The milestone could be closed.
2. Different projects have milestones with different schedules.
3. Different projects have milestones with different titles.
4. Different projects can have milestones with different schedules, but
   the _same_ title. That means we could show issues from a past
   milestone, or one that's far in the future.

After: gather the ID of the next milestone on each project we're looking
at, and find issues with those milestone IDs. Problems:

1. For a lot of projects, this can return a lot of IDs.
2. The SQL query has to be different between Postgres and MySQL, because
   MySQL is much more lenient with HAVING: as well as the columns
   appearing in GROUP BY or in aggregate clauses, MySQL allows them to
   appear in the SELECT list (un-aggregated).

Closes #17227.

See merge request !4125
parents f2343889 e8058bd2
...@@ -15,6 +15,7 @@ v 8.8.0 (unreleased) ...@@ -15,6 +15,7 @@ v 8.8.0 (unreleased)
- Make build status canceled if any of the jobs was canceled and none failed - Make build status canceled if any of the jobs was canceled and none failed
- Upgrade Sidekiq to 4.1.2 - Upgrade Sidekiq to 4.1.2
- Added /health_check endpoint for checking service status - Added /health_check endpoint for checking service status
- Make 'upcoming' filter for milestones work better across projects
- Sanitize repo paths in new project error message - Sanitize repo paths in new project error message
- Bump mail_room to 0.7.0 to fix stuck IDLE connections - Bump mail_room to 0.7.0 to fix stuck IDLE connections
- Remove future dates from contribution calendar graph. - Remove future dates from contribution calendar graph.
......
...@@ -252,8 +252,8 @@ class IssuableFinder ...@@ -252,8 +252,8 @@ class IssuableFinder
if filter_by_no_milestone? if filter_by_no_milestone?
items = items.where(milestone_id: [-1, nil]) items = items.where(milestone_id: [-1, nil])
elsif filter_by_upcoming_milestone? elsif filter_by_upcoming_milestone?
upcoming = Milestone.where(project_id: projects).upcoming upcoming_ids = Milestone.upcoming_ids_by_projects(projects)
items = items.joins(:milestone).where(milestones: { title: upcoming.try(:title) }) items = items.joins(:milestone).where(milestone_id: upcoming_ids)
else else
items = items.joins(:milestone).where(milestones: { title: params[:milestone_title] }) items = items.joins(:milestone).where(milestones: { title: params[:milestone_title] })
......
...@@ -67,8 +67,18 @@ class Milestone < ActiveRecord::Base ...@@ -67,8 +67,18 @@ class Milestone < ActiveRecord::Base
@link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/) @link_reference_pattern ||= super("milestones", /(?<milestone>\d+)/)
end end
def self.upcoming def self.upcoming_ids_by_projects(projects)
self.where('due_date > ?', Time.now).reorder(due_date: :asc).first rel = unscoped.of_projects(projects).active.where('due_date > ?', Time.now)
if Gitlab::Database.postgresql?
rel.order(:project_id, :due_date).select('DISTINCT ON (project_id) id')
else
rel.
group(:project_id).
having('due_date = MIN(due_date)').
pluck(:id, :project_id, :due_date).
map(&:first)
end
end end
def to_reference(from_project = nil) def to_reference(from_project = nil)
......
require 'spec_helper' require 'spec_helper'
describe IssuesFinder do describe IssuesFinder do
let(:user) { create :user } let(:user) { create(:user) }
let(:user2) { create :user } let(:user2) { create(:user) }
let(:project1) { create(:project) } let(:project1) { create(:empty_project) }
let(:project2) { create(:project) } let(:project2) { create(:empty_project) }
let(:milestone) { create(:milestone, project: project1) } let(:milestone) { create(:milestone, project: project1) }
let(:label) { create(:label, project: project2) } let(:label) { create(:label, project: project2) }
let(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone) } let(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone) }
...@@ -16,101 +16,147 @@ describe IssuesFinder do ...@@ -16,101 +16,147 @@ describe IssuesFinder do
project1.team << [user, :master] project1.team << [user, :master]
project2.team << [user, :developer] project2.team << [user, :developer]
project2.team << [user2, :developer] project2.team << [user2, :developer]
issue1
issue2
issue3
end end
describe :execute do describe '#execute' do
before :each do let(:search_user) { user }
issue1 let(:params) { {} }
issue2 let(:issues) { IssuesFinder.new(search_user, params.merge(scope: scope, state: 'opened')).execute }
issue3
end
context 'scope: all' do context 'scope: all' do
it 'should filter by all' do let(:scope) { 'all' }
params = { scope: "all", state: 'opened' }
issues = IssuesFinder.new(user, params).execute it 'returns all issues' do
expect(issues.size).to eq(3) expect(issues).to contain_exactly(issue1, issue2, issue3)
end end
it 'should filter by assignee id' do context 'filtering by assignee ID' do
params = { scope: "all", assignee_id: user.id, state: 'opened' } let(:params) { { assignee_id: user.id } }
issues = IssuesFinder.new(user, params).execute
expect(issues.size).to eq(2) it 'returns issues assigned to that user' do
expect(issues).to contain_exactly(issue1, issue2)
end
end end
it 'should filter by author id' do context 'filtering by author ID' do
params = { scope: "all", author_id: user2.id, state: 'opened' } let(:params) { { author_id: user2.id } }
issues = IssuesFinder.new(user, params).execute
expect(issues).to eq([issue3]) it 'returns issues created by that user' do
expect(issues).to contain_exactly(issue3)
end
end end
it 'should filter by milestone id' do context 'filtering by milestone' do
params = { scope: "all", milestone_title: milestone.title, state: 'opened' } let(:params) { { milestone_title: milestone.title } }
issues = IssuesFinder.new(user, params).execute
expect(issues).to eq([issue1]) it 'returns issues assigned to that milestone' do
expect(issues).to contain_exactly(issue1)
end
end end
it 'should filter by no milestone id' do context 'filtering by no milestone' do
params = { scope: "all", milestone_title: Milestone::None.title, state: 'opened' } let(:params) { { milestone_title: Milestone::None.title } }
issues = IssuesFinder.new(user, params).execute
expect(issues).to match_array([issue2, issue3]) it 'returns issues with no milestone' do
expect(issues).to contain_exactly(issue2, issue3)
end
end end
it 'should filter by label name' do context 'filtering by upcoming milestone' do
params = { scope: "all", label_name: label.title, state: 'opened' } let(:params) { { milestone_title: Milestone::Upcoming.name } }
issues = IssuesFinder.new(user, params).execute
expect(issues).to eq([issue2]) let(:project_no_upcoming_milestones) { create(:empty_project, :public) }
let(:project_next_1_1) { create(:empty_project, :public) }
let(:project_next_8_8) { create(:empty_project, :public) }
let(:yesterday) { Date.today - 1.day }
let(:tomorrow) { Date.today + 1.day }
let(:two_days_from_now) { Date.today + 2.days }
let(:ten_days_from_now) { Date.today + 10.days }
let(:milestones) do
[
create(:milestone, :closed, project: project_no_upcoming_milestones),
create(:milestone, project: project_next_1_1, title: '1.1', due_date: two_days_from_now),
create(:milestone, project: project_next_1_1, title: '8.8', due_date: ten_days_from_now),
create(:milestone, project: project_next_8_8, title: '1.1', due_date: yesterday),
create(:milestone, project: project_next_8_8, title: '8.8', due_date: tomorrow)
]
end
before do
milestones.each do |milestone|
create(:issue, project: milestone.project, milestone: milestone, author: user, assignee: user)
end
end
it 'returns issues in the upcoming milestone for each project' do
expect(issues.map { |issue| issue.milestone.title }).to contain_exactly('1.1', '8.8')
expect(issues.map { |issue| issue.milestone.due_date }).to contain_exactly(tomorrow, two_days_from_now)
end
end end
it 'returns unique issues when filtering by multiple labels' do context 'filtering by label' do
label2 = create(:label, project: project2) let(:params) { { label_name: label.title } }
create(:label_link, label: label2, target: issue2) it 'returns issues with that label' do
expect(issues).to contain_exactly(issue2)
end
end
params = { context 'filtering by multiple labels' do
scope: 'all', let(:params) { { label_name: [label.title, label2.title].join(',') } }
label_name: [label.title, label2.title].join(','), let(:label2) { create(:label, project: project2) }
state: 'opened'
}
issues = IssuesFinder.new(user, params).execute before { create(:label_link, label: label2, target: issue2) }
expect(issues).to eq([issue2]) it 'returns the unique issues with any of those labels' do
expect(issues).to contain_exactly(issue2)
end
end end
it 'should filter by no label name' do context 'filtering by no label' do
params = { scope: "all", label_name: Label::None.title, state: 'opened' } let(:params) { { label_name: Label::None.title } }
issues = IssuesFinder.new(user, params).execute
expect(issues).to match_array([issue1, issue3]) it 'returns issues with no labels' do
expect(issues).to contain_exactly(issue1, issue3)
end
end end
it 'should be empty for unauthorized user' do context 'when the user is unauthorized' do
params = { scope: "all", state: 'opened' } let(:search_user) { nil }
issues = IssuesFinder.new(nil, params).execute
expect(issues.size).to be_zero it 'returns no results' do
expect(issues).to be_empty
end
end end
it 'should not include unauthorized issues' do context 'when the user can see some, but not all, issues' do
params = { scope: "all", state: 'opened' } let(:search_user) { user2 }
issues = IssuesFinder.new(user2, params).execute
expect(issues.size).to eq(2) it 'returns only issues they can see' do
expect(issues).not_to include(issue1) expect(issues).to contain_exactly(issue2, issue3)
expect(issues).to include(issue2) end
expect(issues).to include(issue3)
end end
end end
context 'personal scope' do context 'personal scope' do
it 'should filter by assignee' do let(:scope) { 'assigned-to-me' }
params = { scope: "assigned-to-me", state: 'opened' }
issues = IssuesFinder.new(user, params).execute it 'returns issue assigned to the user' do
expect(issues.size).to eq(2) expect(issues).to contain_exactly(issue1, issue2)
end end
it 'should filter by project' do context 'filtering by project' do
params = { scope: "assigned-to-me", state: 'opened', project_id: project1.id } let(:params) { { project_id: project1.id } }
issues = IssuesFinder.new(user, params).execute
expect(issues.size).to eq(1) it 'returns issues assigned to the user in that project' do
expect(issues).to contain_exactly(issue1)
end
end end
end end
end end
......
...@@ -204,4 +204,37 @@ describe Milestone, models: true do ...@@ -204,4 +204,37 @@ describe Milestone, models: true do
to eq([milestone]) to eq([milestone])
end end
end end
describe '.upcoming_ids_by_projects' do
let(:project_1) { create(:empty_project) }
let(:project_2) { create(:empty_project) }
let(:project_3) { create(:empty_project) }
let(:projects) { [project_1, project_2, project_3] }
let!(:past_milestone_project_1) { create(:milestone, project: project_1, due_date: Time.now - 1.day) }
let!(:current_milestone_project_1) { create(:milestone, project: project_1, due_date: Time.now + 1.day) }
let!(:future_milestone_project_1) { create(:milestone, project: project_1, due_date: Time.now + 2.days) }
let!(:past_milestone_project_2) { create(:milestone, project: project_2, due_date: Time.now - 1.day) }
let!(:closed_milestone_project_2) { create(:milestone, :closed, project: project_2, due_date: Time.now + 1.day) }
let!(:current_milestone_project_2) { create(:milestone, project: project_2, due_date: Time.now + 2.days) }
let!(:past_milestone_project_3) { create(:milestone, project: project_3, due_date: Time.now - 1.day) }
# The call to `#try` is because this returns a relation with a Postgres DB,
# and an array of IDs with a MySQL DB.
let(:milestone_ids) { Milestone.upcoming_ids_by_projects(projects).map { |id| id.try(:id) || id } }
it 'returns the next upcoming open milestone ID for each project' do
expect(milestone_ids).to contain_exactly(current_milestone_project_1.id, current_milestone_project_2.id)
end
context 'when the projects have no open upcoming milestones' do
let(:projects) { [project_3] }
it 'returns no results' do
expect(milestone_ids).to be_empty
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