Commit 4a1c50a5 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'master' into issue_928_group_boards

parents d0cd4ae9 4dce42cc
......@@ -397,7 +397,6 @@ db:migrate:reset-mysql:
.migration-paths: &migration-paths
<<: *dedicated-runner
<<: *only-canonical-masters
<<: *pull-cache
stage: test
variables:
......
......@@ -336,7 +336,7 @@ group :development, :test do
# Generate Fake data
gem 'ffaker', '~> 2.4'
gem 'capybara', '~> 2.6.2'
gem 'capybara', '~> 2.15.0'
gem 'capybara-screenshot', '~> 1.0.0'
gem 'poltergeist', '~> 1.9.0'
......
......@@ -108,9 +108,9 @@ GEM
bundler (~> 1.2)
thor (~> 0.18)
byebug (9.0.6)
capybara (2.6.2)
capybara (2.15.1)
addressable
mime-types (>= 1.16)
mini_mime (>= 0.1.3)
nokogiri (>= 1.3.3)
rack (>= 1.0.0)
rack-test (>= 0.5.4)
......@@ -506,6 +506,7 @@ GEM
method_source (0.8.2)
mime-types (2.99.3)
mimemagic (0.3.0)
mini_mime (0.1.4)
mini_portile2 (2.2.0)
minitest (5.7.0)
mmap2 (2.2.7)
......@@ -976,7 +977,7 @@ GEM
expression_parser
rinku
xml-simple (1.1.5)
xpath (2.0.0)
xpath (2.1.0)
nokogiri (~> 1.3)
PLATFORMS
......@@ -1008,7 +1009,7 @@ DEPENDENCIES
browser (~> 2.2)
bullet (~> 5.5.0)
bundler-audit (~> 0.5.0)
capybara (~> 2.6.2)
capybara (~> 2.15.0)
capybara-screenshot (~> 1.0.0)
carrierwave (~> 1.1)
charlock_holmes (~> 0.7.5)
......
......@@ -122,6 +122,7 @@ $(() => {
this.state.lists = _.sortBy(this.state.lists, 'position');
Store.addBlankState();
Store.addPromotionState();
this.loading = false;
})
.catch(() => new Flash('An error occurred. Please try again.'));
......
/* eslint-disable comma-dangle, space-before-function-paren, one-var */
/* global Sortable */
import Vue from 'vue';
import boardPromotionState from 'ee/boards/components/board_promotion_state';
import AccessorUtilities from '../../lib/utils/accessor';
import boardList from './board_list';
import boardBlankState from './board_blank_state';
......@@ -17,6 +18,7 @@ gl.issueBoards.Board = Vue.extend({
boardList,
'board-delete': gl.issueBoards.BoardDelete,
boardBlankState,
boardPromotionState,
},
props: {
list: Object,
......
......@@ -12,7 +12,7 @@ class List {
this.position = obj.position;
this.title = obj.title;
this.type = obj.list_type;
this.preset = ['backlog', 'closed', 'blank'].indexOf(this.type) > -1;
this.preset = ['backlog', 'closed', 'blank', 'promotion'].indexOf(this.type) > -1;
this.isExpandable = ['backlog', 'closed'].indexOf(this.type) > -1;
this.isExpanded = true;
this.page = 1;
......@@ -26,7 +26,7 @@ class List {
this.label = new ListLabel(obj.label);
}
if (this.type !== 'blank' && this.id) {
if (this.type !== 'blank' && this.type !== 'promotion' && this.id) {
this.getIssues().catch(() => {
// TODO: handle request error
});
......
......@@ -2,6 +2,7 @@
/* global List */
import _ from 'underscore';
import Cookies from 'js-cookie';
import boardsStoreEE from 'ee/boards/stores/boards_store_ee';
window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {};
......@@ -140,3 +141,5 @@ gl.issueBoards.BoardsStore = {
}
},
};
boardsStoreEE.initEESpecific(gl.issueBoards.BoardsStore);
......@@ -237,12 +237,20 @@
}
}
.board-blank-state {
.board-blank-state,
.board-promotion-state {
height: calc(100% - 49px);
padding: $gl-padding;
background-color: $white-light;
}
.board-promotion-state {
.btn.btn-primary {
display: block;
margin-bottom: 15px;
}
}
.board-blank-state-list {
list-style: none;
......
......@@ -514,7 +514,7 @@ ul.notes {
}
.note-actions-item {
margin-left: 15px;
margin-left: 12px;
display: flex;
align-items: center;
......@@ -618,15 +618,25 @@ ul.notes {
.note-role {
position: relative;
padding: 0 7px;
display: inline-block;
color: $notes-role-color;
font-size: 12px;
line-height: 20px;
border: 1px solid $border-color;
border-radius: $label-border-radius;
margin: 0 3px;
&.note-role-access {
padding: 0 7px;
border: 1px solid $border-color;
border-radius: $label-border-radius;
}
&.note-role-special {
text-shadow: 0 0 15px $gl-text-color-inverted;
}
}
/**
* Line note button on the side of diffs
*/
......
......@@ -76,3 +76,31 @@
max-width: 700px;
}
}
.promotion-info-weight-message {
padding: $gl-padding-top;
.dropdown-title {
margin: 0 0 10px;
}
.btn {
padding: $gl-vert-padding $gl-btn-padding;
border-radius: $border-radius-default;
display: block;
line-height: $line-height-base;
}
.btn-link {
display: inline;
color: $gl-link-color;
background-color: transparent;
padding: 0;
&:hover {
background-color: transparent;
color: $gl-link-hover-color;
}
}
}
class Admin::LogsController < Admin::ApplicationController
prepend EE::Admin::LogsController
before_action :loggers
def show
@loggers = [
end
private
def loggers
@loggers ||= [
Gitlab::AppLogger,
Gitlab::GitLogger,
Gitlab::EnvironmentLogger,
......
module RendersNotes
def prepare_notes_for_rendering(notes)
def prepare_notes_for_rendering(notes, noteable = nil)
preload_noteable_for_regular_notes(notes)
preload_max_access_for_authors(notes, @project)
preload_first_time_contribution_for_authors(noteable, notes)
Banzai::NoteRenderer.render(notes, @project, current_user)
notes
......@@ -19,4 +20,10 @@ module RendersNotes
def preload_noteable_for_regular_notes(notes)
ActiveRecord::Associations::Preloader.new.preload(notes.reject(&:for_commit?), :noteable)
end
def preload_first_time_contribution_for_authors(noteable, notes)
return unless noteable.is_a?(Issuable) && noteable.first_contribution?
notes.each {|n| n.specialize_for_first_contribution!(noteable)}
end
end
......@@ -127,7 +127,7 @@ class Projects::CommitController < Projects::ApplicationController
@discussions = commit.discussions
@notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes)
@notes = prepare_notes_for_rendering(@notes)
@notes = prepare_notes_for_rendering(@notes, @commit)
end
def assign_change_commit_vars
......
......@@ -89,7 +89,7 @@ class Projects::IssuesController < Projects::ApplicationController
@note = @project.notes.new(noteable: @issue)
@discussions = @issue.discussions
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
respond_to do |format|
format.html
......
......@@ -61,6 +61,6 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@use_legacy_diff_notes = !@merge_request.has_complete_diff_refs?
@grouped_diff_discussions = @merge_request.grouped_diff_discussions(@compare.diff_refs)
@notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes))
@notes = prepare_notes_for_rendering(@grouped_diff_discussions.values.flatten.flat_map(&:notes), @merge_request)
end
end
......@@ -63,12 +63,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
# Build a note object for comment form
@note = @project.notes.new(noteable: @merge_request)
@discussions = @merge_request.discussions
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
@noteable = @merge_request
@commits_count = @merge_request.commits_count
@discussions = @merge_request.discussions
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
labels
set_pipeline_variables
......
......@@ -64,7 +64,7 @@ class Projects::SnippetsController < Projects::ApplicationController
@noteable = @snippet
@discussions = @snippet.discussions
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
render 'show'
end
......
......@@ -66,7 +66,7 @@ class SnippetsController < ApplicationController
@noteable = @snippet
@discussions = @snippet.discussions
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes))
@notes = prepare_notes_for_rendering(@discussions.flat_map(&:notes), @noteable)
respond_to do |format|
format.html do
......
......@@ -134,6 +134,8 @@ module IssuablesHelper
end
output << "&ensp;".html_safe
output << content_tag(:span, (issuable_first_contribution_icon if issuable.first_contribution?), class: 'has-tooltip', title: _('1st contribution!'))
output << content_tag(:span, (issuable.task_status if issuable.tasks?), id: "task_status", class: "hidden-xs hidden-sm")
output << content_tag(:span, (issuable.task_status_short if issuable.tasks?), id: "task_status_short", class: "hidden-md hidden-lg")
......@@ -176,6 +178,13 @@ module IssuablesHelper
end
end
def issuable_first_contribution_icon
content_tag(:span, class: 'fa-stack') do
concat(icon('certificate', class: "fa-stack-2x"))
concat(content_tag(:strong, '1', class: 'fa-inverse fa-stack-1x'))
end
end
def assigned_issuables_count(issuable_type)
case issuable_type
when :issues
......
......@@ -73,7 +73,7 @@ module NotesHelper
end
def note_max_access_for_user(note)
note.project.team.human_max_access(note.author_id)
note.project.team.max_member_access(note.author_id)
end
def discussion_path(discussion)
......
......@@ -338,4 +338,11 @@ module Issuable
metrics = self.metrics || create_metrics
metrics.record!
end
##
# Override in issuable specialization
#
def first_contribution?
false
end
end
......@@ -2,6 +2,10 @@ module Geo
class EventLog < ActiveRecord::Base
include Geo::Model
belongs_to :repository_created_event,
class_name: 'Geo::RepositoryCreatedEvent',
foreign_key: :repository_created_event_id
belongs_to :repository_updated_event,
class_name: 'Geo::RepositoryUpdatedEvent',
foreign_key: :repository_updated_event_id
......@@ -19,7 +23,8 @@ module Geo
foreign_key: :repositories_changed_event_id
def event
repository_updated_event ||
repository_created_event ||
repository_updated_event ||
repository_deleted_event ||
repository_renamed_event ||
repositories_changed_event
......
module Geo
class RepositoryCreatedEvent < ActiveRecord::Base
include Geo::Model
belongs_to :project
validates :project, :project_name, :repo_path, :repository_storage_name,
:repository_storage_path, presence: true
end
end
......@@ -983,6 +983,12 @@ class MergeRequest < ActiveRecord::Base
Projects::OpenMergeRequestsCountService.new(target_project).refresh_cache
end
def first_contribution?
return false if project.team.max_member_access(author_id) > Gitlab::Access::GUEST
project.merge_requests.merged.where(author_id: author_id).empty?
end
private
def write_ref
......
......@@ -16,6 +16,16 @@ class Note < ActiveRecord::Base
include IgnorableColumn
include Editable
module SpecialRole
FIRST_TIME_CONTRIBUTOR = :first_time_contributor
class << self
def values
constants.map {|const| self.const_get(const)}
end
end
end
ignore_column :original_discussion_id
cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
......@@ -33,9 +43,12 @@ class Note < ActiveRecord::Base
# Banzai::ObjectRenderer
attr_accessor :user_visible_reference_count
# Attribute used to store the attributes that have ben changed by quick actions.
# Attribute used to store the attributes that have been changed by quick actions.
attr_accessor :commands_changes
# A special role that may be displayed on issuable's discussions
attr_accessor :special_role
default_value_for :system, false
attr_mentionable :note, pipeline: :note
......@@ -143,6 +156,10 @@ class Note < ActiveRecord::Base
.group(:noteable_id)
.where(noteable_type: type, noteable_id: ids)
end
def has_special_role?(role, note)
note.special_role == role
end
end
def searchable?
......@@ -212,6 +229,22 @@ class Note < ActiveRecord::Base
super(noteable_type.to_s.classify.constantize.base_class.to_s)
end
def special_role=(role)
raise "Role is undefined, #{role} not found in #{SpecialRole.values}" unless SpecialRole.values.include?(role)
@special_role = role
end
def has_special_role?(role)
self.class.has_special_role?(role, self)
end
def specialize_for_first_contribution!(noteable)
return unless noteable.author_id == self.author_id
self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
end
def editable?
!system?
end
......
......@@ -152,7 +152,7 @@ class ProjectTeam
end
def human_max_access(user_id)
Gitlab::Access.options_with_owner.key(max_member_access(user_id))
Gitlab::Access.human_access(max_member_access(user_id))
end
# Determine the maximum access level for a group of users in bulk.
......
module Geo
class RepositoryCreatedEventStore < EventStore
self.event_type = :repository_created_event
private
def build_event
Geo::RepositoryCreatedEvent.new(
project: project,
repository_storage_name: project.repository.storage,
repository_storage_path: project.repository_storage_path,
repo_path: project.disk_path,
wiki_path: "#{project.disk_path}.wiki",
project_name: project.name
)
end
end
end
- access = note_max_access_for_user(note)
- if access
%span.note-role= access
- if note.has_special_role?(Note::SpecialRole::FIRST_TIME_CONTRIBUTOR)
%span.note-role.note-role-special.has-tooltip{ title: _("This is the author's first Merge Request to this project. Handle with care.") }
= issuable_first_contribution_icon
- if access = note_max_access_for_user(note)
%span.note-role.note-role-access= Gitlab::Access.human_access(access)
- if note.resolvable?
- can_resolve = can?(current_user, :resolve_note, note)
......
......@@ -14,6 +14,7 @@
%script#js-board-template{ type: "text/x-template" }= render "shared/boards/components/board"
%script#js-board-modal-filter{ type: "text/x-template" }= render "shared/issuable/search_bar", type: :boards_modal
%script#js-board-promotion{ type: "text/x-template" }= render "shared/promotions/promote_issue_board"
- if @group
= render "groups/head_issues"
......
......@@ -23,7 +23,7 @@
"v-if" => "!list.preset && list.id" }
%button.board-delete.has-tooltip.append-right-10{ type: "button", title: "Delete list", "aria-label" => "Delete list", data: { placement: "bottom" }, "@click.stop" => "deleteBoard" }
= icon("trash")
.issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank"' }
.issue-count-badge.clearfix{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"' }
%span.issue-count-badge-count.pull-left{ ":class" => '{ "has-btn": list.type !== "closed" && !disabled }' }
{{ list.issuesSize }}
- if can?(current_user, :admin_list, current_board_parent)
......@@ -34,7 +34,7 @@
"title" => "New issue",
data: { placement: "top", container: "body" } }
= icon("plus", class: "js-no-trigger-collapse")
%board-list{ "v-if" => 'list.type !== "blank"',
%board-list{ "v-if" => 'list.type !== "blank" && list.type !== "promotion"',
":list" => "list",
":issues" => "list.issues",
":loading" => "list.loading",
......@@ -45,3 +45,4 @@
"ref" => "board-list" }
- if can?(current_user, :admin_list, current_board_parent)
%board-blank-state{ "v-if" => 'list.id == "blank"' }
%board-promotion-state{ "v-if" => 'list.id == "promotion"' }
<svg xmlns="http://www.w3.org/2000/svg" width="78" height="82" viewBox="0 0 78 82"><g fill="none" fill-rule="evenodd"><path fill="#F9F9F9" d="M2.12 42c-.08.99-.12 1.99-.12 3 0 20.435 16.565 37 37 37s37-16.565 37-37c0-1.01-.04-2.01-.12-3C74.353 61.032 58.425 76 39 76 19.575 76 3.647 61.032 2.12 42z"/><path fill="#EEE" fill-rule="nonzero" d="M39 78C17.46 78 0 60.54 0 39S17.46 0 39 0s39 17.46 39 39-17.46 39-39 39zm0-4c19.33 0 35-15.67 35-35S58.33 4 39 4 4 19.67 4 39s15.67 35 35 35z"/><g transform="translate(12 25)"><path fill="#E1DBF2" d="M3 0h10a3 3 0 0 1 3 3v22a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm1 4v20h8V4H4zm2 2h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z"/><rect width="6" height="4" x="5" y="12" fill="#6B4FBB" rx="1"/></g><g transform="translate(50 25)"><rect width="6" height="4" x="5" y="6" fill="#6B4FBB" rx="1"/><path fill="#E1DBF2" fill-rule="nonzero" d="M3 0h10a3 3 0 0 1 3 3v22a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm1 4v20h8V4H4zm2 8h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"/></g><path fill="#E1DBF2" d="M34 25h10a3 3 0 0 1 3 3v28a3 3 0 0 1-3 3H34a3 3 0 0 1-3-3V28a3 3 0 0 1 3-3zm1 4v26h8V29h-8zm2 8h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"/><path fill="#6B4FBB" d="M37 43h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1zm0-12h4a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1v-2a1 1 0 0 1 1-1z"/></g></svg>
\ No newline at end of file
......@@ -141,6 +141,7 @@
%li
%a{ href: "#", data: { id: weight, none: weight == Issue::WEIGHT_NONE }, class: ("is-active" if params[:weight] == weight.to_s) }
= weight
= render 'shared/promotions/promote_issue_weights'
- if issuable.has_attribute?(:confidential)
%script#js-confidential-issue-data{ type: "application/json" }= { is_confidential: @issue.confidential, is_editable: can_edit_issuable }.to_json.html_safe
......
---
title: Geo - Log a repository created event when a project is created
merge_request: 2807
author:
type: added
---
title: Don't put the password in the SSH remote if using public-key authentication
merge_request: 2837
author:
---
title: Show geo.log in the Admin area
merge_request: 2845
author:
type: added
---
title: "First-time contributor badge"
merge_request: 13143
author: Micaël Bergeron <micaelbergeron@gmail.com>
......@@ -18,6 +18,7 @@ class MigrateIssuesToGhostUser < ActiveRecord::Migration
ActiveRecord::Base.clear_cache!
::User.reset_column_information
::Namespace.reset_column_information
end
def up
......
class CreateGeoRepositoryCreatedEvents < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :geo_repository_created_events, id: :bigserial do |t|
t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: false
t.text :repository_storage_name, null: false
t.text :repository_storage_path, null: false
t.text :repo_path, null: false
t.text :wiki_path
t.text :project_name, null: false
end
add_column :geo_event_log, :repository_created_event_id, :integer, limit: 8
end
end
class AddIndexToGeoEventLogRepositoryCreatedEventId < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :geo_event_log, :repository_created_event_id
end
def down
if index_exists? :geo_event_log, :repository_created_event_id
remove_concurrent_index :geo_event_log, :repository_created_event_id
end
end
end
class AddGeoRepositoryCreatedEventsFkOnGeoEventLog < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key :geo_event_log, :geo_repository_created_events,
column: :repository_created_event_id, on_delete: :cascade
end
def down
remove_foreign_key :geo_event_log, column: :repository_created_event_id
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170905202320) do
ActiveRecord::Schema.define(version: 20170906160132) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -671,9 +671,11 @@ ActiveRecord::Schema.define(version: 20170905202320) do
t.integer "repository_deleted_event_id", limit: 8
t.integer "repository_renamed_event_id", limit: 8
t.integer "repositories_changed_event_id", limit: 8
t.integer "repository_created_event_id", limit: 8
end
add_index "geo_event_log", ["repositories_changed_event_id"], name: "index_geo_event_log_on_repositories_changed_event_id", using: :btree
add_index "geo_event_log", ["repository_created_event_id"], name: "index_geo_event_log_on_repository_created_event_id", using: :btree
add_index "geo_event_log", ["repository_deleted_event_id"], name: "index_geo_event_log_on_repository_deleted_event_id", using: :btree
add_index "geo_event_log", ["repository_renamed_event_id"], name: "index_geo_event_log_on_repository_renamed_event_id", using: :btree
add_index "geo_event_log", ["repository_updated_event_id"], name: "index_geo_event_log_on_repository_updated_event_id", using: :btree
......@@ -713,6 +715,17 @@ ActiveRecord::Schema.define(version: 20170905202320) do
add_index "geo_repositories_changed_events", ["geo_node_id"], name: "index_geo_repositories_changed_events_on_geo_node_id", using: :btree
create_table "geo_repository_created_events", id: :bigserial, force: :cascade do |t|
t.integer "project_id", null: false
t.text "repository_storage_name", null: false
t.text "repository_storage_path", null: false
t.text "repo_path", null: false
t.text "wiki_path"
t.text "project_name", null: false
end
add_index "geo_repository_created_events", ["project_id"], name: "index_geo_repository_created_events_on_project_id", using: :btree
create_table "geo_repository_deleted_events", id: :bigserial, force: :cascade do |t|
t.integer "project_id", null: false
t.text "repository_storage_name", null: false
......@@ -2060,12 +2073,14 @@ ActiveRecord::Schema.define(version: 20170905202320) do
add_foreign_key "events_for_migration", "users", column: "author_id", name: "fk_edfd187b6f", on_delete: :cascade
add_foreign_key "forked_project_links", "projects", column: "forked_to_project_id", name: "fk_434510edb0", on_delete: :cascade
add_foreign_key "geo_event_log", "geo_repositories_changed_events", column: "repositories_changed_event_id", name: "fk_4a99ebfd60", on_delete: :cascade
add_foreign_key "geo_event_log", "geo_repository_created_events", column: "repository_created_event_id", name: "fk_9b9afb1916", on_delete: :cascade
add_foreign_key "geo_event_log", "geo_repository_deleted_events", column: "repository_deleted_event_id", name: "fk_c4b1c1f66e", on_delete: :cascade
add_foreign_key "geo_event_log", "geo_repository_renamed_events", column: "repository_renamed_event_id", name: "fk_86c84214ec", on_delete: :cascade
add_foreign_key "geo_event_log", "geo_repository_updated_events", column: "repository_updated_event_id", on_delete: :cascade
add_foreign_key "geo_node_namespace_links", "geo_nodes", on_delete: :cascade
add_foreign_key "geo_node_namespace_links", "namespaces", on_delete: :cascade
add_foreign_key "geo_repositories_changed_events", "geo_nodes", on_delete: :cascade
add_foreign_key "geo_repository_created_events", "projects", on_delete: :cascade
add_foreign_key "geo_repository_renamed_events", "projects", on_delete: :cascade
add_foreign_key "geo_repository_updated_events", "projects", on_delete: :cascade
add_foreign_key "index_statuses", "projects", name: "fk_74b2492545", on_delete: :cascade
......
const Store = gl.issueBoards.BoardsStore;
export default {
template: '#js-board-promotion',
methods: {
clearPromotionState: Store.removePromotionState.bind(Store),
},
};
/* eslint-disable class-methods-use-this */
import Cookies from 'js-cookie';
class BoardsStoreEE {
initEESpecific(boardsStore) {
this.$boardApp = document.getElementById('board-app');
this.store = boardsStore;
this.store.addPromotionState = () => {
this.addPromotion();
};
this.store.removePromotionState = () => {
this.removePromotion();
};
}
shouldAddPromotionState() {
// Decide whether to add the promotion state
return this.$boardApp.dataset.showPromotion === 'true';
}
addPromotion() {
if (!this.shouldAddPromotionState() || this.promotionIsHidden() || this.store.disabled) return;
this.store.addList({
id: 'promotion',
list_type: 'promotion',
title: 'Improve Issue boards',
position: 0,
});
this.store.state.lists = _.sortBy(this.store.state.lists, 'position');
}
removePromotion() {
this.store.removeList('promotion', 'promotion');
Cookies.set('promotion_issue_board_hidden', 'true', {
expires: 365 * 10,
path: '',
});
}
promotionIsHidden() {
return Cookies.get('promotion_issue_board_hidden') === 'true';
}
}
export default new BoardsStoreEE();
module EE::Admin::LogsController
def loggers
raise NotImplementedError unless defined?(super)
@loggers ||= super + [
Gitlab::GeoLogger
]
end
end
......@@ -48,7 +48,9 @@ module EE
end
def board_sidebar_user_data
super.merge(group_id: @group&.id)
super.merge(group_id: @group&.id,
focus_mode_available: @project.feature_available?(:issue_board_focus_mode).to_s,
show_promotion: (show_promotions? && (!@project.feature_available?(:multiple_issue_boards) || !@project.feature_available?(:issue_board_milestone) || !@project.feature_available?(:issue_board_focus_mode))).to_s)
end
end
end
......@@ -156,7 +156,15 @@ module EE
def fetch_mirror
return unless mirror?
repository.fetch_upstream(self.import_url)
# Only send the password if it's needed
url =
if import_data&.password_auth?
import_url
else
username_only_import_url
end
repository.fetch_upstream(url)
end
def can_override_approvers?
......
......@@ -32,6 +32,10 @@ module EE
ssh_import? && auth_method == 'ssh_public_key'
end
def password_auth?
auth_method == 'password'
end
def ssh_import?
project&.import_url&.start_with?('ssh://')
end
......
......@@ -9,7 +9,7 @@ module EE
mirror_user_id = params.delete(:mirror_user_id)
mirror_trigger_builds = params.delete(:mirror_trigger_builds)
super do |project|
project = super do |project|
# Repository size limit comes as MB from the view
project.repository_size_limit = ::Gitlab::Utils.try_megabytes_to_bytes(limit) if limit
......@@ -19,10 +19,18 @@ module EE
project.mirror_user_id = mirror_user_id
end
end
log_geo_event(project) if project&.persisted?
project
end
private
def log_geo_event(project)
::Geo::RepositoryCreatedEventStore.new(project).create
end
def after_create_actions
raise NotImplementedError unless defined?(super)
......
.board-promotion-state
.svg-container.center
= custom_icon('icon_issue_board')
%p
- if current_application_settings.should_check_namespace_plan?
= _('Upgrade your plan to improve Issue boards.')
- else
= _('Improve Issue boards with GitLab Enterprise Edition.')
%ul
- unless @project.feature_available?(:multiple_issue_boards)
%li
= link_to _('Multiple issue boards'), help_page_path('user/project/issue_board.html', anchor:'use-cases-for-multiple-issue-boards'), target: '_blank'
- unless @project.feature_available?(:issue_board_milestone)
%li
= link_to _('Issue boards with milestones'), help_page_path('user/project/issue_board.html', anchor:'board-with-a-milestone'), target: '_blank'
- unless @project.feature_available?(:issue_board_focus_mode)
%li
= link_to _('Issue board focus mode'), help_page_path('user/project/issue_board.html', anchor:'focus-mode'), target: '_blank'
= render 'shared/promotions/promotion_link_project'
.top-space
%button.btn.btn-default.btn-block#hide-btn{ :href => "#", "@click.stop" => "clearPromotionState" }
= _("Thanks! Don't show me this again")
- if show_promotions? && !@project.feature_available?(:issue_weights)
.block.weight
.sidebar-collapsed-icon{ data: { toggle: "dropdown", target: ".weight" } }
= icon('balance-scale')
%span
No
.title.hide-collapsed
= _('Weight')
= link_to _('Edit'), '#', class: 'edit-link promote-weight-link pull-right', data: { toggle: "dropdown", target: ".weight" }
.promotion-info-weight.dropdown
.dropdown-menu.promotion-info-weight-message
.dropdown-title
%span
= _('Change Weight')
%button.dropdown-title-button.dropdown-menu-close{ "aria-label" => _('Close'), :type => "button" }
%i.fa.fa-times.dropdown-menu-close-icon{ "aria-hidden" => "true", "data-hidden" => "true" }
%div
%p
- if current_application_settings.should_check_namespace_plan?
= _('Upgrade your plan to activate Issue weight.')
- else
= _('Improve issues management with Issue weight and GitLab Enterprise Edition.')
= link_to _('Read more'), help_page_path('workflow/issue_weight.html'), class: 'btn-link', target: '_blank'
%div
= render 'shared/promotions/promotion_link_project', short_form: true
.hide-collapsed
%span.no-value
= _('None')
- short_form = local_assigns.fetch :short_form, false
- if current_application_settings.should_check_namespace_plan?
- if can?(current_user, :admin_namespace, @project.namespace)
= link_to 'Upgrade your plan', upgrade_plan_url, class: 'btn btn-primary'
......@@ -8,9 +9,9 @@
%p Contact owner #{ link_to(owner.name, user_path(owner)) } to upgrade the plan.
- elsif current_user&.admin?
- if License.current&.expired?
= link_to 'Buy GitLab Enterprise Edition', Gitlab::SUBSCRIPTIONS_PLANS_URL, class: 'btn btn-primary'
= link_to (!short_form ? 'Buy GitLab Enterprise Edition' : 'Buy EE'), Gitlab::SUBSCRIPTIONS_PLANS_URL, class: 'btn btn-primary'
- else
= link_to 'Start GitLab Enterprise Edition trial', new_trial_url, class: 'btn btn-primary'
= link_to (!short_form ? 'Start GitLab Enterprise Edition trial' : 'Start GitLab EE trial'), new_trial_url, class: 'btn btn-primary'
- else
%p
Contact your Administrator to upgrade your license.
module Gitlab
class GeoLogger < Gitlab::Logger
def self.file_name_noext
'geo'
end
end
end
......@@ -67,10 +67,14 @@ module Gitlab
def protection_values
protection_options.values
end
def human_access(access)
options_with_owner.key(access)
end
end
def human_access
Gitlab::Access.options_with_owner.key(access_field)
Gitlab::Access.human_access(access_field)
end
def owner?
......
......@@ -66,6 +66,8 @@ module Gitlab
if event_log.repository_updated_event
handle_repository_update(event_log)
elsif event_log.repository_created_event
handle_repository_created(event_log)
elsif event_log.repository_deleted_event
handle_repository_delete(event_log)
elsif event_log.repositories_changed_event
......@@ -104,6 +106,24 @@ module Gitlab
Gitlab::Geo.current_node&.projects_include?(event_log.project_id)
end
def handle_repository_created(event_log)
created_event = event_log.repository_created_event
registry = ::Geo::ProjectRegistry.find_or_initialize_by(project_id: created_event.project_id)
registry.resync_repository = true
registry.resync_wiki = created_event.wiki_path.present?
log_event_info(
event_log.created_at,
message: 'Repository created',
project_id: created_event.project_id,
repo_path: created_event.repo_path,
wiki_path: created_event.wiki_path,
resync_repository: registry.resync_repository,
resync_wiki: registry.resync_wiki)
registry.save!
end
def handle_repository_update(event)
updated_event = event.repository_updated_event
registry = ::Geo::ProjectRegistry.find_or_initialize_by(project_id: updated_event.project_id)
......
......@@ -56,6 +56,28 @@ describe Projects::MergeRequestsController do
expect(response).to be_success
end
context "loads notes" do
let(:first_contributor) { create(:user) }
let(:contributor) { create(:user) }
let(:merge_request) { create(:merge_request, author: first_contributor, target_project: project, source_project: project) }
let(:contributor_merge_request) { create(:merge_request, :merged, author: contributor, target_project: project, source_project: project) }
# the order here is important
# as the controller reloads these from DB, references doesn't correspond after
let!(:first_contributor_note) { create(:note, author: first_contributor, noteable: merge_request, project: project) }
let!(:contributor_note) { create(:note, author: contributor, noteable: merge_request, project: project) }
let!(:owner_note) { create(:note, author: user, noteable: merge_request, project: project) }
it "with special_role FIRST_TIME_CONTRIBUTOR" do
go(format: :html)
notes = assigns(:notes)
expect(notes).to match(a_collection_containing_exactly(an_object_having_attributes(special_role: Note::SpecialRole::FIRST_TIME_CONTRIBUTOR),
an_object_having_attributes(special_role: nil),
an_object_having_attributes(special_role: nil)
))
end
end
end
describe 'as json' do
......
require 'spec_helper'
describe 'Admin browses logs' do
before do
sign_in(create(:admin))
end
it 'shows available log files' do
visit admin_logs_path
expect(page).to have_link 'geo.log'
end
end
......@@ -198,6 +198,24 @@ describe Project do
end
end
describe '#fetch_mirror' do
where(:import_url, :auth_method, :expected) do
'http://foo:bar@example.com' | 'password' | 'http://foo:bar@example.com'
'ssh://foo:bar@example.com' | 'password' | 'ssh://foo:bar@example.com'
'ssh://foo:bar@example.com' | 'ssh_public_key' | 'ssh://foo@example.com'
end
with_them do
let(:project) { build(:project, :mirror, import_url: import_url, import_data_attributes: { auth_method: auth_method } ) }
it do
expect(project.repository).to receive(:fetch_upstream).with(expected)
project.fetch_mirror
end
end
end
describe '#mirror_waiting_duration' do
it 'returns in seconds the time spent in the queue' do
project = create(:project, :mirror, :import_scheduled)
......
......@@ -134,7 +134,24 @@ describe Projects::CreateService, '#execute' do
end
end
context 'when running on a primary node' do
let!(:geo_node) { create(:geo_node, :primary, :current) }
it 'logs an event to the Geo event log' do
expect { create_project(user, opts) }.to change(Geo::RepositoryCreatedEvent, :count).by(1)
end
it 'does not log event to the Geo log if project creation fails' do
failing_opts = {
name: nil,
namespace: user.namespace
}
expect { create_project(user, failing_opts) }.not_to change(Geo::RepositoryCreatedEvent, :count)
end
end
def create_project(user, opts)
Projects::CreateService.new(user, opts).execute
described_class.new(user, opts).execute
end
end
FactoryGirl.define do
factory :geo_event_log, class: Geo::EventLog do
trait :created_event do
repository_created_event factory: :geo_repository_created_event
end
trait :updated_event do
repository_updated_event factory: :geo_repository_updated_event
end
......@@ -13,11 +17,22 @@ FactoryGirl.define do
end
end
factory :geo_repository_created_event, class: Geo::RepositoryCreatedEvent do
project
repository_storage_name { project.repository_storage }
repository_storage_path { project.repository_storage_path }
add_attribute(:repo_path) { project.disk_path }
project_name { project.name }
wiki_path { "{project.disk_path}.wiki" }
end
factory :geo_repository_updated_event, class: Geo::RepositoryUpdatedEvent do
project
source 0
branches_affected 0
tags_affected 0
project
end
factory :geo_repository_deleted_event, class: Geo::RepositoryDeletedEvent do
......
......@@ -8,8 +8,10 @@ describe 'Admin browses logs' do
it 'shows available log files' do
visit admin_logs_path
expect(page).to have_content 'test.log'
expect(page).to have_content 'githost.log'
expect(page).to have_content 'application.log'
expect(page).to have_link 'application.log'
expect(page).to have_link 'githost.log'
expect(page).to have_link 'test.log'
expect(page).to have_link 'sidekiq.log'
expect(page).to have_link 'repocheck.log'
end
end
......@@ -115,9 +115,9 @@ feature 'Dashboard Projects' do
expect(page).to have_selector('.merge-request-form')
expect(current_path).to eq project_new_merge_request_path(project)
expect(find('#merge_request_target_project_id').value).to eq project.id.to_s
expect(find('input#merge_request_source_branch').value).to eq 'feature'
expect(find('input#merge_request_target_branch').value).to eq 'master'
expect(find('#merge_request_target_project_id', visible: false).value).to eq project.id.to_s
expect(find('input#merge_request_source_branch', visible: false).value).to eq 'feature'
expect(find('input#merge_request_target_branch', visible: false).value).to eq 'master'
end
end
end
......@@ -139,7 +139,7 @@ feature 'Diff note avatars', js: true do
end
page.within find("[id='#{position.line_code(project.repository)}']") do
find('.diff-notes-collapse').click
find('.diff-notes-collapse').trigger('click')
expect(page).to have_selector('img.js-diff-comment-avatar', count: 2)
end
......@@ -152,7 +152,7 @@ feature 'Diff note avatars', js: true do
page.within '.js-discussion-note-form' do
find('.js-note-text').native.send_keys('Test')
find('.js-comment-button').trigger 'click'
find('.js-comment-button').trigger('click')
wait_for_requests
end
......
......@@ -196,10 +196,11 @@ feature 'Diff notes resolve', js: true do
end
it 'does not mark discussion as resolved when resolving single note' do
page.first '.diff-content .note' do
page.within("#note_#{note.id}") do
first('.line-resolve-btn').click
expect(page).to have_selector('.note-action-button .loading')
wait_for_requests
expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
end
......
......@@ -14,7 +14,7 @@ feature 'User wants to create a file' do
file_name = find('#file_name')
file_name.set options[:file_name] || 'README.md'
file_content = find('#file-content')
file_content = find('#file-content', visible: false)
file_content.set options[:file_content] || 'Some content'
click_button 'Commit changes'
......
......@@ -204,6 +204,33 @@ describe 'Promotions', js: true do
end
end
describe 'for issue boards ', js: true do
before do
stub_application_setting(check_namespace_plan: true)
allow(Gitlab).to receive(:com?) { true }
project.team << [user, :master]
sign_in(user)
end
it 'should appear in milestone page' do
visit project_boards_path(project)
expect(find('.board-promotion-state')).to have_content "Upgrade your plan to improve Issue boards"
end
it 'does not show when cookie is set' do
visit project_boards_path(project)
within('.board-promotion-state') do
find('#hide-btn').trigger('click')
end
visit project_boards_path(project, milestone)
expect(page).not_to have_selector('.board-promotion-state')
end
end
describe 'for issue export', js: true do
before do
allow(License).to receive(:current).and_return(nil)
......@@ -220,6 +247,23 @@ describe 'Promotions', js: true do
end
end
describe 'for issue weight', js: true do
before do
allow(License).to receive(:current).and_return(nil)
stub_application_setting(check_namespace_plan: false)
project.team << [user, :master]
sign_in(user)
end
it 'should appear on the page', js: true do
visit project_issue_path(project, issue)
wait_for_requests
find('.promote-weight-link').click
expect(find('.promotion-info-weight-message')).to have_content 'Improve issues management with Issue weight and GitLab Enterprise Edition'
end
end
describe 'for project audit events', js: true do
before do
allow(License).to receive(:current).and_return(nil)
......
......@@ -295,7 +295,7 @@ describe "Search" do
fill_in 'search', with: 'foo'
click_button 'Search'
expect(find('#group_id').value).to eq(project.namespace.id.to_s)
expect(find('#group_id', visible: false).value).to eq(project.namespace.id.to_s)
end
it 'preserves the project being searched in' do
......@@ -304,7 +304,7 @@ describe "Search" do
fill_in 'search', with: 'foo'
click_button 'Search'
expect(find('#project_id').value).to eq(project.id.to_s)
expect(find('#project_id', visible: false).value).to eq(project.id.to_s)
end
end
end
......@@ -23,10 +23,10 @@ describe NotesHelper do
end
describe "#notes_max_access_for_users" do
it 'returns human access levels' do
expect(helper.note_max_access_for_user(owner_note)).to eq('Owner')
expect(helper.note_max_access_for_user(master_note)).to eq('Master')
expect(helper.note_max_access_for_user(reporter_note)).to eq('Reporter')
it 'returns access levels' do
expect(helper.note_max_access_for_user(owner_note)).to eq(Gitlab::Access::OWNER)
expect(helper.note_max_access_for_user(master_note)).to eq(Gitlab::Access::MASTER)
expect(helper.note_max_access_for_user(reporter_note)).to eq(Gitlab::Access::REPORTER)
end
it 'handles access in different projects' do
......@@ -34,8 +34,8 @@ describe NotesHelper do
second_project.team << [master, :reporter]
other_note = create(:note, author: master, project: second_project)
expect(helper.note_max_access_for_user(master_note)).to eq('Master')
expect(helper.note_max_access_for_user(other_note)).to eq('Reporter')
expect(helper.note_max_access_for_user(master_note)).to eq(Gitlab::Access::MASTER)
expect(helper.note_max_access_for_user(other_note)).to eq(Gitlab::Access::REPORTER)
end
end
......
......@@ -23,6 +23,38 @@ describe Gitlab::Geo::LogCursor::Daemon, :postgresql do
end
end
context 'when replaying a repository created event' do
let(:repository_created_event) { create(:geo_repository_created_event) }
let(:event_log) { create(:geo_event_log, repository_created_event: repository_created_event) }
let!(:event_log_state) { create(:geo_event_log_state, event_id: event_log.id - 1) }
before do
allow(subject).to receive(:exit?).and_return(false, true)
end
it 'creates a new project registry' do
expect { subject.run! }.to change(Geo::ProjectRegistry, :count).by(1)
end
it 'sets resync attributes to true' do
subject.run!
registry = Geo::ProjectRegistry.last
expect(registry).to have_attributes(resync_repository: true, resync_wiki: true)
end
it 'sets resync_wiki to false if wiki_path is nil' do
repository_created_event.update_attribute(:wiki_path, nil)
subject.run!
registry = Geo::ProjectRegistry.last
expect(registry).to have_attributes(resync_repository: true, resync_wiki: false)
end
end
context 'when replaying a repository updated event' do
let(:event_log) { create(:geo_event_log, :updated_event) }
let!(:event_log_state) { create(:geo_event_log_state, event_id: event_log.id - 1) }
......
......@@ -487,4 +487,71 @@ describe Issuable do
end
end
end
describe '#first_contribution?' do
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:other_project) { create(:project) }
let(:owner) { create(:owner) }
let(:master) { create(:user) }
let(:reporter) { create(:user) }
let(:guest) { create(:user) }
let(:contributor) { create(:user) }
let(:first_time_contributor) { create(:user) }
before do
group.add_owner(owner)
project.add_master(master)
project.add_reporter(reporter)
project.add_guest(guest)
project.add_guest(contributor)
project.add_guest(first_time_contributor)
end
let(:merged_mr) { create(:merge_request, :merged, author: contributor, target_project: project, source_project: project) }
let(:open_mr) { create(:merge_request, author: first_time_contributor, target_project: project, source_project: project) }
let(:merged_mr_other_project) { create(:merge_request, :merged, author: first_time_contributor, target_project: other_project, source_project: other_project) }
context "for merge requests" do
it "is false for MASTER" do
mr = create(:merge_request, author: master, target_project: project, source_project: project)
expect(mr).not_to be_first_contribution
end
it "is false for OWNER" do
mr = create(:merge_request, author: owner, target_project: project, source_project: project)
expect(mr).not_to be_first_contribution
end
it "is false for REPORTER" do
mr = create(:merge_request, author: reporter, target_project: project, source_project: project)
expect(mr).not_to be_first_contribution
end
it "is true when you don't have any merged MR" do
expect(open_mr).to be_first_contribution
expect(merged_mr).not_to be_first_contribution
end
it "handles multiple projects separately" do
expect(open_mr).to be_first_contribution
expect(merged_mr_other_project).not_to be_first_contribution
end
end
context "for issues" do
let(:contributor_issue) { create(:issue, author: contributor, project: project) }
let(:first_time_contributor_issue) { create(:issue, author: first_time_contributor, project: project) }
it "is false even without merged MR" do
expect(merged_mr).to be
expect(first_time_contributor_issue).not_to be_first_contribution
expect(contributor_issue).not_to be_first_contribution
end
end
end
end
......@@ -2,10 +2,11 @@ require 'spec_helper'
RSpec.describe Geo::EventLog, type: :model do
describe 'relationships' do
it { is_expected.to belong_to(:repository_updated_event).class_name('Geo::RepositoryUpdatedEvent').with_foreign_key('repository_updated_event_id') }
it { is_expected.to belong_to(:repositories_changed_event).class_name('Geo::RepositoriesChangedEvent').with_foreign_key('repositories_changed_event_id') }
it { is_expected.to belong_to(:repository_created_event).class_name('Geo::RepositoryCreatedEvent').with_foreign_key('repository_created_event_id') }
it { is_expected.to belong_to(:repository_deleted_event).class_name('Geo::RepositoryDeletedEvent').with_foreign_key('repository_deleted_event_id') }
it { is_expected.to belong_to(:repository_renamed_event).class_name('Geo::RepositoryRenamedEvent').with_foreign_key('repository_renamed_event_id') }
it { is_expected.to belong_to(:repositories_changed_event).class_name('Geo::RepositoriesChangedEvent').with_foreign_key('repositories_changed_event_id') }
it { is_expected.to belong_to(:repository_updated_event).class_name('Geo::RepositoryUpdatedEvent').with_foreign_key('repository_updated_event_id') }
end
describe '#event' do
......@@ -13,6 +14,13 @@ RSpec.describe Geo::EventLog, type: :model do
expect(subject.event).to be_nil
end
it 'returns repository_created_event when set' do
repository_created_event = build(:geo_repository_created_event)
subject.repository_created_event = repository_created_event
expect(subject.event).to eq repository_created_event
end
it 'returns repository_updated_event when set' do
repository_updated_event = build(:geo_repository_updated_event)
subject.repository_updated_event = repository_updated_event
......
require 'spec_helper'
describe Geo::RepositoryCreatedEvent, type: :model do
describe 'relationships' do
it { is_expected.to belong_to(:project) }
end
describe 'validations' do
it { is_expected.to validate_presence_of(:project) }
it { is_expected.to validate_presence_of(:project_name) }
it { is_expected.to validate_presence_of(:repo_path) }
it { is_expected.to validate_presence_of(:repository_storage_name) }
it { is_expected.to validate_presence_of(:repository_storage_path) }
end
end
require 'spec_helper'
describe Geo::RepositoryCreatedEventStore do
let(:project) { create(:project) }
subject(:event) { described_class.new(project) }
describe '#create' do
it 'does not create an event when not running on a primary node' do
allow(Gitlab::Geo).to receive(:primary?) { false }
expect { event.create }.not_to change(Geo::RepositoryCreatedEvent, :count)
end
context 'when running on a primary node' do
before do
allow(Gitlab::Geo).to receive(:primary?) { true }
end
it 'creates a created event' do
expect { event.create }.to change(Geo::RepositoryCreatedEvent, :count).by(1)
end
it 'tracks information for the created project' do
event.create
event = Geo::RepositoryCreatedEvent.last
expect(event).to have_attributes(
project_id: project.id,
repo_path: project.disk_path,
wiki_path: "#{project.disk_path}.wiki",
project_name: project.name,
repository_storage_name: project.repository_storage,
repository_storage_path: project.repository_storage_path
)
end
end
end
end
......@@ -33,11 +33,11 @@ describe 'shared/issuable/_approvals.html.haml' do
end
it 'shows select approvers field' do
expect(rendered).to have_css('#merge_request_approver_ids')
expect(rendered).to have_css('#merge_request_approver_ids', visible: false)
end
it 'shows select approver groups field' do
expect(rendered).to have_css('#merge_request_approver_group_ids')
expect(rendered).to have_css('#merge_request_approver_group_ids', visible: false)
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