Commit b8f0f1b1 authored by Andreas Brandl's avatar Andreas Brandl

Merge branch '66986-milestone-release-many-to-many' into 'master'

Switched Milestone and Release to many-to-many relationships

See merge request gitlab-org/gitlab!16517
parents 97f85fd6 233c708d
...@@ -27,11 +27,8 @@ class Milestone < ApplicationRecord ...@@ -27,11 +27,8 @@ class Milestone < ApplicationRecord
belongs_to :project belongs_to :project
belongs_to :group belongs_to :group
# A one-to-one relationship is set up here as part of a MVC: https://gitlab.com/gitlab-org/gitlab-ce/issues/62402 has_many :milestone_releases
# However, on the long term, we will want a many-to-many relationship between Release and Milestone. has_many :releases, through: :milestone_releases
# The "has_one through" allows us today to set up this one-to-one relationship while setting up the architecture for the long-term (ie intermediate table).
has_one :milestone_release
has_one :release, through: :milestone_release
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) } has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) } has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
...@@ -68,7 +65,7 @@ class Milestone < ApplicationRecord ...@@ -68,7 +65,7 @@ class Milestone < ApplicationRecord
validate :milestone_type_check validate :milestone_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
validate :dates_within_4_digits validate :dates_within_4_digits
validates_associated :milestone_release, message: -> (_, obj) { obj[:value].errors.full_messages.join(",") } validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
strip_attributes :title strip_attributes :title
......
...@@ -4,9 +4,11 @@ class MilestoneRelease < ApplicationRecord ...@@ -4,9 +4,11 @@ class MilestoneRelease < ApplicationRecord
belongs_to :milestone belongs_to :milestone
belongs_to :release belongs_to :release
validates :milestone_id, uniqueness: { scope: [:release_id] }
validate :same_project_between_milestone_and_release validate :same_project_between_milestone_and_release
# Keep until 2019-11-29
self.ignored_columns += %i[id]
private private
def same_project_between_milestone_and_release def same_project_between_milestone_and_release
......
...@@ -12,11 +12,8 @@ class Release < ApplicationRecord ...@@ -12,11 +12,8 @@ class Release < ApplicationRecord
has_many :links, class_name: 'Releases::Link' has_many :links, class_name: 'Releases::Link'
# A one-to-one relationship is set up here as part of a MVC: https://gitlab.com/gitlab-org/gitlab-ce/issues/62402 has_many :milestone_releases
# However, on the long term, we will want a many-to-many relationship between Release and Milestone. has_many :milestones, through: :milestone_releases
# The "has_one through" allows us today to set up this one-to-one relationship while setting up the architecture for the long-term (ie intermediate table).
has_one :milestone_release
has_one :milestone, through: :milestone_release
default_value_for :released_at, allows_nil: false do default_value_for :released_at, allows_nil: false do
Time.zone.now Time.zone.now
...@@ -26,7 +23,7 @@ class Release < ApplicationRecord ...@@ -26,7 +23,7 @@ class Release < ApplicationRecord
validates :description, :project, :tag, presence: true validates :description, :project, :tag, presence: true
validates :name, presence: true, on: :create validates :name, presence: true, on: :create
validates_associated :milestone_release, message: -> (_, obj) { obj[:value].errors.full_messages.join(",") } validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
scope :sorted, -> { order(released_at: :desc) } scope :sorted, -> { order(released_at: :desc) }
......
...@@ -48,25 +48,29 @@ module Releases ...@@ -48,25 +48,29 @@ module Releases
end end
end end
def milestone def milestones
return unless params[:milestone] return [] unless param_for_milestone_titles_provided?
strong_memoize(:milestone) do strong_memoize(:milestones) do
MilestonesFinder.new( MilestonesFinder.new(
project: project, project: project,
current_user: current_user, current_user: current_user,
project_ids: Array(project.id), project_ids: Array(project.id),
title: params[:milestone] state: 'all',
).execute.first title: params[:milestones]
).execute
end end
end end
def inexistent_milestone? def inexistent_milestones
params[:milestone] && !params[:milestone].empty? && !milestone return [] unless param_for_milestone_titles_provided?
existing_milestone_titles = milestones.map(&:title)
Array(params[:milestones]) - existing_milestone_titles
end end
def param_for_milestone_title_provided? def param_for_milestone_titles_provided?
params[:milestone].present? || params[:milestone]&.empty? params.key?(:milestones)
end end
end end
end end
......
...@@ -7,7 +7,7 @@ module Releases ...@@ -7,7 +7,7 @@ module Releases
def execute def execute
return error('Access Denied', 403) unless allowed? return error('Access Denied', 403) unless allowed?
return error('Release already exists', 409) if release return error('Release already exists', 409) if release
return error('Milestone does not exist', 400) if inexistent_milestone? return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
tag = ensure_tag tag = ensure_tag
...@@ -61,7 +61,7 @@ module Releases ...@@ -61,7 +61,7 @@ module Releases
sha: tag.dereferenced_target.sha, sha: tag.dereferenced_target.sha,
released_at: released_at, released_at: released_at,
links_attributes: params.dig(:assets, 'links') || [], links_attributes: params.dig(:assets, 'links') || [],
milestone: milestone milestones: milestones
) )
end end
end end
......
...@@ -9,9 +9,9 @@ module Releases ...@@ -9,9 +9,9 @@ module Releases
return error('Release does not exist', 404) unless release return error('Release does not exist', 404) unless release
return error('Access Denied', 403) unless allowed? return error('Access Denied', 403) unless allowed?
return error('params is empty', 400) if empty_params? return error('params is empty', 400) if empty_params?
return error('Milestone does not exist', 400) if inexistent_milestone? return error("Milestone(s) not found: #{inexistent_milestones.join(', ')}", 400) if inexistent_milestones.any?
params[:milestone] = milestone if param_for_milestone_title_provided? params[:milestones] = milestones if param_for_milestone_titles_provided?
if release.update(params) if release.update(params)
success(tag: existing_tag, release: release) success(tag: existing_tag, release: release)
......
---
title: Switch Milestone and Release to a many-to-many relationship
merge_request: 16517
author:
type: changed
# frozen_string_literal: true
class RemoveIdColumnFromIntermediateReleaseMilestones < ActiveRecord::Migration[5.2]
DOWNTIME = false
def change
remove_column :milestone_releases, :id, :bigint
end
end
...@@ -2208,7 +2208,7 @@ ActiveRecord::Schema.define(version: 2019_09_12_061145) do ...@@ -2208,7 +2208,7 @@ ActiveRecord::Schema.define(version: 2019_09_12_061145) do
t.index ["user_id"], name: "index_merge_trains_on_user_id" t.index ["user_id"], name: "index_merge_trains_on_user_id"
end end
create_table "milestone_releases", force: :cascade do |t| create_table "milestone_releases", id: false, force: :cascade do |t|
t.bigint "milestone_id", null: false t.bigint "milestone_id", null: false
t.bigint "release_id", null: false t.bigint "release_id", null: false
t.index ["milestone_id", "release_id"], name: "index_miletone_releases_on_milestone_and_release", unique: true t.index ["milestone_id", "release_id"], name: "index_miletone_releases_on_milestone_and_release", unique: true
......
This diff is collapsed.
...@@ -1280,7 +1280,7 @@ module API ...@@ -1280,7 +1280,7 @@ module API
expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? } expose :author, using: Entities::UserBasic, if: -> (release, _) { release.author.present? }
expose :commit, using: Entities::Commit, if: lambda { |_, _| can_download_code? } expose :commit, using: Entities::Commit, if: lambda { |_, _| can_download_code? }
expose :upcoming_release?, as: :upcoming_release expose :upcoming_release?, as: :upcoming_release
expose :milestone, using: Entities::Milestone, if: -> (release, _) { release.milestone.present? } expose :milestones, using: Entities::Milestone, if: -> (release, _) { release.milestones.present? }
expose :assets do expose :assets do
expose :assets_count, as: :count do |release, _| expose :assets_count, as: :count do |release, _|
......
...@@ -54,7 +54,7 @@ module API ...@@ -54,7 +54,7 @@ module API
requires :url, type: String requires :url, type: String
end end
end end
optional :milestone, type: String, desc: 'The title of the related milestone' optional :milestones, type: Array, desc: 'The titles of the related milestones', default: []
optional :released_at, type: DateTime, desc: 'The date when the release will be/was ready. Defaults to the current time.' optional :released_at, type: DateTime, desc: 'The date when the release will be/was ready. Defaults to the current time.'
end end
post ':id/releases' do post ':id/releases' do
...@@ -80,7 +80,7 @@ module API ...@@ -80,7 +80,7 @@ module API
optional :name, type: String, desc: 'The name of the release' optional :name, type: String, desc: 'The name of the release'
optional :description, type: String, desc: 'Release notes with markdown support' optional :description, type: String, desc: 'Release notes with markdown support'
optional :released_at, type: DateTime, desc: 'The date when the release will be/was ready.' optional :released_at, type: DateTime, desc: 'The date when the release will be/was ready.'
optional :milestone, type: String, desc: 'The title of the related milestone' optional :milestones, type: Array, desc: 'The titles of the related milestones'
end end
put ':id/releases/:tag_name', requirements: RELEASE_ENDPOINT_REQUIREMENTS do put ':id/releases/:tag_name', requirements: RELEASE_ENDPOINT_REQUIREMENTS do
authorize_update_release! authorize_update_release!
......
...@@ -15,7 +15,10 @@ ...@@ -15,7 +15,10 @@
"author": { "author": {
"oneOf": [{ "type": "null" }, { "$ref": "user/basic.json" }] "oneOf": [{ "type": "null" }, { "$ref": "user/basic.json" }]
}, },
"milestone": { "type": "string" }, "milestones": {
"type": "array",
"items": { "$ref": "milestone.json" }
},
"assets": { "assets": {
"required": ["count", "links", "sources"], "required": ["count", "links", "sources"],
"properties": { "properties": {
......
...@@ -65,8 +65,8 @@ milestone: ...@@ -65,8 +65,8 @@ milestone:
- participants - participants
- events - events
- boards - boards
- milestone_release - milestone_releases
- release - releases
snippets: snippets:
- author - author
- project - project
...@@ -77,8 +77,8 @@ releases: ...@@ -77,8 +77,8 @@ releases:
- author - author
- project - project
- links - links
- milestone_release - milestone_releases
- milestone - milestones
links: links:
- release - release
project_members: project_members:
......
...@@ -14,23 +14,29 @@ describe MilestoneRelease do ...@@ -14,23 +14,29 @@ describe MilestoneRelease do
it { is_expected.to belong_to(:release) } it { is_expected.to belong_to(:release) }
end end
context 'when trying to create the same record in milestone_releases twice' do
it 'is not committing on the second time' do
create(:milestone_release, milestone: milestone, release: release)
expect do
subject.save!
end.to raise_error(ActiveRecord::RecordNotUnique)
end
end
describe 'validations' do describe 'validations' do
it { is_expected.to validate_uniqueness_of(:milestone_id).scoped_to(:release_id) } subject(:milestone_release) { build(:milestone_release, milestone: milestone, release: release) }
context 'when milestone and release do not have the same project' do context 'when milestone and release do not have the same project' do
it 'is not valid' do it 'is not valid' do
other_project = create(:project) milestone_release.release = build(:release, project: create(:project))
release = build(:release, project: other_project)
milestone_release = described_class.new(milestone: milestone, release: release)
expect(milestone_release).not_to be_valid expect(milestone_release).not_to be_valid
end end
end end
context 'when milestone and release have the same project' do context 'when milestone and release have the same project' do
it 'is valid' do it { is_expected.to be_valid }
milestone_release = described_class.new(milestone: milestone, release: release)
expect(milestone_release).to be_valid
end
end end
end end
end end
...@@ -55,20 +55,20 @@ describe Milestone do ...@@ -55,20 +55,20 @@ describe Milestone do
end end
end end
describe 'milestone_release' do describe 'milestone_releases' do
let(:milestone) { build(:milestone, project: project) } let(:milestone) { build(:milestone, project: project) }
context 'when it is tied to a release for another project' do context 'when it is tied to a release for another project' do
it 'creates a validation error' do it 'creates a validation error' do
other_project = create(:project) other_project = create(:project)
milestone.release = build(:release, project: other_project) milestone.releases << build(:release, project: other_project)
expect(milestone).not_to be_valid expect(milestone).not_to be_valid
end end
end end
context 'when it is tied to a release for the same project' do context 'when it is tied to a release for the same project' do
it 'is valid' do it 'is valid' do
milestone.release = build(:release, project: project) milestone.releases << build(:release, project: project)
expect(milestone).to be_valid expect(milestone).to be_valid
end end
end end
...@@ -78,7 +78,8 @@ describe Milestone do ...@@ -78,7 +78,8 @@ describe Milestone do
describe "Associations" do describe "Associations" do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:issues) } it { is_expected.to have_many(:issues) }
it { is_expected.to have_one(:release) } it { is_expected.to have_many(:releases) }
it { is_expected.to have_many(:milestone_releases) }
end end
let(:project) { create(:project, :public) } let(:project) { create(:project, :public) }
......
...@@ -13,7 +13,8 @@ RSpec.describe Release do ...@@ -13,7 +13,8 @@ RSpec.describe Release do
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:author).class_name('User') } it { is_expected.to belong_to(:author).class_name('User') }
it { is_expected.to have_many(:links).class_name('Releases::Link') } it { is_expected.to have_many(:links).class_name('Releases::Link') }
it { is_expected.to have_one(:milestone) } it { is_expected.to have_many(:milestones) }
it { is_expected.to have_many(:milestone_releases) }
end end
describe 'validation' do describe 'validation' do
...@@ -38,15 +39,15 @@ RSpec.describe Release do ...@@ -38,15 +39,15 @@ RSpec.describe Release do
context 'when a release is tied to a milestone for another project' do context 'when a release is tied to a milestone for another project' do
it 'creates a validation error' do it 'creates a validation error' do
release.milestone = build(:milestone, project: create(:project)) milestone = build(:milestone, project: create(:project))
expect(release).not_to be_valid expect { release.milestones << milestone }.to raise_error
end end
end end
context 'when a release is tied to a milestone linked to the same project' do context 'when a release is tied to a milestone linked to the same project' do
it 'is valid' do it 'successfully links this release to this milestone' do
release.milestone = build(:milestone, project: project) milestone = build(:milestone, project: project)
expect(release).to be_valid expect { release.milestones << milestone }.to change { MilestoneRelease.count }.by(1)
end end
end end
end end
......
...@@ -72,7 +72,7 @@ describe Milestones::DestroyService do ...@@ -72,7 +72,7 @@ describe Milestones::DestroyService do
:release, :release,
tag: 'v1.0', tag: 'v1.0',
project: project, project: project,
milestone: milestone milestones: [milestone]
) )
expect { service.execute(milestone) }.not_to change { Release.count } expect { service.execute(milestone) }.not_to change { Release.count }
......
...@@ -75,10 +75,12 @@ describe Releases::CreateService do ...@@ -75,10 +75,12 @@ describe Releases::CreateService do
context 'when a passed-in milestone does not exist for this project' do context 'when a passed-in milestone does not exist for this project' do
it 'raises an error saying the milestone is inexistent' do it 'raises an error saying the milestone is inexistent' do
service = described_class.new(project, user, params.merge!({ milestone: 'v111.0' })) inexistent_milestone_tag = 'v111.0'
service = described_class.new(project, user, params.merge!({ milestones: [inexistent_milestone_tag] }))
result = service.execute result = service.execute
expect(result[:status]).to eq(:error) expect(result[:status]).to eq(:error)
expect(result[:message]).to eq('Milestone does not exist') expect(result[:message]).to eq("Milestone(s) not found: #{inexistent_milestone_tag}")
end end
end end
end end
...@@ -93,10 +95,10 @@ describe Releases::CreateService do ...@@ -93,10 +95,10 @@ describe Releases::CreateService do
context 'when existing milestone is passed in' do context 'when existing milestone is passed in' do
let(:title) { 'v1.0' } let(:title) { 'v1.0' }
let(:milestone) { create(:milestone, :active, project: project, title: title) } let(:milestone) { create(:milestone, :active, project: project, title: title) }
let(:params_with_milestone) { params.merge!({ milestone: title }) } let(:params_with_milestone) { params.merge!({ milestones: [title] }) }
let(:service) { described_class.new(milestone.project, user, params_with_milestone) }
it 'creates a release and ties this milestone to it' do it 'creates a release and ties this milestone to it' do
service = described_class.new(milestone.project, user, params_with_milestone)
result = service.execute result = service.execute
expect(project.releases.count).to eq(1) expect(project.releases.count).to eq(1)
...@@ -104,29 +106,66 @@ describe Releases::CreateService do ...@@ -104,29 +106,66 @@ describe Releases::CreateService do
release = project.releases.last release = project.releases.last
expect(release.milestone).to eq(milestone) expect(release.milestones).to match_array([milestone])
end end
context 'when another release was previously created with that same milestone linked' do context 'when another release was previously created with that same milestone linked' do
it 'also creates another release tied to that same milestone' do it 'also creates another release tied to that same milestone' do
other_release = create(:release, milestone: milestone, project: project, tag: 'v1.0') other_release = create(:release, milestones: [milestone], project: project, tag: 'v1.0')
service = described_class.new(milestone.project, user, params_with_milestone)
service.execute service.execute
release = project.releases.last release = project.releases.last
expect(release.milestone).to eq(milestone) expect(release.milestones).to match_array([milestone])
expect(other_release.milestone).to eq(milestone) expect(other_release.milestones).to match_array([milestone])
expect(release.id).not_to eq(other_release.id) expect(release.id).not_to eq(other_release.id)
end end
end end
end end
context 'when multiple existing milestone titles are passed in' do
let(:title_1) { 'v1.0' }
let(:title_2) { 'v1.0-rc' }
let!(:milestone_1) { create(:milestone, :active, project: project, title: title_1) }
let!(:milestone_2) { create(:milestone, :active, project: project, title: title_2) }
let!(:params_with_milestones) { params.merge!({ milestones: [title_1, title_2] }) }
it 'creates a release and ties it to these milestones' do
described_class.new(project, user, params_with_milestones).execute
release = project.releases.last
expect(release.milestones.map(&:title)).to include(title_1, title_2)
end
end
context 'when multiple miletone titles are passed in but one of them does not exist' do
let(:title) { 'v1.0' }
let(:inexistent_title) { 'v111.0' }
let!(:milestone) { create(:milestone, :active, project: project, title: title) }
let!(:params_with_milestones) { params.merge!({ milestones: [title, inexistent_title] }) }
let(:service) { described_class.new(milestone.project, user, params_with_milestones) }
it 'raises an error' do
result = service.execute
expect(result[:status]).to eq(:error)
expect(result[:message]).to eq("Milestone(s) not found: #{inexistent_title}")
end
it 'does not create any release' do
expect do
service.execute
end.not_to change(Release, :count)
end
end
context 'when no milestone is passed in' do context 'when no milestone is passed in' do
it 'creates a release without a milestone tied to it' do it 'creates a release without a milestone tied to it' do
expect(params.key? :milestone).to be_falsey expect(params.key? :milestones).to be_falsey
service.execute service.execute
release = project.releases.last release = project.releases.last
expect(release.milestone).to be_nil
expect(release.milestones).to be_empty
end end
it 'does not create any new MilestoneRelease object' do it 'does not create any new MilestoneRelease object' do
...@@ -136,10 +175,11 @@ describe Releases::CreateService do ...@@ -136,10 +175,11 @@ describe Releases::CreateService do
context 'when an empty value is passed as a milestone' do context 'when an empty value is passed as a milestone' do
it 'creates a release without a milestone tied to it' do it 'creates a release without a milestone tied to it' do
service = described_class.new(project, user, params.merge!({ milestone: '' })) service = described_class.new(project, user, params.merge!({ milestones: [] }))
service.execute service.execute
release = project.releases.last release = project.releases.last
expect(release.milestone).to be_nil
expect(release.milestones).to be_empty
end end
end end
end end
......
...@@ -60,7 +60,7 @@ describe Releases::DestroyService do ...@@ -60,7 +60,7 @@ describe Releases::DestroyService do
context 'when a milestone is tied to the release' do context 'when a milestone is tied to the release' do
let!(:milestone) { create(:milestone, :active, project: project, title: 'v1.0') } let!(:milestone) { create(:milestone, :active, project: project, title: 'v1.0') }
let!(:release) { create(:release, milestone: milestone, project: project, tag: tag) } let!(:release) { create(:release, milestones: [milestone], project: project, tag: tag) }
it 'destroys the release but leave the milestone intact' do it 'destroys the release but leave the milestone intact' do
expect { subject }.not_to change { Milestone.count } expect { subject }.not_to change { Milestone.count }
......
...@@ -50,39 +50,60 @@ describe Releases::UpdateService do ...@@ -50,39 +50,60 @@ describe Releases::UpdateService do
end end
context 'when a milestone is passed in' do context 'when a milestone is passed in' do
let(:old_title) { 'v1.0' }
let(:new_title) { 'v2.0' } let(:new_title) { 'v2.0' }
let(:milestone) { create(:milestone, project: project, title: old_title) } let(:milestone) { create(:milestone, project: project, title: 'v1.0') }
let(:new_milestone) { create(:milestone, project: project, title: new_title) } let(:new_milestone) { create(:milestone, project: project, title: new_title) }
let(:params_with_milestone) { params.merge!({ milestone: new_title }) } let(:params_with_milestone) { params.merge!({ milestones: [new_title] }) }
let(:service) { described_class.new(new_milestone.project, user, params_with_milestone) }
before do before do
release.milestone = milestone release.milestones << milestone
release.save!
described_class.new(new_milestone.project, user, params_with_milestone).execute service.execute
release.reload release.reload
end end
it 'updates the related milestone accordingly' do it 'updates the related milestone accordingly' do
expect(release.milestone.title).to eq(new_title) expect(release.milestones.first.title).to eq(new_title)
end end
end end
context "when an 'empty' milestone is passed in" do context "when an 'empty' milestone is passed in" do
let(:milestone) { create(:milestone, project: project, title: 'v1.0') } let(:milestone) { create(:milestone, project: project, title: 'v1.0') }
let(:params_with_empty_milestone) { params.merge!({ milestone: '' }) } let(:params_with_empty_milestone) { params.merge!({ milestones: [] }) }
before do before do
release.milestone = milestone release.milestones << milestone
release.save!
described_class.new(milestone.project, user, params_with_empty_milestone).execute service.params = params_with_empty_milestone
service.execute
release.reload release.reload
end end
it 'removes the old milestone and does not associate any new milestone' do it 'removes the old milestone and does not associate any new milestone' do
expect(release.milestone).to be_nil expect(release.milestones).not_to be_present
end
end
context "when multiple new milestones are passed in" do
let(:new_title_1) { 'v2.0' }
let(:new_title_2) { 'v2.0-rc' }
let(:milestone) { create(:milestone, project: project, title: 'v1.0') }
let(:params_with_milestones) { params.merge!({ milestones: [new_title_1, new_title_2] }) }
let(:service) { described_class.new(project, user, params_with_milestones) }
before do
create(:milestone, project: project, title: new_title_1)
create(:milestone, project: project, title: new_title_2)
release.milestones << milestone
service.execute
release.reload
end
it 'removes the old milestone and update the release with the new ones' do
milestone_titles = release.milestones.map(&:title)
expect(milestone_titles).to match_array([new_title_1, new_title_2])
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