Commit 4bebcbff authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '250479-create-iteration-lists-api' into 'master'

Add creation of iteration lists

See merge request gitlab-org/gitlab!49688
parents 7b4ef3d2 546ee070
......@@ -2119,6 +2119,11 @@ input BoardListCreateInput {
"""
clientMutationId: String
"""
Global ID of an existing iteration
"""
iterationId: IterationID
"""
Global ID of an existing label
"""
......
......@@ -5610,6 +5610,16 @@
},
"defaultValue": null
},
{
"name": "iterationId",
"description": "Global ID of an existing iteration",
"type": {
"kind": "SCALAR",
"name": "IterationID",
"ofType": null
},
"defaultValue": null
},
{
"name": "assigneeId",
"description": "Global ID of an existing user",
......@@ -13,7 +13,7 @@ module EE
override :list_creation_attrs
def list_creation_attrs
additional_attrs = %i[assignee_id milestone_id]
additional_attrs = %i[assignee_id milestone_id iteration_id]
additional_attrs += EE_MAX_LIMITS_PARAMS if wip_limits_available?
super + additional_attrs
......@@ -28,7 +28,7 @@ module EE
override :serialization_attrs
def serialization_attrs
super.merge(user: true, milestone: true).tap do |attrs|
super.merge(user: true, milestone: true, iteration: true).tap do |attrs|
attrs[:only] += EE_MAX_LIMITS_PARAMS if wip_limits_available?
end
end
......
......@@ -116,6 +116,8 @@ class IterationsFinder
end
def by_state(items)
return items unless params[:state].present?
Iteration.filter_by_state(items, params[:state])
end
......
......@@ -12,6 +12,9 @@ module EE
argument :milestone_id, ::Types::GlobalIDType[::Milestone],
required: false,
description: 'Global ID of an existing milestone'
argument :iteration_id, ::Types::GlobalIDType[::Iteration],
required: false,
description: 'Global ID of an existing iteration'
argument :assignee_id, ::Types::GlobalIDType[::User],
required: false,
description: 'Global ID of an existing user'
......@@ -24,14 +27,15 @@ module EE
params = super
params[:milestone_id] &&= ::GitlabSchema.parse_gid(params[:milestone_id], expected_type: ::Milestone).model_id
params[:assignee_id] &&= ::GitlabSchema.parse_gid(params[:assignee_id], expected_type: ::User).model_id
params[:iteration_id] &&= ::GitlabSchema.parse_gid(params[:iteration_id], expected_type: ::Iteration).model_id
params[:assignee_id] &&= ::GitlabSchema.parse_gid(params[:assignee_id], expected_type: ::User).model_id
params
end
override :mutually_exclusive_args
def mutually_exclusive_args
super + [:milestone_id, :assignee_id]
super + [:milestone_id, :iteration_id, :assignee_id]
end
end
end
......
......@@ -81,6 +81,10 @@ module EE
if options.key?(:milestone)
json[:milestone] = MilestoneSerializer.new.represent(milestone).as_json
end
if options.key?(:iteration)
json[:iteration] = IterationSerializer.new.represent(iteration).as_json
end
end
end
......
# frozen_string_literal: true
class IterationSerializer < BaseSerializer
entity API::Entities::Iteration
end
......@@ -8,6 +8,42 @@ module EE
include MaxLimits
override :execute
def execute(board)
return ServiceResponse.error(message: 'iteration_board_lists feature flag is disabled') if type == :iteration && ::Feature.disabled?(:iteration_board_lists, board.resource_parent)
return license_validation_error unless valid_license?(board.resource_parent)
super
end
private
def valid_license?(parent)
license_name = case type
when :assignee
:board_assignee_lists
when :milestone
:board_milestone_lists
when :iteration
:iterations
end
license_name.nil? || parent.feature_available?(license_name)
end
def license_validation_error
message = case type
when :assignee
_('Assignee lists not available with your current license')
when :milestone
_('Milestone lists not available with your current license')
when :iteration
_('Iteration lists not available with your current license')
end
ServiceResponse.error(message: message)
end
override :type
def type
# We don't ever expect to have more than one list
......@@ -16,6 +52,8 @@ module EE
:assignee
elsif params.key?('milestone_id')
:milestone
elsif params.key?('iteration_id')
:iteration
else
super
end
......@@ -29,6 +67,8 @@ module EE
find_user(board)
when :milestone
find_milestone(board)
when :iteration
find_iteration(board)
else
super
end
......@@ -46,13 +86,16 @@ module EE
)
end
private
def find_milestone(board)
milestones = milestone_finder(board).execute
milestones.find_by(id: params['milestone_id']) # rubocop: disable CodeReuse/ActiveRecord
end
def find_iteration(board)
parent_params = ::IterationsFinder.params_for_parent(board.resource_parent, include_ancestors: true)
::IterationsFinder.new(current_user, parent_params).find_by(id: params['iteration_id']) # rubocop: disable CodeReuse/ActiveRecord
end
# rubocop: disable CodeReuse/ActiveRecord
def find_user(board)
user_ids = user_finder(board).execute.select(:user_id)
......
---
name: iteration_board_lists
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/49688
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/250479
milestone: '13.7'
type: development
group: group::project management
default_enabled: false
# frozen_string_literal: true
module API
module Entities
class Iteration < Grape::Entity
expose :id, :iid
expose :project_id, if: -> (entity, options) { entity&.project_id }
expose :group_id, if: -> (entity, options) { entity&.group_id }
expose :title, :description
expose :state_enum, as: :state
expose :created_at, :updated_at
expose :start_date, :due_date
end
end
end
......@@ -19,7 +19,7 @@ module API
def list_iterations_for(parent)
iterations = IterationsFinder.new(current_user, iterations_finder_params(parent)).execute
present paginate(iterations), with: EE::API::Entities::Iteration
present paginate(iterations), with: Entities::Iteration
end
def iterations_finder_params(parent)
......@@ -36,7 +36,7 @@ module API
resource :projects, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of project iterations' do
detail 'This feature was introduced in GitLab 13.5'
success EE::API::Entities::Iteration
success Entities::Iteration
end
params do
use :list_params
......@@ -54,7 +54,7 @@ module API
resource :groups, requirements: API::NAMESPACE_OR_PROJECT_REQUIREMENTS do
desc 'Get a list of group iterations' do
detail 'This feature was introduced in GitLab 13.5'
success EE::API::Entities::Iteration
success Entities::Iteration
end
params do
use :list_params
......
......@@ -7,17 +7,13 @@ module EE
prepended do
helpers do
# Overrides API::BoardsResponses create_list_params
def create_list_params
params.slice(:label_id, :milestone_id, :assignee_id)
end
# Overrides API::BoardsResponses list_creation_params
params :list_creation_params do
optional :label_id, type: Integer, desc: 'The ID of an existing label'
optional :milestone_id, type: Integer, desc: 'The ID of an existing milestone'
optional :iteration_id, type: Integer, desc: 'The ID of an assignee iteration'
optional :assignee_id, type: Integer, desc: 'The ID of an assignee'
exactly_one_of :label_id, :milestone_id, :assignee_id
exactly_one_of :label_id, :milestone_id, :iteration_id, :assignee_id
end
# Overrides API::BoardsResponses update_params
......
# frozen_string_literal: true
module EE
module API
module Entities
class Iteration < Grape::Entity
expose :id, :iid
expose :project_id, if: -> (entity, options) { entity&.project_id }
expose :group_id, if: -> (entity, options) { entity&.group_id }
expose :title, :description
expose :state_enum, as: :state
expose :created_at, :updated_at
expose :start_date, :due_date
end
end
end
end
......@@ -8,6 +8,7 @@ module EE
prepended do
expose :milestone, using: ::API::Entities::Milestone, if: -> (entity, _) { entity.milestone? }
expose :iteration, using: ::API::Entities::Iteration, if: -> (entity, _) { entity.iteration? }
expose :user, as: :assignee, using: ::API::Entities::UserSafe, if: -> (entity, _) { entity.assignee? }
expose :max_issue_count, if: -> (list, _) { list.wip_limits_available? }
expose :max_issue_weight, if: -> (list, _) { list.wip_limits_available? }
......
......@@ -13,7 +13,7 @@ module EE
expose :resource_id do |event, _options|
event.issuable.id
end
expose :iteration, using: Entities::Iteration
expose :iteration, using: ::API::Entities::Iteration
expose :action
end
end
......
......@@ -52,16 +52,56 @@ RSpec.describe Boards::ListsController do
describe 'POST create' do
context 'with valid params' do
it 'returns a successful 200 response' do
create_board_list user: user, board: board, label_id: label.id
context 'for label lists' do
it 'returns a successful 200 response' do
create_board_list user: user, board: board, label_id: label.id
expect(response).to have_gitlab_http_status(:ok)
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('list', dir: 'ee')
end
end
it 'returns the created list' do
create_board_list user: user, board: board, label_id: label.id
context 'for iteration lists' do
let_it_be(:iteration) { create(:iteration, group: group) }
context 'when iteration_board_lists is disabled' do
before do
stub_feature_flags(iteration_board_lists: false)
end
it 'returns an error' do
create_board_list user: user, board: board, iteration_id: iteration.id
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['errors']).to eq(['iteration_board_lists feature flag is disabled'])
end
end
expect(response).to match_response_schema('list', dir: 'ee')
context 'when license is available' do
before do
stub_licensed_features(iterations: true)
end
it 'returns a successful 200 response' do
create_board_list user: user, board: board, iteration_id: iteration.id
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('list', dir: 'ee')
end
end
context 'when license is unavailable' do
before do
stub_licensed_features(iterations: false)
end
it 'returns an error' do
create_board_list user: user, board: board, iteration_id: iteration.id
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['errors']).to eq(['Iteration lists not available with your current license'])
end
end
end
end
......@@ -166,9 +206,9 @@ RSpec.describe Boards::ListsController do
end
context 'with invalid params' do
context 'when label is nil' do
context 'when label is empty' do
it 'returns an unprocessable entity 422 response' do
create_board_list user: user, board: board, label_id: nil
create_board_list user: user, board: board, label_id: ''
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['errors']).to eq(['Label not found'])
......@@ -195,12 +235,12 @@ RSpec.describe Boards::ListsController do
end
end
def create_board_list(user:, board:, label_id:, params: {})
def create_board_list(user:, board:, label_id: nil, iteration_id: nil, params: {})
sign_in(user)
post :create, params: {
board_id: board.to_param,
list: { label_id: label_id }.merge(params)
list: { label_id: label_id, iteration_id: iteration_id }.compact.merge!(params)
},
format: :json
end
......
......@@ -18,7 +18,7 @@ RSpec.describe IterationsFinder do
context 'without permissions' do
context 'groups and projects' do
let(:params) { { project_ids: project_ids, group_ids: group.id, state: 'all' } }
let(:params) { { project_ids: project_ids, group_ids: group.id } }
it 'returns iterations for groups and projects' do
expect(subject).to be_empty
......@@ -34,7 +34,7 @@ RSpec.describe IterationsFinder do
end
context 'iterations for projects' do
let(:params) { { project_ids: project_ids, state: 'all' } }
let(:params) { { project_ids: project_ids } }
it 'returns iterations for projects' do
expect(subject).to contain_exactly(iteration_from_project_1, iteration_from_project_2)
......@@ -42,7 +42,7 @@ RSpec.describe IterationsFinder do
end
context 'iterations for groups' do
let(:params) { { group_ids: group.id, state: 'all' } }
let(:params) { { group_ids: group.id } }
it 'returns iterations for groups' do
expect(subject).to contain_exactly(started_group_iteration, upcoming_group_iteration)
......@@ -50,7 +50,7 @@ RSpec.describe IterationsFinder do
end
context 'iterations for groups and project' do
let(:params) { { project_ids: project_ids, group_ids: group.id, state: 'all' } }
let(:params) { { project_ids: project_ids, group_ids: group.id } }
it 'returns iterations for groups and projects' do
expect(subject).to contain_exactly(started_group_iteration, upcoming_group_iteration, iteration_from_project_1, iteration_from_project_2)
......@@ -69,8 +69,7 @@ RSpec.describe IterationsFinder do
let(:params) do
{
project_ids: project_ids,
group_ids: group.id,
state: 'all'
group_ids: group.id
}
end
......@@ -79,6 +78,12 @@ RSpec.describe IterationsFinder do
iteration_from_project_1.close
end
it 'filters by all states' do
params[:state] = 'all'
expect(subject).to contain_exactly(started_group_iteration, upcoming_group_iteration, iteration_from_project_1, iteration_from_project_2)
end
it 'filters by started state' do
params[:state] = 'started'
......@@ -154,7 +159,7 @@ RSpec.describe IterationsFinder do
describe '#find_by' do
it 'finds a single iteration' do
finder = described_class.new(user, project_ids: [project_1.id], state: 'all')
finder = described_class.new(user, project_ids: [project_1.id])
expect(finder.find_by(iid: iteration_from_project_1.iid)).to eq(iteration_from_project_1)
end
......
......@@ -5,7 +5,7 @@
"$ref": "../../../../../spec/fixtures/api/schemas/list.json"
},
{
"required": ["user"],
"required": ["user", "iteration"],
"properties": {
"user": {
"type": [
......@@ -44,6 +44,35 @@
"type": "string"
}
}
},
"iteration": {
"type": [
"object",
"null"
],
"required": [
"id",
"title",
"description",
"state"
],
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
},
"description": {
"type": [
"string",
"null"
]
},
"state": {
"type": "integer"
}
}
}
}
}
......
......@@ -21,7 +21,7 @@ RSpec.describe Mutations::Boards::Lists::Create do
end
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, iterations: true)
end
subject { mutation.resolve(board_id: board.to_global_id.to_s, **list_create_params) }
......@@ -30,13 +30,13 @@ RSpec.describe Mutations::Boards::Lists::Create do
it 'raises an error if required arguments are missing' do
expect { mutation.ready?(board_id: 'some id') }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError,
'one and only one of backlog or labelId or milestoneId or assigneeId is required')
'one and only one of backlog or labelId or milestoneId or iterationId or assigneeId is required')
end
it 'raises an error if too many required arguments are specified' do
expect { mutation.ready?(board_id: 'some id', milestone_id: 'some milestone', assignee_id: 'some label') }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError,
'one and only one of backlog or labelId or milestoneId or assigneeId is required')
'one and only one of backlog or labelId or milestoneId or iterationId or assigneeId is required')
end
end
......@@ -49,7 +49,7 @@ RSpec.describe Mutations::Boards::Lists::Create do
it 'returns an error' do
stub_licensed_features(board_milestone_lists: false)
expect(subject[:errors]).to include 'List type Milestone lists not available with your current license'
expect(subject[:errors]).to include 'Milestone lists not available with your current license'
end
end
......@@ -79,7 +79,7 @@ RSpec.describe Mutations::Boards::Lists::Create do
it 'returns an error' do
stub_licensed_features(board_assignee_lists: false)
expect(subject[:errors]).to include 'List type Assignee lists not available with your current license'
expect(subject[:errors]).to include 'Assignee lists not available with your current license'
end
end
......@@ -101,6 +101,45 @@ RSpec.describe Mutations::Boards::Lists::Create do
end
end
end
describe 'iteration list' do
let(:iteration) { create(:iteration, group: group) }
let(:list_create_params) { { iteration_id: iteration.to_global_id.to_s } }
context 'when feature unavailable' do
it 'returns an error' do
stub_licensed_features(iterations: false)
expect(subject[:errors]).to include 'Iteration lists not available with your current license'
end
end
context 'when feature flag is disabled' do
it 'returns an error' do
stub_feature_flags(iteration_board_lists: false)
expect(subject[:errors]).to include 'iteration_board_lists feature flag is disabled'
end
end
it 'creates a new issue board list for the iteration' do
expect { subject }.to change { board.lists.count }.from(1).to(2)
new_list = subject[:list]
expect(new_list.title).to eq "#{iteration.title}"
expect(new_list.iteration_id).to eq iteration.id
expect(new_list.position).to eq 0
end
context 'when iteration not found' do
let(:list_create_params) { { iteration_id: "gid://gitlab/Iteration/#{non_existing_record_id}" } }
it 'returns an error' do
expect(subject[:errors]).to include 'Iteration not found'
end
end
end
end
context 'without proper permissions' do
......
......@@ -4,10 +4,15 @@ require 'spec_helper'
RSpec.describe API::Boards do
let_it_be(:user) { create(:user) }
let_it_be(:board_parent) { create(:project, :public, creator_id: user.id, namespace: user.namespace ) }
let_it_be(:group) { create(:group, :public) }
let_it_be(:board_parent) { create(:project, :public, group: group ) }
let_it_be(:milestone) { create(:milestone, project: board_parent) }
let_it_be(:board) { create(:board, project: board_parent, milestone: milestone, assignee: user) }
before_all do
group.add_maintainer(user)
end
it_behaves_like 'multiple and scoped issue boards', "/projects/:id/boards"
before do
......@@ -19,6 +24,9 @@ RSpec.describe API::Boards do
it_behaves_like 'milestone board list'
it_behaves_like 'assignee board list'
it_behaves_like 'iteration board list' do
let_it_be(:iteration) { create(:iteration, group: group) }
end
end
context 'GET /projects/:id/boards/:board_id with special milestones' do
......
......@@ -9,7 +9,7 @@ RSpec.describe API::GroupBoards do
let_it_be(:admin) { create(:user, :admin) }
let_it_be(:board_parent) { create(:group, :public) }
before do
before_all do
board_parent.add_owner(user)
end
......@@ -54,5 +54,8 @@ RSpec.describe API::GroupBoards do
it_behaves_like 'milestone board list'
it_behaves_like 'assignee board list'
it_behaves_like 'iteration board list' do
let_it_be(:iteration) { create(:iteration, group: board_parent) }
end
end
end
......@@ -4,8 +4,9 @@ require 'spec_helper'
RSpec.describe Boards::Lists::CreateService do
describe '#execute' do
let_it_be(:project) { create(:project) }
let_it_be(:board) { create(:board, project: project) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:board, refind: true) { create(:board, project: project) }
let_it_be(:user) { create(:user) }
context 'when assignee_id param is sent' do
......@@ -31,7 +32,6 @@ RSpec.describe Boards::Lists::CreateService do
end
context 'when milestone_id param is sent' do
let_it_be(:user) { create(:user) }
let_it_be(:milestone) { create(:milestone, project: project) }
before_all do
......@@ -52,6 +52,56 @@ RSpec.describe Boards::Lists::CreateService do
end
end
context 'when iteration_id param is sent' do
let_it_be(:iteration) { create(:iteration, group: group) }
before_all do
group.add_developer(user)
end
subject(:service) { described_class.new(project, user, 'iteration_id' => iteration.id) }
before do
stub_licensed_features(iterations: true)
end
it 'creates an iteration list when param is valid' do
response = service.execute(board)
expect(response.success?).to eq(true)
expect(response.payload[:list].list_type).to eq('iteration')
end
context 'when iteration is from another group' do
let_it_be(:iteration) { create(:iteration) }
it 'returns an error' do
response = service.execute(board)
expect(response.success?).to eq(false)
expect(response.errors).to include('Iteration not found')
end
end
it 'returns an error when feature flag is disabled' do
stub_feature_flags(iteration_board_lists: false)
response = service.execute(board)
expect(response.success?).to eq(false)
expect(response.errors).to include('iteration_board_lists feature flag is disabled')
end
it 'returns an error when license is unavailable' do
stub_licensed_features(iterations: false)
response = service.execute(board)
expect(response.success?).to eq(false)
expect(response.errors).to include('Iteration lists not available with your current license')
end
end
context 'max limits' do
describe '#create_list_attributes' do
shared_examples 'attribute provider for list creation' do
......@@ -90,7 +140,7 @@ RSpec.describe Boards::Lists::CreateService do
it 'contains the expected max limits' do
service = described_class.new(project, user, params)
attrs = service.create_list_attributes(nil, nil, nil)
attrs = service.send(:create_list_attributes, nil, nil, nil)
if wip_limits_enabled
expect(attrs).to include(max_issue_count: expected_max_issue_count,
......
# frozen_string_literal: true
RSpec.shared_examples 'assignee board list' do
before do
stub_licensed_features(board_assignee_lists: true)
end
context 'when assignee_id is sent' do
it 'returns 400 if user is not found' do
other_user = create(:user)
......@@ -17,12 +21,10 @@ RSpec.shared_examples 'assignee board list' do
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response.dig('message', 'error'))
.to eq('List type Assignee lists not available with your current license')
.to eq('Assignee lists not available with your current license')
end
it 'creates an assignee list if user is found' do
stub_licensed_features(board_assignee_lists: true)
post api(url, user), params: { assignee_id: user.id }
expect(response).to have_gitlab_http_status(:created)
......
# frozen_string_literal: true
RSpec.shared_examples 'iteration board list' do
before do
stub_licensed_features(iterations: true)
end
context 'when iteration_id is sent' do
it 'returns 400 if iteration is not found' do
other_iteration = create(:iteration)
post api(url, user), params: { iteration_id: other_iteration.id }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response.dig('message', 'error')).to eq('Iteration not found')
end
it 'returns 400 if feature flag is disabled' do
stub_feature_flags(iteration_board_lists: false)
post api(url, user), params: { iteration_id: iteration.id }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response.dig('message', 'error')).to eq('iteration_board_lists feature flag is disabled')
end
it 'returns 400 if not licensed' do
stub_licensed_features(iterations: false)
post api(url, user), params: { iteration_id: iteration.id }
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response.dig('message', 'error'))
.to eq('Iteration lists not available with your current license')
end
it 'creates an iteration list if iteration is found' do
post api(url, user), params: { iteration_id: iteration.id }
expect(response).to have_gitlab_http_status(:created)
expect(json_response.dig('iteration', 'id')).to eq(iteration.id)
end
end
end
# frozen_string_literal: true
RSpec.shared_examples 'milestone board list' do
before do
stub_licensed_features(board_milestone_lists: true)
end
context 'when milestone_id is sent' do
it 'returns 400 if milestone is not found' do
other_milestone = create(:milestone)
......@@ -17,12 +21,10 @@ RSpec.shared_examples 'milestone board list' do
expect(response).to have_gitlab_http_status(:bad_request)
expect(json_response.dig('message', 'error'))
.to eq('List type Milestone lists not available with your current license')
.to eq('Milestone lists not available with your current license')
end
it 'creates a milestone list if milestone is found' do
stub_licensed_features(board_milestone_lists: true)
post api(url, user), params: { milestone_id: milestone.id }
expect(response).to have_gitlab_http_status(:created)
......
......@@ -45,7 +45,7 @@ module API
def create_list
create_list_service =
::Boards::Lists::CreateService.new(board_parent, current_user, create_list_params)
::Boards::Lists::CreateService.new(board_parent, current_user, declared_params.compact.with_indifferent_access)
response = create_list_service.execute(board)
......@@ -56,10 +56,6 @@ module API
end
end
def create_list_params
params.slice(:label_id)
end
def move_list(list)
move_list_service =
::Boards::Lists::MoveService.new(board_parent, current_user, { position: params[:position].to_i })
......
......@@ -10,7 +10,7 @@
"id": { "type": "integer" },
"list_type": {
"type": "string",
"enum": ["backlog", "label", "closed"]
"enum": ["backlog", "label", "iteration", "closed"]
},
"label": {
"type": ["object", "null"],
......
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