Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
616129d41c
commit
4c788f43cb
|
@ -13,3 +13,6 @@ indent_size = 2
|
||||||
[*.{js,json,vue,scss,rb,haml,yml,md}]
|
[*.{js,json,vue,scss,rb,haml,yml,md}]
|
||||||
indent_style = space
|
indent_style = space
|
||||||
charset = utf-8
|
charset = utf-8
|
||||||
|
|
||||||
|
[*.{md,markdown}]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
|
@ -83,13 +83,7 @@ class List {
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
const index = boardsStore.state.lists.indexOf(this);
|
boardsStore.destroy(this);
|
||||||
boardsStore.state.lists.splice(index, 1);
|
|
||||||
boardsStore.updateNewListDropdown(this.id);
|
|
||||||
|
|
||||||
boardsStore.destroyList(this.id).catch(() => {
|
|
||||||
// TODO: handle request error
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
|
|
|
@ -547,6 +547,15 @@ const boardsStore = {
|
||||||
destroyList(id) {
|
destroyList(id) {
|
||||||
return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`);
|
return axios.delete(`${this.state.endpoints.listsEndpoint}/${id}`);
|
||||||
},
|
},
|
||||||
|
destroy(list) {
|
||||||
|
const index = this.state.lists.indexOf(list);
|
||||||
|
this.state.lists.splice(index, 1);
|
||||||
|
this.updateNewListDropdown(list.id);
|
||||||
|
|
||||||
|
this.destroyList(list.id).catch(() => {
|
||||||
|
// TODO: handle request error
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
saveList(list) {
|
saveList(list) {
|
||||||
const entity = list.label || list.assignee || list.milestone;
|
const entity = list.label || list.assignee || list.milestone;
|
||||||
|
|
|
@ -1,3 +1,10 @@
|
||||||
export const dateFormatMask = 'mmm dd HH:MM:ss.l';
|
export const dateFormatMask = 'mmm dd HH:MM:ss.l';
|
||||||
|
|
||||||
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
|
export const TOKEN_TYPE_POD_NAME = 'TOKEN_TYPE_POD_NAME';
|
||||||
|
|
||||||
|
export const tracking = {
|
||||||
|
USED_SEARCH_BAR: 'used_search_bar',
|
||||||
|
POD_LOG_CHANGED: 'pod_log_changed',
|
||||||
|
TIME_RANGE_SET: 'time_range_set',
|
||||||
|
ENVIRONMENT_SELECTED: 'environment_selected',
|
||||||
|
};
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
import Tracking from '~/tracking';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of 1 in count, means there was one action performed
|
||||||
|
* related to the tracked action, in either of the following categories
|
||||||
|
* 1. Refreshing the logs
|
||||||
|
* 2. Select an environment
|
||||||
|
* 3. Change the time range
|
||||||
|
* 4. Use the search bar
|
||||||
|
*/
|
||||||
|
const trackLogs = label =>
|
||||||
|
Tracking.event(document.body.dataset.page, 'logs_view', {
|
||||||
|
label,
|
||||||
|
property: 'count',
|
||||||
|
value: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default trackLogs;
|
|
@ -2,7 +2,8 @@ import { backOff } from '~/lib/utils/common_utils';
|
||||||
import httpStatusCodes from '~/lib/utils/http_status';
|
import httpStatusCodes from '~/lib/utils/http_status';
|
||||||
import axios from '~/lib/utils/axios_utils';
|
import axios from '~/lib/utils/axios_utils';
|
||||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||||
import { TOKEN_TYPE_POD_NAME } from '../constants';
|
import { TOKEN_TYPE_POD_NAME, tracking } from '../constants';
|
||||||
|
import trackLogs from '../logs_tracking_helper';
|
||||||
|
|
||||||
import * as types from './mutation_types';
|
import * as types from './mutation_types';
|
||||||
|
|
||||||
|
@ -81,22 +82,22 @@ export const showFilteredLogs = ({ dispatch, commit }, filters = []) => {
|
||||||
commit(types.SET_CURRENT_POD_NAME, podName);
|
commit(types.SET_CURRENT_POD_NAME, podName);
|
||||||
commit(types.SET_SEARCH, search);
|
commit(types.SET_SEARCH, search);
|
||||||
|
|
||||||
dispatch('fetchLogs');
|
dispatch('fetchLogs', tracking.USED_SEARCH_BAR);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showPodLogs = ({ dispatch, commit }, podName) => {
|
export const showPodLogs = ({ dispatch, commit }, podName) => {
|
||||||
commit(types.SET_CURRENT_POD_NAME, podName);
|
commit(types.SET_CURRENT_POD_NAME, podName);
|
||||||
dispatch('fetchLogs');
|
dispatch('fetchLogs', tracking.POD_LOG_CHANGED);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setTimeRange = ({ dispatch, commit }, timeRange) => {
|
export const setTimeRange = ({ dispatch, commit }, timeRange) => {
|
||||||
commit(types.SET_TIME_RANGE, timeRange);
|
commit(types.SET_TIME_RANGE, timeRange);
|
||||||
dispatch('fetchLogs');
|
dispatch('fetchLogs', tracking.TIME_RANGE_SET);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
|
export const showEnvironment = ({ dispatch, commit }, environmentName) => {
|
||||||
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
|
commit(types.SET_PROJECT_ENVIRONMENT, environmentName);
|
||||||
dispatch('fetchLogs');
|
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -111,19 +112,22 @@ export const fetchEnvironments = ({ commit, dispatch }, environmentsPath) => {
|
||||||
.get(environmentsPath)
|
.get(environmentsPath)
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
|
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data.environments);
|
||||||
dispatch('fetchLogs');
|
dispatch('fetchLogs', tracking.ENVIRONMENT_SELECTED);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
|
commit(types.RECEIVE_ENVIRONMENTS_DATA_ERROR);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchLogs = ({ commit, state }) => {
|
export const fetchLogs = ({ commit, state }, trackingLabel) => {
|
||||||
commit(types.REQUEST_LOGS_DATA);
|
commit(types.REQUEST_LOGS_DATA);
|
||||||
|
|
||||||
return requestLogsUntilData({ commit, state })
|
return requestLogsUntilData({ commit, state })
|
||||||
.then(({ data }) => {
|
.then(({ data }) => {
|
||||||
const { pod_name, pods, logs, cursor } = data;
|
const { pod_name, pods, logs, cursor } = data;
|
||||||
|
if (logs && logs.length > 0) {
|
||||||
|
trackLogs(trackingLabel);
|
||||||
|
}
|
||||||
commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
|
commit(types.RECEIVE_LOGS_DATA_SUCCESS, { logs, cursor });
|
||||||
commit(types.SET_CURRENT_POD_NAME, pod_name);
|
commit(types.SET_CURRENT_POD_NAME, pod_name);
|
||||||
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
|
commit(types.RECEIVE_PODS_DATA_SUCCESS, pods);
|
||||||
|
|
|
@ -1,24 +1,12 @@
|
||||||
<script>
|
<script>
|
||||||
import { GlPagination, GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
|
import { GlPagination } from '@gitlab/ui';
|
||||||
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
import ImageListRow from './image_list_row.vue';
|
||||||
|
|
||||||
import {
|
|
||||||
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
|
|
||||||
LIST_DELETE_BUTTON_DISABLED,
|
|
||||||
REMOVE_REPOSITORY_LABEL,
|
|
||||||
ROW_SCHEDULED_FOR_DELETION,
|
|
||||||
} from '../constants';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'ImageList',
|
name: 'ImageList',
|
||||||
components: {
|
components: {
|
||||||
GlPagination,
|
GlPagination,
|
||||||
ClipboardButton,
|
ImageListRow,
|
||||||
GlDeprecatedButton,
|
|
||||||
GlIcon,
|
|
||||||
},
|
|
||||||
directives: {
|
|
||||||
GlTooltip: GlTooltipDirective,
|
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
images: {
|
images: {
|
||||||
|
@ -30,12 +18,6 @@ export default {
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
i18n: {
|
|
||||||
LIST_DELETE_BUTTON_DISABLED,
|
|
||||||
REMOVE_REPOSITORY_LABEL,
|
|
||||||
ROW_SCHEDULED_FOR_DELETION,
|
|
||||||
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
currentPage: {
|
currentPage: {
|
||||||
get() {
|
get() {
|
||||||
|
@ -46,79 +28,25 @@ export default {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
|
||||||
encodeListItem(item) {
|
|
||||||
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
|
|
||||||
return window.btoa(params);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="gl-display-flex gl-flex-direction-column">
|
<div class="gl-display-flex gl-flex-direction-column">
|
||||||
<div
|
<image-list-row
|
||||||
v-for="(listItem, index) in images"
|
v-for="(listItem, index) in images"
|
||||||
:key="index"
|
:key="index"
|
||||||
v-gl-tooltip="{
|
:item="listItem"
|
||||||
placement: 'left',
|
:show-top-border="index === 0"
|
||||||
disabled: !listItem.deleting,
|
@delete="$emit('delete', $event)"
|
||||||
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
|
/>
|
||||||
}"
|
|
||||||
data-testid="rowItem"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 border-bottom"
|
|
||||||
:class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
|
|
||||||
>
|
|
||||||
<div class="gl-display-flex gl-align-items-center">
|
|
||||||
<router-link
|
|
||||||
data-testid="detailsLink"
|
|
||||||
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
|
|
||||||
>
|
|
||||||
{{ listItem.path }}
|
|
||||||
</router-link>
|
|
||||||
<clipboard-button
|
|
||||||
v-if="listItem.location"
|
|
||||||
:disabled="listItem.deleting"
|
|
||||||
:text="listItem.location"
|
|
||||||
:title="listItem.location"
|
|
||||||
css-class="btn-default btn-transparent btn-clipboard"
|
|
||||||
/>
|
|
||||||
<gl-icon
|
|
||||||
v-if="listItem.failedDelete"
|
|
||||||
v-gl-tooltip
|
|
||||||
:title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE"
|
|
||||||
name="warning"
|
|
||||||
class="text-warning align-middle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-gl-tooltip="{ disabled: listItem.destroy_path }"
|
|
||||||
class="d-none d-sm-block"
|
|
||||||
:title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
|
|
||||||
>
|
|
||||||
<gl-deprecated-button
|
|
||||||
v-gl-tooltip
|
|
||||||
data-testid="deleteImageButton"
|
|
||||||
:disabled="!listItem.destroy_path || listItem.deleting"
|
|
||||||
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
|
|
||||||
:aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
|
|
||||||
class="btn-inverted"
|
|
||||||
variant="danger"
|
|
||||||
@click="$emit('delete', listItem)"
|
|
||||||
>
|
|
||||||
<gl-icon name="remove" />
|
|
||||||
</gl-deprecated-button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<gl-pagination
|
<gl-pagination
|
||||||
v-model="currentPage"
|
v-model="currentPage"
|
||||||
:per-page="pagination.perPage"
|
:per-page="pagination.perPage"
|
||||||
:total-items="pagination.total"
|
:total-items="pagination.total"
|
||||||
align="center"
|
align="center"
|
||||||
class="w-100 gl-mt-2"
|
class="w-100 gl-mt-3"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
<script>
|
||||||
|
import { GlTooltipDirective, GlButton, GlIcon, GlSprintf } from '@gitlab/ui';
|
||||||
|
import { n__ } from '~/locale';
|
||||||
|
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
|
||||||
|
LIST_DELETE_BUTTON_DISABLED,
|
||||||
|
REMOVE_REPOSITORY_LABEL,
|
||||||
|
ROW_SCHEDULED_FOR_DELETION,
|
||||||
|
} from '../constants';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ImageListrow',
|
||||||
|
components: {
|
||||||
|
ClipboardButton,
|
||||||
|
GlButton,
|
||||||
|
GlSprintf,
|
||||||
|
GlIcon,
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
GlTooltip: GlTooltipDirective,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
showTopBorder: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
LIST_DELETE_BUTTON_DISABLED,
|
||||||
|
REMOVE_REPOSITORY_LABEL,
|
||||||
|
ROW_SCHEDULED_FOR_DELETION,
|
||||||
|
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
encodedItem() {
|
||||||
|
const params = JSON.stringify({
|
||||||
|
name: this.item.path,
|
||||||
|
tags_path: this.item.tags_path,
|
||||||
|
id: this.item.id,
|
||||||
|
});
|
||||||
|
return window.btoa(params);
|
||||||
|
},
|
||||||
|
disabledDelete() {
|
||||||
|
return !this.item.destroy_path || this.item.deleting;
|
||||||
|
},
|
||||||
|
tagsCountText() {
|
||||||
|
return n__(
|
||||||
|
'ContainerRegistry|%{count} Tag',
|
||||||
|
'ContainerRegistry|%{count} Tags',
|
||||||
|
this.item.tags_count,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-gl-tooltip="{
|
||||||
|
placement: 'left',
|
||||||
|
disabled: !item.deleting,
|
||||||
|
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4 "
|
||||||
|
:class="{
|
||||||
|
'gl-border-t-solid gl-border-t-1': showTopBorder,
|
||||||
|
'disabled-content': item.deleting,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="gl-display-flex gl-flex-direction-column">
|
||||||
|
<div class="gl-display-flex gl-align-items-center">
|
||||||
|
<router-link
|
||||||
|
class="gl-text-black-normal gl-font-weight-bold"
|
||||||
|
data-testid="detailsLink"
|
||||||
|
:to="{ name: 'details', params: { id: encodedItem } }"
|
||||||
|
>
|
||||||
|
{{ item.path }}
|
||||||
|
</router-link>
|
||||||
|
<clipboard-button
|
||||||
|
v-if="item.location"
|
||||||
|
:disabled="item.deleting"
|
||||||
|
:text="item.location"
|
||||||
|
:title="item.location"
|
||||||
|
css-class="btn-default btn-transparent btn-clipboard gl-text-gray-500"
|
||||||
|
/>
|
||||||
|
<gl-icon
|
||||||
|
v-if="item.failedDelete"
|
||||||
|
v-gl-tooltip
|
||||||
|
:title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE"
|
||||||
|
name="warning"
|
||||||
|
class="text-warning"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="gl-font-sm gl-text-gray-500">
|
||||||
|
<span class="gl-display-flex gl-align-items-center" data-testid="tagsCount">
|
||||||
|
<gl-icon name="tag" class="gl-mr-2" />
|
||||||
|
<gl-sprintf :message="tagsCountText">
|
||||||
|
<template #count>
|
||||||
|
{{ item.tags_count }}
|
||||||
|
</template>
|
||||||
|
</gl-sprintf>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-gl-tooltip="{
|
||||||
|
disabled: item.destroy_path,
|
||||||
|
title: $options.i18n.LIST_DELETE_BUTTON_DISABLED,
|
||||||
|
}"
|
||||||
|
class="d-none d-sm-block"
|
||||||
|
data-testid="deleteButtonWrapper"
|
||||||
|
>
|
||||||
|
<gl-button
|
||||||
|
v-gl-tooltip
|
||||||
|
data-testid="deleteImageButton"
|
||||||
|
:disabled="disabledDelete"
|
||||||
|
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
|
||||||
|
:aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
|
||||||
|
class="btn-inverted"
|
||||||
|
variant="danger"
|
||||||
|
icon="remove"
|
||||||
|
@click="$emit('delete', item)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -42,7 +42,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.broadcast-message-dismiss {
|
.broadcast-message-dismiss {
|
||||||
height: 100%;
|
|
||||||
color: $gray-800;
|
color: $gray-800;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -241,7 +241,8 @@ class Admin::UsersController < Admin::ApplicationController
|
||||||
:theme_id,
|
:theme_id,
|
||||||
:twitter,
|
:twitter,
|
||||||
:username,
|
:username,
|
||||||
:website_url
|
:website_url,
|
||||||
|
:note
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -162,8 +162,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
|
||||||
def renderable_notes
|
def renderable_notes
|
||||||
define_diff_comment_vars unless @notes
|
define_diff_comment_vars unless @notes
|
||||||
|
|
||||||
@notes
|
draft_notes =
|
||||||
|
if current_user
|
||||||
|
merge_request.draft_notes.authored_by(current_user)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
@notes.concat(draft_notes)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
Projects::MergeRequests::DiffsController.prepend_if_ee('EE::Projects::MergeRequests::DiffsController')
|
|
||||||
|
|
|
@ -0,0 +1,129 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Projects::MergeRequests::DraftsController < Projects::MergeRequests::ApplicationController
|
||||||
|
include Gitlab::Utils::StrongMemoize
|
||||||
|
|
||||||
|
respond_to :json
|
||||||
|
|
||||||
|
before_action :authorize_create_note!, only: [:create, :publish]
|
||||||
|
before_action :authorize_admin_draft!, only: [:update, :destroy]
|
||||||
|
before_action :authorize_admin_draft!, if: -> { action_name == 'publish' && params[:id].present? }
|
||||||
|
|
||||||
|
def index
|
||||||
|
drafts = prepare_notes_for_rendering(draft_notes)
|
||||||
|
render json: DraftNoteSerializer.new(current_user: current_user).represent(drafts)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
create_params = draft_note_params.merge(in_reply_to_discussion_id: params[:in_reply_to_discussion_id])
|
||||||
|
create_service = DraftNotes::CreateService.new(merge_request, current_user, create_params)
|
||||||
|
|
||||||
|
draft_note = create_service.execute
|
||||||
|
|
||||||
|
prepare_notes_for_rendering(draft_note)
|
||||||
|
|
||||||
|
render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
draft_note.update!(draft_note_params)
|
||||||
|
|
||||||
|
prepare_notes_for_rendering(draft_note)
|
||||||
|
|
||||||
|
render json: DraftNoteSerializer.new(current_user: current_user).represent(draft_note)
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
DraftNotes::DestroyService.new(merge_request, current_user).execute(draft_note)
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def publish
|
||||||
|
result = DraftNotes::PublishService.new(merge_request, current_user).execute(draft_note(allow_nil: true))
|
||||||
|
|
||||||
|
if result[:status] == :success
|
||||||
|
head :ok
|
||||||
|
else
|
||||||
|
render json: { message: result[:message] }, status: result[:status]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def discard
|
||||||
|
DraftNotes::DestroyService.new(merge_request, current_user).execute
|
||||||
|
|
||||||
|
head :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def draft_note(allow_nil: false)
|
||||||
|
strong_memoize(:draft_note) do
|
||||||
|
draft_notes.find(params[:id])
|
||||||
|
end
|
||||||
|
rescue ActiveRecord::RecordNotFound => ex
|
||||||
|
# draft_note is allowed to be nil in #publish
|
||||||
|
raise ex unless allow_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def draft_notes
|
||||||
|
return unless current_user
|
||||||
|
|
||||||
|
strong_memoize(:draft_notes) do
|
||||||
|
merge_request.draft_notes.authored_by(current_user)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# rubocop: disable CodeReuse/ActiveRecord
|
||||||
|
def merge_request
|
||||||
|
@merge_request ||= MergeRequestsFinder.new(current_user, project_id: @project.id).find_by!(iid: params[:merge_request_id])
|
||||||
|
end
|
||||||
|
# rubocop: enable CodeReuse/ActiveRecord
|
||||||
|
|
||||||
|
def draft_note_params
|
||||||
|
params.require(:draft_note).permit(
|
||||||
|
:commit_id,
|
||||||
|
:note,
|
||||||
|
:position,
|
||||||
|
:resolve_discussion
|
||||||
|
).tap do |h|
|
||||||
|
# Old FE version will still be sending `draft_note[commit_id]` as 'undefined'.
|
||||||
|
# That can result to having a note linked to a commit with 'undefined' ID
|
||||||
|
# which is non-existent.
|
||||||
|
h[:commit_id] = nil if h[:commit_id] == 'undefined'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def prepare_notes_for_rendering(notes)
|
||||||
|
return [] unless notes
|
||||||
|
|
||||||
|
notes = Array.wrap(notes)
|
||||||
|
|
||||||
|
# Preload author and access-level information
|
||||||
|
DraftNote.preload_author(notes)
|
||||||
|
user_ids = notes.map(&:author_id)
|
||||||
|
project.team.max_member_access_for_user_ids(user_ids)
|
||||||
|
|
||||||
|
notes.map(&method(:render_draft_note))
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_draft_note(note)
|
||||||
|
params = { target_id: merge_request.id, target_type: 'MergeRequest', text: note.note }
|
||||||
|
result = PreviewMarkdownService.new(@project, current_user, params).execute
|
||||||
|
markdown_params = { markdown_engine: result[:markdown_engine], issuable_state_filter_enabled: true }
|
||||||
|
|
||||||
|
note.rendered_note = view_context.markdown(result[:text], markdown_params)
|
||||||
|
note.users_referenced = result[:users]
|
||||||
|
note.commands_changes = view_context.markdown(result[:commands])
|
||||||
|
|
||||||
|
note
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_admin_draft!
|
||||||
|
access_denied! unless can?(current_user, :admin_note, draft_note)
|
||||||
|
end
|
||||||
|
|
||||||
|
def authorize_create_note!
|
||||||
|
access_denied! unless can?(current_user, :create_note, merge_request)
|
||||||
|
end
|
||||||
|
end
|
|
@ -61,8 +61,8 @@ module NotesHelper
|
||||||
class: 'add-diff-note js-add-diff-note-button',
|
class: 'add-diff-note js-add-diff-note-button',
|
||||||
type: 'submit', name: 'button',
|
type: 'submit', name: 'button',
|
||||||
data: diff_view_line_data(line_code, position, line_type),
|
data: diff_view_line_data(line_code, position, line_type),
|
||||||
title: 'Add a comment to this line' do
|
title: _('Add a comment to this line') do
|
||||||
icon('comment-o')
|
sprite_icon('comment', size: 12)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class DraftNotePolicy < BasePolicy
|
||||||
|
delegate { @subject.merge_request }
|
||||||
|
|
||||||
|
condition(:is_author) { @user && @subject.author == @user }
|
||||||
|
|
||||||
|
rule { is_author }.policy do
|
||||||
|
enable :read_note
|
||||||
|
enable :admin_note
|
||||||
|
enable :resolve_note
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,39 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
class DraftNoteEntity < Grape::Entity
|
||||||
|
include RequestAwareEntity
|
||||||
|
|
||||||
|
expose :id
|
||||||
|
expose :author, using: NoteUserEntity
|
||||||
|
expose :merge_request_id
|
||||||
|
expose :position, if: -> (note, _) { note.on_diff? }
|
||||||
|
expose :line_code
|
||||||
|
expose :file_identifier_hash
|
||||||
|
expose :file_hash
|
||||||
|
expose :file_path
|
||||||
|
expose :note
|
||||||
|
expose :rendered_note, as: :note_html
|
||||||
|
expose :references
|
||||||
|
expose :discussion_id
|
||||||
|
expose :resolve_discussion
|
||||||
|
expose :noteable_type
|
||||||
|
|
||||||
|
expose :current_user do
|
||||||
|
expose :can_edit do |note|
|
||||||
|
can?(current_user, :admin_note, note)
|
||||||
|
end
|
||||||
|
|
||||||
|
expose :can_award_emoji do |note|
|
||||||
|
note.emoji_awardable?
|
||||||
|
end
|
||||||
|
|
||||||
|
expose :can_resolve do |note|
|
||||||
|
note.resolvable? && can?(current_user, :resolve_note, note)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def current_user
|
||||||
|
request.current_user
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,4 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
class DraftNoteSerializer < BaseSerializer
|
||||||
|
entity DraftNoteEntity
|
||||||
|
end
|
|
@ -82,7 +82,8 @@ module Users
|
||||||
:organization,
|
:organization,
|
||||||
:location,
|
:location,
|
||||||
:public_email,
|
:public_email,
|
||||||
:user_type
|
:user_type,
|
||||||
|
:note
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
%fieldset
|
||||||
|
%legend= _('Admin notes')
|
||||||
|
.form-group.row
|
||||||
|
.col-sm-2.col-form-label.text-right
|
||||||
|
= f.label :note, s_('AdminNote|Note')
|
||||||
|
.col-sm-10
|
||||||
|
= f.text_area :note, class: 'form-control'
|
|
@ -83,7 +83,7 @@
|
||||||
.col-sm-10
|
.col-sm-10
|
||||||
= f.text_field :website_url, class: 'form-control'
|
= f.text_field :website_url, class: 'form-control'
|
||||||
|
|
||||||
= render_if_exists 'admin/users/admin_notes', f: f
|
= render 'admin/users/admin_notes', f: f
|
||||||
|
|
||||||
.form-actions
|
.form-actions
|
||||||
- if @user.new_record?
|
- if @user.new_record?
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
= image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 gl-mt-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
|
= image_tag avatar_icon_for_user(user), class: 'avatar s16 d-xs-flex d-md-none mr-1 gl-mt-2', alt: _('Avatar for %{name}') % { name: sanitize_name(user.name) }
|
||||||
= link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id, qa_selector: 'username_link' }
|
= link_to user.name, admin_user_path(user), class: 'text-plain js-user-link', data: { user_id: user.id, qa_selector: 'username_link' }
|
||||||
|
|
||||||
= render_if_exists 'admin/users/user_listing_note', user: user
|
= render 'admin/users/user_listing_note', user: user
|
||||||
|
|
||||||
- user_badges_in_admin_section(user).each do |badge|
|
- user_badges_in_admin_section(user).each do |badge|
|
||||||
- css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present?
|
- css_badge = "badge badge-#{badge[:variant]}" if badge[:variant].present?
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
- if @user.note.present?
|
||||||
|
- text = @user.note
|
||||||
|
.card.border-info
|
||||||
|
.card-header.bg-info.text-white
|
||||||
|
= _('Admin Note')
|
||||||
|
.card-body
|
||||||
|
%p= text
|
|
@ -0,0 +1,3 @@
|
||||||
|
- if user.note.present?
|
||||||
|
%span.has-tooltip.user-note{ title: user.note }
|
||||||
|
= icon("sticky-note-o cgrey")
|
|
@ -154,7 +154,7 @@
|
||||||
%br
|
%br
|
||||||
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' }
|
= link_to 'Confirm user', confirm_admin_user_path(@user), method: :put, class: "btn btn-info", data: { confirm: 'Are you sure?', qa_selector: 'confirm_user_button' }
|
||||||
|
|
||||||
= render_if_exists 'admin/users/user_detail_note'
|
= render 'admin/users/user_detail_note'
|
||||||
|
|
||||||
- if @user.deactivated?
|
- if @user.deactivated?
|
||||||
.card.border-info
|
.card.border-info
|
||||||
|
|
|
@ -22,8 +22,8 @@
|
||||||
|
|
||||||
.header-action-buttons
|
.header-action-buttons
|
||||||
- if defined?(@notes_count) && @notes_count > 0
|
- if defined?(@notes_count) && @notes_count > 0
|
||||||
%span.btn.disabled.btn-grouped.d-none.d-sm-block.append-right-10
|
%span.btn.disabled.btn-grouped.d-none.d-sm-block.append-right-10.has-tooltip{ title: n_("%d comment on this commit", "%d comments on this commit", @notes_count) % @notes_count }
|
||||||
= icon('comment')
|
= sprite_icon('comment')
|
||||||
= @notes_count
|
= @notes_count
|
||||||
= link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 d-none d-sm-none d-md-inline" do
|
= link_to project_tree_path(@project, @commit), class: "btn btn-default append-right-10 d-none d-sm-none d-md-inline" do
|
||||||
#{ _('Browse files') }
|
#{ _('Browse files') }
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
.file-actions.d-none.d-sm-block
|
.file-actions.d-none.d-sm-block
|
||||||
- if blob&.readable_text?
|
- if blob&.readable_text?
|
||||||
= link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
|
= link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: _("Toggle comments for this file"), disabled: @diff_notes_disabled do
|
||||||
= icon('comment')
|
= sprite_icon('comment', size: 16)
|
||||||
\
|
\
|
||||||
- if editable_diff?(diff_file)
|
- if editable_diff?(diff_file)
|
||||||
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
|
- link_opts = @merge_request.persisted? ? { from_merge_request_iid: @merge_request.iid } : {}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
- page_title _('CI / CD Charts')
|
- page_title _('CI / CD Analytics')
|
||||||
|
|
||||||
#js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts),
|
#js-project-pipelines-charts-app{ data: { counts: @counts, success_ratio: success_ratio(@counts),
|
||||||
times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times },
|
times_chart: { labels: @charts[:pipeline_times].labels, values: @charts[:pipeline_times].pipeline_times },
|
||||||
|
|
|
@ -19,14 +19,11 @@ class NewNoteWorker # rubocop:disable Scalability/IdempotentWorker
|
||||||
Gitlab::AppLogger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
|
Gitlab::AppLogger.error("NewNoteWorker: couldn't find note with ID=#{note_id}, skipping job")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
# rubocop: enable CodeReuse/ActiveRecord
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# EE-only method
|
|
||||||
def skip_notification?(note)
|
def skip_notification?(note)
|
||||||
false
|
note.review.present?
|
||||||
end
|
end
|
||||||
# rubocop: enable CodeReuse/ActiveRecord
|
|
||||||
end
|
end
|
||||||
|
|
||||||
NewNoteWorker.prepend_if_ee('EE::NewNoteWorker')
|
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Include tag count in the image repository list
|
||||||
|
merge_request: 33027
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Add note to ECS CI template
|
||||||
|
merge_request: 32597
|
||||||
|
author:
|
||||||
|
type: added
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Move review related controllers/workers outside EE
|
||||||
|
merge_request: 32663
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Remove destroy function logic from list model
|
||||||
|
merge_request: 32237
|
||||||
|
author: nuwe1
|
||||||
|
type: other
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Use sprites for comment icons on Commits
|
||||||
|
merge_request: 31696
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Add snowplow tracking for logs page
|
||||||
|
merge_request: 32704
|
||||||
|
author:
|
||||||
|
type: other
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Move broadcast notification dismiss button to the top
|
||||||
|
merge_request: 33174
|
||||||
|
author:
|
||||||
|
type: changed
|
|
@ -0,0 +1,5 @@
|
||||||
|
---
|
||||||
|
title: Move Admin note feature to GitLab Core
|
||||||
|
merge_request: 31457
|
||||||
|
author: Rajendra
|
||||||
|
type: added
|
|
@ -55,6 +55,15 @@ resources :merge_requests, concerns: :awardable, except: [:new, :create, :show],
|
||||||
delete :resolve, action: :unresolve
|
delete :resolve, action: :unresolve
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
scope module: :merge_requests do
|
||||||
|
resources :drafts, only: [:index, :update, :create, :destroy] do
|
||||||
|
collection do
|
||||||
|
post :publish
|
||||||
|
delete :discard
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
scope path: 'merge_requests', controller: 'merge_requests/creations' do
|
scope path: 'merge_requests', controller: 'merge_requests/creations' do
|
||||||
|
|
|
@ -121,6 +121,12 @@ Note the following when promoting a secondary:
|
||||||
gitlab-ctl promote-to-primary-node
|
gitlab-ctl promote-to-primary-node
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you have already run the [preflight checks](planned_failover.md#preflight-checks), you can skip them with:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
gitlab-ctl promote-to-primary-node --skip-preflight-check
|
||||||
|
```
|
||||||
|
|
||||||
1. Verify you can connect to the newly promoted **primary** node using the URL used
|
1. Verify you can connect to the newly promoted **primary** node using the URL used
|
||||||
previously for the **secondary** node.
|
previously for the **secondary** node.
|
||||||
1. If successful, the **secondary** node has now been promoted to the **primary** node.
|
1. If successful, the **secondary** node has now been promoted to the **primary** node.
|
||||||
|
|
|
@ -784,9 +784,7 @@ If the time frame is not specified, dead replication jobs from the last six hour
|
||||||
sudo /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.toml dataloss
|
sudo /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.toml dataloss
|
||||||
|
|
||||||
Failed replication jobs between [2020-01-02 00:00:00 +0000 UTC, 2020-01-02 06:00:00 +0000 UTC):
|
Failed replication jobs between [2020-01-02 00:00:00 +0000 UTC, 2020-01-02 06:00:00 +0000 UTC):
|
||||||
example/repository-1: 1 jobs
|
@hashed/fa/53/fa539965395b8382145f8370b34eab249cf610d2d6f2943c95b9b9d08a63d4a3.git: 2 jobs
|
||||||
example/repository-2: 4 jobs
|
|
||||||
example/repository-3: 2 jobs
|
|
||||||
```
|
```
|
||||||
|
|
||||||
To specify a time frame in UTC, run:
|
To specify a time frame in UTC, run:
|
||||||
|
@ -797,7 +795,8 @@ sudo /opt/gitlab/embedded/bin/praefect -config /var/opt/gitlab/praefect/config.t
|
||||||
|
|
||||||
### Checking repository checksums
|
### Checking repository checksums
|
||||||
|
|
||||||
To check a project's checksums across all nodes, the Praefect replicas Rake task can be used:
|
To check a project's repository checksums across on all Gitaly nodes, the
|
||||||
|
replicas Rake task can be run on the main GitLab node:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
sudo gitlab-rake "gitlab:praefect:replicas[project_id]"
|
sudo gitlab-rake "gitlab:praefect:replicas[project_id]"
|
||||||
|
|
|
@ -8309,11 +8309,6 @@ type Project {
|
||||||
"""
|
"""
|
||||||
after: String
|
after: String
|
||||||
|
|
||||||
"""
|
|
||||||
Filter requirements by author username
|
|
||||||
"""
|
|
||||||
authorUsername: [String!]
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Returns the elements in the list that come before the specified cursor.
|
Returns the elements in the list that come before the specified cursor.
|
||||||
"""
|
"""
|
||||||
|
@ -8455,6 +8450,11 @@ type Project {
|
||||||
"""
|
"""
|
||||||
after: String
|
after: String
|
||||||
|
|
||||||
|
"""
|
||||||
|
Filter requirements by author username
|
||||||
|
"""
|
||||||
|
authorUsername: [String!]
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Returns the elements in the list that come before the specified cursor.
|
Returns the elements in the list that come before the specified cursor.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -24398,34 +24398,6 @@
|
||||||
"ofType": null
|
"ofType": null
|
||||||
},
|
},
|
||||||
"defaultValue": null
|
"defaultValue": null
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "search",
|
|
||||||
"description": "Filter requirements by title search",
|
|
||||||
"type": {
|
|
||||||
"kind": "SCALAR",
|
|
||||||
"name": "String",
|
|
||||||
"ofType": null
|
|
||||||
},
|
|
||||||
"defaultValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "authorUsername",
|
|
||||||
"description": "Filter requirements by author username",
|
|
||||||
"type": {
|
|
||||||
"kind": "LIST",
|
|
||||||
"name": null,
|
|
||||||
"ofType": {
|
|
||||||
"kind": "NON_NULL",
|
|
||||||
"name": null,
|
|
||||||
"ofType": {
|
|
||||||
"kind": "SCALAR",
|
|
||||||
"name": "String",
|
|
||||||
"ofType": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"defaultValue": null
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"type": {
|
"type": {
|
||||||
|
@ -24488,34 +24460,6 @@
|
||||||
},
|
},
|
||||||
"defaultValue": null
|
"defaultValue": null
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "search",
|
|
||||||
"description": "Filter requirements by title search",
|
|
||||||
"type": {
|
|
||||||
"kind": "SCALAR",
|
|
||||||
"name": "String",
|
|
||||||
"ofType": null
|
|
||||||
},
|
|
||||||
"defaultValue": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "authorUsername",
|
|
||||||
"description": "Filter requirements by author username",
|
|
||||||
"type": {
|
|
||||||
"kind": "LIST",
|
|
||||||
"name": null,
|
|
||||||
"ofType": {
|
|
||||||
"kind": "NON_NULL",
|
|
||||||
"name": null,
|
|
||||||
"ofType": {
|
|
||||||
"kind": "SCALAR",
|
|
||||||
"name": "String",
|
|
||||||
"ofType": null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"defaultValue": null
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "after",
|
"name": "after",
|
||||||
"description": "Returns the elements in the list that come after the specified cursor.",
|
"description": "Returns the elements in the list that come after the specified cursor.",
|
||||||
|
@ -24766,6 +24710,34 @@
|
||||||
"ofType": null
|
"ofType": null
|
||||||
},
|
},
|
||||||
"defaultValue": null
|
"defaultValue": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search",
|
||||||
|
"description": "Filter requirements by title search",
|
||||||
|
"type": {
|
||||||
|
"kind": "SCALAR",
|
||||||
|
"name": "String",
|
||||||
|
"ofType": null
|
||||||
|
},
|
||||||
|
"defaultValue": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "authorUsername",
|
||||||
|
"description": "Filter requirements by author username",
|
||||||
|
"type": {
|
||||||
|
"kind": "LIST",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "NON_NULL",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "SCALAR",
|
||||||
|
"name": "String",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultValue": null
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"type": {
|
"type": {
|
||||||
|
@ -24842,6 +24814,34 @@
|
||||||
},
|
},
|
||||||
"defaultValue": null
|
"defaultValue": null
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "search",
|
||||||
|
"description": "Filter requirements by title search",
|
||||||
|
"type": {
|
||||||
|
"kind": "SCALAR",
|
||||||
|
"name": "String",
|
||||||
|
"ofType": null
|
||||||
|
},
|
||||||
|
"defaultValue": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "authorUsername",
|
||||||
|
"description": "Filter requirements by author username",
|
||||||
|
"type": {
|
||||||
|
"kind": "LIST",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "NON_NULL",
|
||||||
|
"name": null,
|
||||||
|
"ofType": {
|
||||||
|
"kind": "SCALAR",
|
||||||
|
"name": "String",
|
||||||
|
"ofType": null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultValue": null
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "after",
|
"name": "after",
|
||||||
"description": "Returns the elements in the list that come after the specified cursor.",
|
"description": "Returns the elements in the list that come after the specified cursor.",
|
||||||
|
|
|
@ -104,6 +104,7 @@ GET /users
|
||||||
"color_scheme_id": 2,
|
"color_scheme_id": 2,
|
||||||
"projects_limit": 100,
|
"projects_limit": 100,
|
||||||
"current_sign_in_at": "2012-06-02T06:36:55Z",
|
"current_sign_in_at": "2012-06-02T06:36:55Z",
|
||||||
|
"note": "DMCA Request: 2018-11-05 | DMCA Violation | Abuse | https://gitlab.zendesk.com/agent/tickets/123",
|
||||||
"identities": [
|
"identities": [
|
||||||
{"provider": "github", "extern_uid": "2435223452345"},
|
{"provider": "github", "extern_uid": "2435223452345"},
|
||||||
{"provider": "bitbucket", "extern_uid": "john.smith"},
|
{"provider": "bitbucket", "extern_uid": "john.smith"},
|
||||||
|
@ -154,7 +155,7 @@ GET /users
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/) will also see the `shared_runners_minutes_limit`, `extra_shared_runners_minutes_limit`, and `note` parameters.
|
Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/) will also see the `shared_runners_minutes_limit`, and `extra_shared_runners_minutes_limit` parameters.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
[
|
[
|
||||||
|
@ -163,7 +164,6 @@ Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/)
|
||||||
...
|
...
|
||||||
"shared_runners_minutes_limit": 133,
|
"shared_runners_minutes_limit": 133,
|
||||||
"extra_shared_runners_minutes_limit": 133,
|
"extra_shared_runners_minutes_limit": 133,
|
||||||
"note": "DMCA Request: 2018-11-05 | DMCA Violation | Abuse | https://gitlab.zendesk.com/agent/tickets/123",
|
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
@ -296,6 +296,7 @@ Example Responses:
|
||||||
"color_scheme_id": 2,
|
"color_scheme_id": 2,
|
||||||
"projects_limit": 100,
|
"projects_limit": 100,
|
||||||
"current_sign_in_at": "2012-06-02T06:36:55Z",
|
"current_sign_in_at": "2012-06-02T06:36:55Z",
|
||||||
|
"note": "DMCA Request: 2018-11-05 | DMCA Violation | Abuse | https://gitlab.zendesk.com/agent/tickets/123",
|
||||||
"identities": [
|
"identities": [
|
||||||
{"provider": "github", "extern_uid": "2435223452345"},
|
{"provider": "github", "extern_uid": "2435223452345"},
|
||||||
{"provider": "bitbucket", "extern_uid": "john.smith"},
|
{"provider": "bitbucket", "extern_uid": "john.smith"},
|
||||||
|
@ -316,7 +317,7 @@ Example Responses:
|
||||||
NOTE: **Note:** The `plan` and `trial` parameters are only available on GitLab Enterprise Edition.
|
NOTE: **Note:** The `plan` and `trial` parameters are only available on GitLab Enterprise Edition.
|
||||||
|
|
||||||
Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/) will also see
|
Users on GitLab [Starter, Bronze, or higher](https://about.gitlab.com/pricing/) will also see
|
||||||
the `shared_runners_minutes_limit`, `extra_shared_runners_minutes_limit`, and `note` parameters.
|
the `shared_runners_minutes_limit`, and `extra_shared_runners_minutes_limit` parameters.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
@ -324,7 +325,6 @@ the `shared_runners_minutes_limit`, `extra_shared_runners_minutes_limit`, and `n
|
||||||
"username": "john_smith",
|
"username": "john_smith",
|
||||||
"shared_runners_minutes_limit": 133,
|
"shared_runners_minutes_limit": 133,
|
||||||
"extra_shared_runners_minutes_limit": 133,
|
"extra_shared_runners_minutes_limit": 133,
|
||||||
"note": "DMCA Request: 2018-11-05 | DMCA Violation | Abuse | https://gitlab.zendesk.com/agent/tickets/123",
|
|
||||||
...
|
...
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -338,7 +338,6 @@ see the `group_saml` option:
|
||||||
"username": "john_smith",
|
"username": "john_smith",
|
||||||
"shared_runners_minutes_limit": 133,
|
"shared_runners_minutes_limit": 133,
|
||||||
"extra_shared_runners_minutes_limit": 133,
|
"extra_shared_runners_minutes_limit": 133,
|
||||||
"note": "DMCA Request: 2018-11-05 | DMCA Violation | Abuse | https://gitlab.zendesk.com/agent/tickets/123",
|
|
||||||
"identities": [
|
"identities": [
|
||||||
{"provider": "github", "extern_uid": "2435223452345"},
|
{"provider": "github", "extern_uid": "2435223452345"},
|
||||||
{"provider": "bitbucket", "extern_uid": "john.smith"},
|
{"provider": "bitbucket", "extern_uid": "john.smith"},
|
||||||
|
@ -391,6 +390,7 @@ Parameters:
|
||||||
| `linkedin` | No | LinkedIn |
|
| `linkedin` | No | LinkedIn |
|
||||||
| `location` | No | User's location |
|
| `location` | No | User's location |
|
||||||
| `name` | Yes | Name |
|
| `name` | Yes | Name |
|
||||||
|
| `note` | No | Admin notes for this user |
|
||||||
| `organization` | No | Organization name |
|
| `organization` | No | Organization name |
|
||||||
| `password` | No | Password |
|
| `password` | No | Password |
|
||||||
| `private_profile` | No | User's profile is private - true, false (default), or null (will be converted to false) |
|
| `private_profile` | No | User's profile is private - true, false (default), or null (will be converted to false) |
|
||||||
|
@ -432,7 +432,7 @@ Parameters:
|
||||||
| `linkedin` | No | LinkedIn |
|
| `linkedin` | No | LinkedIn |
|
||||||
| `location` | No | User's location |
|
| `location` | No | User's location |
|
||||||
| `name` | No | Name |
|
| `name` | No | Name |
|
||||||
| `note` | No | Admin notes for this user **(STARTER)** |
|
| `note` | No | Admin notes for this user |
|
||||||
| `organization` | No | Organization name |
|
| `organization` | No | Organization name |
|
||||||
| `password` | No | Password |
|
| `password` | No | Password |
|
||||||
| `private_profile` | No | User's profile is private - true, false (default), or null (will be converted to false) |
|
| `private_profile` | No | User's profile is private - true, false (default), or null (will be converted to false) |
|
||||||
|
|
|
@ -123,6 +123,17 @@ After you're all set up on AWS ECS, follow these steps:
|
||||||
task definition, making the cluster pull the newest version of your
|
task definition, making the cluster pull the newest version of your
|
||||||
application.
|
application.
|
||||||
|
|
||||||
|
CAUTION: **Warning:**
|
||||||
|
The [`Deploy-ECS.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Deploy-ECS.gitlab-ci.yml)
|
||||||
|
template includes both the [`Jobs/Build.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Build.gitlab-ci.yml)
|
||||||
|
and [`Jobs/Deploy/ECS.gitlab-ci.yml`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy/ECS.gitlab-ci.yml)
|
||||||
|
"sub-templates". Do not include these "sub-templates" on their own, and only include the main
|
||||||
|
`Deploy-ECS.gitlab-ci.yml` template. The "sub-templates" are designed to only be
|
||||||
|
used along with the main template. They may move or change unexpectedly causing your
|
||||||
|
pipeline to fail if you didn't include the main template. Also, the job names within
|
||||||
|
these templates may change. Do not override these jobs names in your own pipeline,
|
||||||
|
as the override will stop working when the name changes.
|
||||||
|
|
||||||
Alternatively, if you don't wish to use the `Deploy-ECS.gitlab-ci.yml` template
|
Alternatively, if you don't wish to use the `Deploy-ECS.gitlab-ci.yml` template
|
||||||
to deploy to AWS ECS, you can always use our
|
to deploy to AWS ECS, you can always use our
|
||||||
`aws-base` Docker image to run your own [AWS CLI commands for ECS](https://docs.aws.amazon.com/cli/latest/reference/ecs/index.html#cli-aws-ecs).
|
`aws-base` Docker image to run your own [AWS CLI commands for ECS](https://docs.aws.amazon.com/cli/latest/reference/ecs/index.html#cli-aws-ecs).
|
||||||
|
|
|
@ -232,6 +232,15 @@ NOTE: **Note:**
|
||||||
If you have both a valid `AUTO_DEVOPS_PLATFORM_TARGET` variable and a Kubernetes cluster tied to your project,
|
If you have both a valid `AUTO_DEVOPS_PLATFORM_TARGET` variable and a Kubernetes cluster tied to your project,
|
||||||
only the deployment to Kubernetes will run.
|
only the deployment to Kubernetes will run.
|
||||||
|
|
||||||
|
CAUTION: **Warning:**
|
||||||
|
Setting the `AUTO_DEVOPS_PLATFORM_TARGET` variable to `ECS` will trigger jobs
|
||||||
|
defined in the [`Jobs/Deploy/ECS.gitlab-ci.yml` template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Jobs/Deploy/ECS.gitlab-ci.yml).
|
||||||
|
However, it is not recommended to [include](../../ci/yaml/README.md#includetemplate)
|
||||||
|
it on its own. This template is designed to be used with Auto DevOps only. It may change
|
||||||
|
unexpectedly causing your pipeline to fail if included on its own. Also, the job
|
||||||
|
names within this template may also change. Do not override these jobs names in your
|
||||||
|
own pipeline, as the override will stop working when the name changes.
|
||||||
|
|
||||||
## Auto DevOps base domain
|
## Auto DevOps base domain
|
||||||
|
|
||||||
The Auto DevOps base domain is required to use
|
The Auto DevOps base domain is required to use
|
||||||
|
|
|
@ -4,8 +4,7 @@ module API
|
||||||
module Entities
|
module Entities
|
||||||
class UserWithAdmin < UserPublic
|
class UserWithAdmin < UserPublic
|
||||||
expose :admin?, as: :is_admin
|
expose :admin?, as: :is_admin
|
||||||
|
expose :note
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
API::Entities::UserWithAdmin.prepend_if_ee('EE::API::Entities::UserWithAdmin', with_descendants: true)
|
|
||||||
|
|
|
@ -55,6 +55,7 @@ module API
|
||||||
optional :theme_id, type: Integer, desc: 'The GitLab theme for the user'
|
optional :theme_id, type: Integer, desc: 'The GitLab theme for the user'
|
||||||
optional :color_scheme_id, type: Integer, desc: 'The color scheme for the file viewer'
|
optional :color_scheme_id, type: Integer, desc: 'The color scheme for the file viewer'
|
||||||
optional :private_profile, type: Boolean, desc: 'Flag indicating the user has a private profile'
|
optional :private_profile, type: Boolean, desc: 'Flag indicating the user has a private profile'
|
||||||
|
optional :note, type: String, desc: 'Admin note for this user'
|
||||||
all_or_none_of :extern_uid, :provider
|
all_or_none_of :extern_uid, :provider
|
||||||
|
|
||||||
use :optional_params_ee
|
use :optional_params_ee
|
||||||
|
|
|
@ -1,3 +1,13 @@
|
||||||
|
# WARNING (post-GitLab 13.0):
|
||||||
|
#
|
||||||
|
# This CI template should NOT be included in your own CI configuration files:
|
||||||
|
# 'review_ecs' and 'production_ecs' are two temporary names given to the jobs below.
|
||||||
|
#
|
||||||
|
# Should this template be included in your CI configuration, the upcoming name changes could
|
||||||
|
# then result in potentially breaking your future pipelines.
|
||||||
|
#
|
||||||
|
# More about including CI templates: https://docs.gitlab.com/ee/ci/yaml/#includetemplate
|
||||||
|
|
||||||
.deploy_to_ecs:
|
.deploy_to_ecs:
|
||||||
image: 'registry.gitlab.com/gitlab-org/cloud-deploy/aws-ecs:latest'
|
image: 'registry.gitlab.com/gitlab-org/cloud-deploy/aws-ecs:latest'
|
||||||
script:
|
script:
|
||||||
|
|
|
@ -12,14 +12,15 @@ module Gitlab
|
||||||
WIKI = RepoType.new(
|
WIKI = RepoType.new(
|
||||||
name: :wiki,
|
name: :wiki,
|
||||||
access_checker_class: Gitlab::GitAccessWiki,
|
access_checker_class: Gitlab::GitAccessWiki,
|
||||||
repository_resolver: -> (project) { project&.wiki&.repository },
|
repository_resolver: -> (container) { container&.wiki&.repository },
|
||||||
|
project_resolver: -> (container) { container.is_a?(Project) ? container : nil },
|
||||||
suffix: :wiki
|
suffix: :wiki
|
||||||
).freeze
|
).freeze
|
||||||
SNIPPET = RepoType.new(
|
SNIPPET = RepoType.new(
|
||||||
name: :snippet,
|
name: :snippet,
|
||||||
access_checker_class: Gitlab::GitAccessSnippet,
|
access_checker_class: Gitlab::GitAccessSnippet,
|
||||||
repository_resolver: -> (snippet) { snippet&.repository },
|
repository_resolver: -> (snippet) { snippet&.repository },
|
||||||
container_resolver: -> (id) { Snippet.find_by_id(id) },
|
container_class: Snippet,
|
||||||
project_resolver: -> (snippet) { snippet&.project },
|
project_resolver: -> (snippet) { snippet&.project },
|
||||||
guest_read_ability: :read_snippet
|
guest_read_ability: :read_snippet
|
||||||
).freeze
|
).freeze
|
||||||
|
@ -42,16 +43,12 @@ module Gitlab
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.parse(gl_repository)
|
def self.parse(gl_repository)
|
||||||
type_name, _id = gl_repository.split('-').first
|
result = ::Gitlab::GlRepository::Identifier.new(gl_repository)
|
||||||
type = types[type_name]
|
|
||||||
|
|
||||||
unless type
|
repo_type = result.repo_type
|
||||||
raise ArgumentError, "Invalid GL Repository \"#{gl_repository}\""
|
container = result.fetch_container!
|
||||||
end
|
|
||||||
|
|
||||||
container = type.fetch_container!(gl_repository)
|
[container, repo_type.project_for(container), repo_type]
|
||||||
|
|
||||||
[container, type.project_for(container), type]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.default_type
|
def self.default_type
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Gitlab
|
||||||
|
class GlRepository
|
||||||
|
class Identifier
|
||||||
|
attr_reader :gl_repository, :repo_type
|
||||||
|
|
||||||
|
def initialize(gl_repository)
|
||||||
|
@gl_repository = gl_repository
|
||||||
|
@segments = gl_repository.split('-')
|
||||||
|
|
||||||
|
raise_error if segments.size > 3
|
||||||
|
|
||||||
|
@repo_type = find_repo_type
|
||||||
|
@container_id = find_container_id
|
||||||
|
@container_class = find_container_class
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_container!
|
||||||
|
container_class.find_by_id(container_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
attr_reader :segments, :container_class, :container_id
|
||||||
|
|
||||||
|
def find_repo_type
|
||||||
|
type_name = three_segments_format? ? segments.last : segments.first
|
||||||
|
type = Gitlab::GlRepository.types[type_name]
|
||||||
|
|
||||||
|
raise_error unless type
|
||||||
|
|
||||||
|
type
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_container_class
|
||||||
|
if three_segments_format?
|
||||||
|
case segments[0]
|
||||||
|
when 'project'
|
||||||
|
Project
|
||||||
|
when 'group'
|
||||||
|
Group
|
||||||
|
else
|
||||||
|
raise_error
|
||||||
|
end
|
||||||
|
else
|
||||||
|
repo_type.container_class
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_container_id
|
||||||
|
id = Integer(segments[1], 10, exception: false)
|
||||||
|
|
||||||
|
raise_error unless id
|
||||||
|
|
||||||
|
id
|
||||||
|
end
|
||||||
|
|
||||||
|
# gl_repository can either have 2 or 3 segments:
|
||||||
|
# "wiki-1" is the older 2-segment format, where container is implied.
|
||||||
|
# "group-1-wiki" is the newer 3-segment format, including container information.
|
||||||
|
#
|
||||||
|
# TODO: convert all 2-segment format to 3-segment:
|
||||||
|
# https://gitlab.com/gitlab-org/gitlab/-/issues/219192
|
||||||
|
def three_segments_format?
|
||||||
|
segments.size == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
def raise_error
|
||||||
|
raise ArgumentError, "Invalid GL Repository \"#{gl_repository}\""
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -6,7 +6,7 @@ module Gitlab
|
||||||
attr_reader :name,
|
attr_reader :name,
|
||||||
:access_checker_class,
|
:access_checker_class,
|
||||||
:repository_resolver,
|
:repository_resolver,
|
||||||
:container_resolver,
|
:container_class,
|
||||||
:project_resolver,
|
:project_resolver,
|
||||||
:guest_read_ability,
|
:guest_read_ability,
|
||||||
:suffix
|
:suffix
|
||||||
|
@ -15,36 +15,27 @@ module Gitlab
|
||||||
name:,
|
name:,
|
||||||
access_checker_class:,
|
access_checker_class:,
|
||||||
repository_resolver:,
|
repository_resolver:,
|
||||||
container_resolver: default_container_resolver,
|
container_class: default_container_class,
|
||||||
project_resolver: nil,
|
project_resolver: nil,
|
||||||
guest_read_ability: :download_code,
|
guest_read_ability: :download_code,
|
||||||
suffix: nil)
|
suffix: nil)
|
||||||
@name = name
|
@name = name
|
||||||
@access_checker_class = access_checker_class
|
@access_checker_class = access_checker_class
|
||||||
@repository_resolver = repository_resolver
|
@repository_resolver = repository_resolver
|
||||||
@container_resolver = container_resolver
|
@container_class = container_class
|
||||||
@project_resolver = project_resolver
|
@project_resolver = project_resolver
|
||||||
@guest_read_ability = guest_read_ability
|
@guest_read_ability = guest_read_ability
|
||||||
@suffix = suffix
|
@suffix = suffix
|
||||||
end
|
end
|
||||||
|
|
||||||
def identifier_for_container(container)
|
def identifier_for_container(container)
|
||||||
|
if container.is_a?(Group)
|
||||||
|
return "#{container.class.name.underscore}-#{container.id}-#{name}"
|
||||||
|
end
|
||||||
|
|
||||||
"#{name}-#{container.id}"
|
"#{name}-#{container.id}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def fetch_id(identifier)
|
|
||||||
match = /\A#{name}-(?<id>\d+)\z/.match(identifier)
|
|
||||||
match[:id] if match
|
|
||||||
end
|
|
||||||
|
|
||||||
def fetch_container!(identifier)
|
|
||||||
id = fetch_id(identifier)
|
|
||||||
|
|
||||||
raise ArgumentError, "Invalid GL Repository \"#{identifier}\"" unless id
|
|
||||||
|
|
||||||
container_resolver.call(id)
|
|
||||||
end
|
|
||||||
|
|
||||||
def wiki?
|
def wiki?
|
||||||
self == WIKI
|
self == WIKI
|
||||||
end
|
end
|
||||||
|
@ -85,8 +76,8 @@ module Gitlab
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def default_container_resolver
|
def default_container_class
|
||||||
-> (id) { Project.find_by_id(id) }
|
Project
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -104,6 +104,23 @@ module Gitlab
|
||||||
command :target_branch do |branch_name|
|
command :target_branch do |branch_name|
|
||||||
@updates[:target_branch] = branch_name if project.repository.branch_exists?(branch_name)
|
@updates[:target_branch] = branch_name if project.repository.branch_exists?(branch_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
desc _('Submit a review')
|
||||||
|
explanation _('Submit the current review.')
|
||||||
|
types MergeRequest
|
||||||
|
condition do
|
||||||
|
quick_action_target.persisted?
|
||||||
|
end
|
||||||
|
command :submit_review do
|
||||||
|
next if params[:review_id]
|
||||||
|
|
||||||
|
result = DraftNotes::PublishService.new(quick_action_target, current_user).execute
|
||||||
|
@execution_message[:submit_review] = if result[:status] == :success
|
||||||
|
_('Submitted the current review.')
|
||||||
|
else
|
||||||
|
result[:message]
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def merge_orchestration_service
|
def merge_orchestration_service
|
||||||
|
|
|
@ -96,6 +96,11 @@ msgid_plural "%d comments"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "%d comment on this commit"
|
||||||
|
msgid_plural "%d comments on this commit"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
msgid "%d commit"
|
msgid "%d commit"
|
||||||
msgid_plural "%d commits"
|
msgid_plural "%d commits"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
|
@ -1218,6 +1223,9 @@ msgstr ""
|
||||||
msgid "Add a bullet list"
|
msgid "Add a bullet list"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Add a comment to this line"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Add a general comment to this %{noteableDisplayName}."
|
msgid "Add a general comment to this %{noteableDisplayName}."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -1419,6 +1427,9 @@ msgstr ""
|
||||||
msgid "Admin Area"
|
msgid "Admin Area"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Admin Note"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Admin Overview"
|
msgid "Admin Overview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -3605,7 +3616,7 @@ msgstr ""
|
||||||
msgid "CI / CD"
|
msgid "CI / CD"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "CI / CD Charts"
|
msgid "CI / CD Analytics"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "CI / CD Settings"
|
msgid "CI / CD Settings"
|
||||||
|
@ -5838,6 +5849,11 @@ msgid_plural "ContainerRegistry|%{count} Image repositories"
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
|
msgid "ContainerRegistry|%{count} Tag"
|
||||||
|
msgid_plural "ContainerRegistry|%{count} Tags"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
msgid "ContainerRegistry|%{imageName} tags"
|
msgid "ContainerRegistry|%{imageName} tags"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -6407,6 +6423,9 @@ msgstr ""
|
||||||
msgid "Create issue"
|
msgid "Create issue"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Create iteration"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Create lists from labels. Issues with that label appear in that list."
|
msgid "Create lists from labels. Issues with that label appear in that list."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -23333,6 +23352,9 @@ msgstr ""
|
||||||
msgid "Unable to resolve"
|
msgid "Unable to resolve"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Unable to save iteration. Please try again"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Unable to save your changes. Please try again."
|
msgid "Unable to save your changes. Please try again."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|
|
@ -254,6 +254,18 @@ describe Admin::UsersController do
|
||||||
errors = assigns[:user].errors
|
errors = assigns[:user].errors
|
||||||
expect(errors).to contain_exactly(errors.full_message(:email, I18n.t('errors.messages.invalid')))
|
expect(errors).to contain_exactly(errors.full_message(:email, I18n.t('errors.messages.invalid')))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'admin notes' do
|
||||||
|
it 'creates the user with note' do
|
||||||
|
note = '2020-05-12 | Note | DCMA | Link'
|
||||||
|
user_params = attributes_for(:user, note: note)
|
||||||
|
|
||||||
|
expect { post :create, params: { user: user_params } }.to change { User.count }.by(1)
|
||||||
|
|
||||||
|
new_user = User.last
|
||||||
|
expect(new_user.note).to eq(note)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST update' do
|
describe 'POST update' do
|
||||||
|
@ -338,6 +350,20 @@ describe Admin::UsersController do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'admin notes' do
|
||||||
|
it 'updates the note for the user' do
|
||||||
|
note = '2020-05-12 | Note | DCMA | Link'
|
||||||
|
params = {
|
||||||
|
id: user.to_param,
|
||||||
|
user: {
|
||||||
|
note: note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect { post :update, params: params }.to change { user.reload.note }.to(note)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "DELETE #remove_email" do
|
describe "DELETE #remove_email" do
|
||||||
|
|
|
@ -0,0 +1,455 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Projects::MergeRequests::DraftsController do
|
||||||
|
include RepoHelpers
|
||||||
|
|
||||||
|
let(:project) { create(:project, :repository) }
|
||||||
|
let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
|
||||||
|
let(:user) { project.owner }
|
||||||
|
let(:user2) { create(:user) }
|
||||||
|
|
||||||
|
let(:params) do
|
||||||
|
{
|
||||||
|
namespace_id: project.namespace.to_param,
|
||||||
|
project_id: project.to_param,
|
||||||
|
merge_request_id: merge_request.iid
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in(user)
|
||||||
|
stub_licensed_features(multiple_merge_request_assignees: true)
|
||||||
|
stub_commonmark_sourcepos_disabled
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET #index' do
|
||||||
|
let!(:draft_note) { create(:draft_note, merge_request: merge_request, author: user) }
|
||||||
|
|
||||||
|
it 'list merge request draft notes for current user' do
|
||||||
|
get :index, params: params
|
||||||
|
|
||||||
|
expect(json_response.first['merge_request_id']).to eq(merge_request.id)
|
||||||
|
expect(json_response.first['author']['id']).to eq(user.id)
|
||||||
|
expect(json_response.first['note_html']).not_to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #create' do
|
||||||
|
def create_draft_note(draft_overrides: {}, overrides: {})
|
||||||
|
post_params = params.merge({
|
||||||
|
draft_note: {
|
||||||
|
note: 'This is a unpublished comment'
|
||||||
|
}.merge(draft_overrides)
|
||||||
|
}.merge(overrides))
|
||||||
|
|
||||||
|
post :create, params: post_params
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'without permissions' do
|
||||||
|
let(:project) { create(:project, :private) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow draft note creation' do
|
||||||
|
expect { create_draft_note }.to change { DraftNote.count }.by(0)
|
||||||
|
expect(response).to have_gitlab_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a draft note' do
|
||||||
|
expect { create_draft_note }.to change { DraftNote.count }.by(1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates draft note with position' do
|
||||||
|
diff_refs = project.commit(sample_commit.id).try(:diff_refs)
|
||||||
|
|
||||||
|
position = Gitlab::Diff::Position.new(
|
||||||
|
old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 14,
|
||||||
|
diff_refs: diff_refs
|
||||||
|
)
|
||||||
|
|
||||||
|
create_draft_note(draft_overrides: { position: position.to_json })
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
|
expect(json_response['position']).to be_present
|
||||||
|
expect(json_response['file_hash']).to be_present
|
||||||
|
expect(json_response['file_identifier_hash']).to be_present
|
||||||
|
expect(json_response['line_code']).to match(/\w+_\d+_\d+/)
|
||||||
|
expect(json_response['note_html']).to eq('<p dir="auto">This is a unpublished comment</p>')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a draft note with quick actions' do
|
||||||
|
create_draft_note(draft_overrides: { note: "#{user2.to_reference}\n/assign #{user.to_reference}" })
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
|
expect(json_response['note_html']).to match(/#{user2.to_reference}/)
|
||||||
|
expect(json_response['references']['commands']).to match(/Assigns/)
|
||||||
|
expect(json_response['references']['users']).to include(user2.username)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'in a thread' do
|
||||||
|
let(:discussion) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project).discussion }
|
||||||
|
|
||||||
|
it 'creates draft note as a reply' do
|
||||||
|
expect do
|
||||||
|
create_draft_note(overrides: { in_reply_to_discussion_id: discussion.reply_id })
|
||||||
|
end.to change { DraftNote.count }.by(1)
|
||||||
|
|
||||||
|
draft_note = DraftNote.last
|
||||||
|
|
||||||
|
expect(draft_note).to be_valid
|
||||||
|
expect(draft_note.discussion_id).to eq(discussion.reply_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'creates a draft note that will resolve a thread' do
|
||||||
|
expect do
|
||||||
|
create_draft_note(
|
||||||
|
overrides: { in_reply_to_discussion_id: discussion.reply_id },
|
||||||
|
draft_overrides: { resolve_discussion: true }
|
||||||
|
)
|
||||||
|
end.to change { DraftNote.count }.by(1)
|
||||||
|
|
||||||
|
draft_note = DraftNote.last
|
||||||
|
|
||||||
|
expect(draft_note).to be_valid
|
||||||
|
expect(draft_note.discussion_id).to eq(discussion.reply_id)
|
||||||
|
expect(draft_note.resolve_discussion).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'cannot create more than one draft note per thread' do
|
||||||
|
expect do
|
||||||
|
create_draft_note(
|
||||||
|
overrides: { in_reply_to_discussion_id: discussion.reply_id },
|
||||||
|
draft_overrides: { resolve_discussion: true }
|
||||||
|
)
|
||||||
|
end.to change { DraftNote.count }.by(1)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
create_draft_note(
|
||||||
|
overrides: { in_reply_to_discussion_id: discussion.reply_id },
|
||||||
|
draft_overrides: { resolve_discussion: true, note: 'A note' }
|
||||||
|
)
|
||||||
|
end.to change { DraftNote.count }.by(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'commit_id is present' do
|
||||||
|
let(:commit) { project.commit(sample_commit.id) }
|
||||||
|
|
||||||
|
let(:position) do
|
||||||
|
Gitlab::Diff::Position.new(
|
||||||
|
old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 14,
|
||||||
|
diff_refs: commit.diff_refs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
create_draft_note(draft_overrides: { commit_id: commit_id, position: position.to_json })
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'value is a commit sha' do
|
||||||
|
let(:commit_id) { commit.id }
|
||||||
|
|
||||||
|
it 'creates the draft note with commit ID' do
|
||||||
|
expect(DraftNote.last.commit_id).to eq(commit_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'value is "undefined"' do
|
||||||
|
let(:commit_id) { 'undefined' }
|
||||||
|
|
||||||
|
it 'creates the draft note with nil commit ID' do
|
||||||
|
expect(DraftNote.last.commit_id).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'PUT #update' do
|
||||||
|
let(:draft) { create(:draft_note, merge_request: merge_request, author: user) }
|
||||||
|
|
||||||
|
def update_draft_note(overrides = {})
|
||||||
|
put_params = params.merge({
|
||||||
|
id: draft.id,
|
||||||
|
draft_note: {
|
||||||
|
note: 'This is an updated unpublished comment'
|
||||||
|
}.merge(overrides)
|
||||||
|
})
|
||||||
|
|
||||||
|
put :update, params: put_params
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'without permissions' do
|
||||||
|
before do
|
||||||
|
sign_in(user2)
|
||||||
|
project.add_developer(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow editing draft note belonging to someone else' do
|
||||||
|
update_draft_note
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:not_found)
|
||||||
|
expect(draft.reload.note).not_to eq('This is an updated unpublished comment')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates the draft' do
|
||||||
|
expect(draft.note).not_to be_empty
|
||||||
|
|
||||||
|
expect { update_draft_note }.not_to change { DraftNote.count }
|
||||||
|
|
||||||
|
draft.reload
|
||||||
|
|
||||||
|
expect(draft.note).to eq('This is an updated unpublished comment')
|
||||||
|
expect(json_response['note_html']).not_to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'POST #publish' do
|
||||||
|
context 'without permissions' do
|
||||||
|
shared_examples_for 'action that does not allow publishing draft note' do
|
||||||
|
it 'does not allow publishing draft note' do
|
||||||
|
expect { action }
|
||||||
|
.to not_change { Note.count }
|
||||||
|
.and not_change { DraftNote.count }
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
sign_in(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when note belongs to someone else' do
|
||||||
|
before do
|
||||||
|
project.add_developer(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'action that does not allow publishing draft note' do
|
||||||
|
let!(:draft) { create(:draft_note, merge_request: merge_request, author: user) }
|
||||||
|
let(:action) { post :publish, params: params.merge(id: draft.id) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when merge request discussion is locked' do
|
||||||
|
let(:project) { create(:project, :public, :merge_requests_public, :repository) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
create(:draft_note, merge_request: merge_request, author: user2)
|
||||||
|
merge_request.update!(discussion_locked: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
it_behaves_like 'action that does not allow publishing draft note' do
|
||||||
|
let(:action) { post :publish, params: params }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when PublishService errors' do
|
||||||
|
it 'returns message and 500 response' do
|
||||||
|
create(:draft_note, merge_request: merge_request, author: user)
|
||||||
|
error_message = "Something went wrong"
|
||||||
|
|
||||||
|
expect_next_instance_of(DraftNotes::PublishService) do |service|
|
||||||
|
allow(service).to receive(:execute).and_return({ message: error_message, status: :error })
|
||||||
|
end
|
||||||
|
|
||||||
|
post :publish, params: params
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:error)
|
||||||
|
expect(json_response["message"]).to include(error_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'publishes draft notes with position' do
|
||||||
|
diff_refs = project.commit(sample_commit.id).try(:diff_refs)
|
||||||
|
|
||||||
|
position = Gitlab::Diff::Position.new(
|
||||||
|
old_path: "files/ruby/popen.rb",
|
||||||
|
new_path: "files/ruby/popen.rb",
|
||||||
|
old_line: nil,
|
||||||
|
new_line: 14,
|
||||||
|
diff_refs: diff_refs
|
||||||
|
)
|
||||||
|
|
||||||
|
draft = create(:draft_note_on_text_diff, merge_request: merge_request, author: user, position: position)
|
||||||
|
|
||||||
|
expect { post :publish, params: params }.to change { Note.count }.by(1)
|
||||||
|
.and change { DraftNote.count }.by(-1)
|
||||||
|
|
||||||
|
note = merge_request.notes.reload.last
|
||||||
|
|
||||||
|
expect(note.note).to eq(draft.note)
|
||||||
|
expect(note.position).to eq(draft.position)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does nothing if there are no draft notes' do
|
||||||
|
expect { post :publish, params: params }.to change { Note.count }.by(0).and change { DraftNote.count }.by(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'publishes a draft note with quick actions and applies them' do
|
||||||
|
project.add_developer(user2)
|
||||||
|
create(:draft_note, merge_request: merge_request, author: user,
|
||||||
|
note: "/assign #{user2.to_reference}")
|
||||||
|
|
||||||
|
expect(merge_request.assignees).to be_empty
|
||||||
|
|
||||||
|
expect { post :publish, params: params }.to change { Note.count }.by(1)
|
||||||
|
.and change { DraftNote.count }.by(-1)
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
|
expect(merge_request.reload.assignee_ids).to match_array([user2.id])
|
||||||
|
expect(Note.last.system?).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'publishes all draft notes for an MR' do
|
||||||
|
draft_params = { merge_request: merge_request, author: user }
|
||||||
|
|
||||||
|
drafts = create_list(:draft_note, 4, draft_params)
|
||||||
|
|
||||||
|
note = create(:discussion_note_on_merge_request, noteable: merge_request, project: project)
|
||||||
|
draft_reply = create(:draft_note, draft_params.merge(discussion_id: note.discussion_id))
|
||||||
|
|
||||||
|
diff_note = create(:diff_note_on_merge_request, noteable: merge_request, project: project)
|
||||||
|
diff_draft_reply = create(:draft_note, draft_params.merge(discussion_id: diff_note.discussion_id))
|
||||||
|
|
||||||
|
expect { post :publish, params: params }.to change { Note.count }.by(6)
|
||||||
|
.and change { DraftNote.count }.by(-6)
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
|
|
||||||
|
notes = merge_request.notes.reload
|
||||||
|
|
||||||
|
expect(notes.pluck(:note)).to include(*drafts.map(&:note))
|
||||||
|
expect(note.discussion.notes.last.note).to eq(draft_reply.note)
|
||||||
|
expect(diff_note.discussion.notes.last.note).to eq(diff_draft_reply.note)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'can publish just a single draft note' do
|
||||||
|
draft_params = { merge_request: merge_request, author: user }
|
||||||
|
|
||||||
|
drafts = create_list(:draft_note, 4, draft_params)
|
||||||
|
|
||||||
|
expect { post :publish, params: params.merge(id: drafts.first.id) }.to change { Note.count }.by(1)
|
||||||
|
.and change { DraftNote.count }.by(-1)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when publishing drafts in a thread' do
|
||||||
|
let(:note) { create(:discussion_note_on_merge_request, noteable: merge_request, project: project) }
|
||||||
|
|
||||||
|
def create_reply(discussion_id, resolves: false)
|
||||||
|
create(:draft_note,
|
||||||
|
merge_request: merge_request,
|
||||||
|
author: user,
|
||||||
|
discussion_id: discussion_id,
|
||||||
|
resolve_discussion: resolves
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'resolves a thread if the draft note resolves it' do
|
||||||
|
draft_reply = create_reply(note.discussion_id, resolves: true)
|
||||||
|
|
||||||
|
post :publish, params: params
|
||||||
|
|
||||||
|
discussion = note.discussion
|
||||||
|
|
||||||
|
expect(discussion.notes.last.note).to eq(draft_reply.note)
|
||||||
|
expect(discussion.resolved?).to eq(true)
|
||||||
|
expect(discussion.resolved_by.id).to eq(user.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'unresolves a thread if the draft note unresolves it' do
|
||||||
|
note.discussion.resolve!(user)
|
||||||
|
expect(note.discussion.resolved?).to eq(true)
|
||||||
|
|
||||||
|
draft_reply = create_reply(note.discussion_id, resolves: false)
|
||||||
|
|
||||||
|
post :publish, params: params
|
||||||
|
|
||||||
|
discussion = note.discussion
|
||||||
|
|
||||||
|
expect(discussion.notes.last.note).to eq(draft_reply.note)
|
||||||
|
expect(discussion.resolved?).to eq(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE #destroy' do
|
||||||
|
let(:draft) { create(:draft_note, merge_request: merge_request, author: user) }
|
||||||
|
|
||||||
|
def create_draft
|
||||||
|
create(:draft_note, merge_request: merge_request, author: user)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'without permissions' do
|
||||||
|
before do
|
||||||
|
sign_in(user2)
|
||||||
|
project.add_developer(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow destroying a draft note belonging to someone else' do
|
||||||
|
draft = create(:draft_note, merge_request: merge_request, author: user)
|
||||||
|
|
||||||
|
expect { post :destroy, params: params.merge(id: draft.id) }
|
||||||
|
.not_to change { DraftNote.count }
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'destroys the draft note when ID is given' do
|
||||||
|
draft = create_draft
|
||||||
|
|
||||||
|
expect { delete :destroy, params: params.merge(id: draft.id) }.to change { DraftNote.count }.by(-1)
|
||||||
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'without permissions' do
|
||||||
|
before do
|
||||||
|
sign_in(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not allow editing draft note belonging to someone else' do
|
||||||
|
draft = create_draft
|
||||||
|
|
||||||
|
expect { delete :destroy, params: params.merge(id: draft.id) }.to change { DraftNote.count }.by(0)
|
||||||
|
expect(response).to have_gitlab_http_status(:not_found)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'DELETE #discard' do
|
||||||
|
it 'deletes all DraftNotes belonging to a user in a Merge Request' do
|
||||||
|
create_list(:draft_note, 6, merge_request: merge_request, author: user)
|
||||||
|
|
||||||
|
expect { delete :discard, params: params }.to change { DraftNote.count }.by(-6)
|
||||||
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'without permissions' do
|
||||||
|
before do
|
||||||
|
sign_in(user2)
|
||||||
|
project.add_developer(user2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not destroys a draft note belonging to someone else' do
|
||||||
|
create(:draft_note, merge_request: merge_request, author: user)
|
||||||
|
|
||||||
|
expect { post :discard, params: params }
|
||||||
|
.not_to change { DraftNote.count }
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:ok)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -183,6 +183,10 @@ FactoryBot.define do
|
||||||
confidential { true }
|
confidential { true }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :with_review do
|
||||||
|
review
|
||||||
|
end
|
||||||
|
|
||||||
transient do
|
transient do
|
||||||
in_reply_to { nil }
|
in_reply_to { nil }
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import MockAdapter from 'axios-mock-adapter';
|
import MockAdapter from 'axios-mock-adapter';
|
||||||
|
|
||||||
import testAction from 'helpers/vuex_action_helper';
|
import testAction from 'helpers/vuex_action_helper';
|
||||||
|
import Tracking from '~/tracking';
|
||||||
import * as types from '~/logs/stores/mutation_types';
|
import * as types from '~/logs/stores/mutation_types';
|
||||||
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
import { convertToFixedRange } from '~/lib/utils/datetime_range';
|
||||||
import logsPageState from '~/logs/stores/state';
|
import logsPageState from '~/logs/stores/state';
|
||||||
|
@ -104,7 +104,7 @@ describe('Logs Store actions', () => {
|
||||||
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
||||||
{ type: types.SET_SEARCH, payload: '' },
|
{ type: types.SET_SEARCH, payload: '' },
|
||||||
],
|
],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||||
));
|
));
|
||||||
|
|
||||||
it('text search should filter with a search term', () =>
|
it('text search should filter with a search term', () =>
|
||||||
|
@ -116,7 +116,7 @@ describe('Logs Store actions', () => {
|
||||||
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
||||||
{ type: types.SET_SEARCH, payload: mockSearch },
|
{ type: types.SET_SEARCH, payload: mockSearch },
|
||||||
],
|
],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||||
));
|
));
|
||||||
|
|
||||||
it('pod search should filter with a search term', () =>
|
it('pod search should filter with a search term', () =>
|
||||||
|
@ -128,7 +128,7 @@ describe('Logs Store actions', () => {
|
||||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||||
{ type: types.SET_SEARCH, payload: '' },
|
{ type: types.SET_SEARCH, payload: '' },
|
||||||
],
|
],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||||
));
|
));
|
||||||
|
|
||||||
it('pod search should filter with a pod selection and a search term', () =>
|
it('pod search should filter with a pod selection and a search term', () =>
|
||||||
|
@ -140,7 +140,7 @@ describe('Logs Store actions', () => {
|
||||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||||
{ type: types.SET_SEARCH, payload: mockSearch },
|
{ type: types.SET_SEARCH, payload: mockSearch },
|
||||||
],
|
],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||||
));
|
));
|
||||||
|
|
||||||
it('pod search should filter with a pod selection and two search terms', () =>
|
it('pod search should filter with a pod selection and two search terms', () =>
|
||||||
|
@ -152,7 +152,7 @@ describe('Logs Store actions', () => {
|
||||||
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
{ type: types.SET_CURRENT_POD_NAME, payload: null },
|
||||||
{ type: types.SET_SEARCH, payload: `term1 term2` },
|
{ type: types.SET_SEARCH, payload: `term1 term2` },
|
||||||
],
|
],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||||
));
|
));
|
||||||
|
|
||||||
it('pod search should filter with a pod selection and a search terms before and after', () =>
|
it('pod search should filter with a pod selection and a search terms before and after', () =>
|
||||||
|
@ -168,7 +168,7 @@ describe('Logs Store actions', () => {
|
||||||
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName },
|
||||||
{ type: types.SET_SEARCH, payload: `term1 term2` },
|
{ type: types.SET_SEARCH, payload: `term1 term2` },
|
||||||
],
|
],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'used_search_bar' }],
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -179,7 +179,7 @@ describe('Logs Store actions', () => {
|
||||||
mockPodName,
|
mockPodName,
|
||||||
state,
|
state,
|
||||||
[{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }],
|
[{ type: types.SET_CURRENT_POD_NAME, payload: mockPodName }],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'pod_log_changed' }],
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -198,7 +198,7 @@ describe('Logs Store actions', () => {
|
||||||
{ type: types.REQUEST_ENVIRONMENTS_DATA },
|
{ type: types.REQUEST_ENVIRONMENTS_DATA },
|
||||||
{ type: types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, payload: mockEnvironments },
|
{ type: types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, payload: mockEnvironments },
|
||||||
],
|
],
|
||||||
[{ type: 'fetchLogs' }],
|
[{ type: 'fetchLogs', payload: 'environment_selected' }],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -471,3 +471,58 @@ describe('Logs Store actions', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Tracking user interaction', () => {
|
||||||
|
let commit;
|
||||||
|
let dispatch;
|
||||||
|
let state;
|
||||||
|
let mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Tracking, 'event');
|
||||||
|
commit = jest.fn();
|
||||||
|
dispatch = jest.fn();
|
||||||
|
state = logsPageState();
|
||||||
|
state.environments.options = mockEnvironments;
|
||||||
|
state.environments.current = mockEnvName;
|
||||||
|
|
||||||
|
mock = new MockAdapter(axios);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logs with data', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.onGet(mockLogsEndpoint).reply(200, mockResponse);
|
||||||
|
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks fetched logs with data', () => {
|
||||||
|
return fetchLogs({ state, commit, dispatch }, 'environment_selected').then(() => {
|
||||||
|
expect(Tracking.event).toHaveBeenCalledWith(document.body.dataset.page, 'logs_view', {
|
||||||
|
label: 'environment_selected',
|
||||||
|
property: 'count',
|
||||||
|
value: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Logs without data', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.onGet(mockLogsEndpoint).reply(200, {
|
||||||
|
...mockResponse,
|
||||||
|
logs: [],
|
||||||
|
});
|
||||||
|
mock.onGet(mockLogsEndpoint).replyOnce(202); // mock reactive cache
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not track empty log responses', () => {
|
||||||
|
return fetchLogs({ state, commit, dispatch }).then(() => {
|
||||||
|
expect(Tracking.event).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
import { shallowMount } from '@vue/test-utils';
|
||||||
|
import { GlIcon, GlSprintf } from '@gitlab/ui';
|
||||||
|
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
|
||||||
|
import Component from '~/registry/explorer/components/image_list_row.vue';
|
||||||
|
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
||||||
|
import {
|
||||||
|
ROW_SCHEDULED_FOR_DELETION,
|
||||||
|
LIST_DELETE_BUTTON_DISABLED,
|
||||||
|
} from '~/registry/explorer/constants';
|
||||||
|
import { RouterLink } from '../stubs';
|
||||||
|
import { imagesListResponse } from '../mock_data';
|
||||||
|
|
||||||
|
describe('Image List Row', () => {
|
||||||
|
let wrapper;
|
||||||
|
const item = imagesListResponse.data[0];
|
||||||
|
const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]');
|
||||||
|
const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]');
|
||||||
|
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
|
||||||
|
const findDeleteButtonWrapper = () => wrapper.find('[data-testid="deleteButtonWrapper"]');
|
||||||
|
const findClipboardButton = () => wrapper.find(ClipboardButton);
|
||||||
|
|
||||||
|
const mountComponent = props => {
|
||||||
|
wrapper = shallowMount(Component, {
|
||||||
|
stubs: {
|
||||||
|
RouterLink,
|
||||||
|
GlSprintf,
|
||||||
|
},
|
||||||
|
propsData: {
|
||||||
|
item,
|
||||||
|
...props,
|
||||||
|
},
|
||||||
|
directives: {
|
||||||
|
GlTooltip: createMockDirective(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
wrapper.destroy();
|
||||||
|
wrapper = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('main tooltip', () => {
|
||||||
|
it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
|
||||||
|
mountComponent();
|
||||||
|
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
|
||||||
|
expect(tooltip).toBeDefined();
|
||||||
|
expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is disabled when item is being deleted', () => {
|
||||||
|
mountComponent({ item: { ...item, deleting: true } });
|
||||||
|
const tooltip = getBinding(wrapper.element, 'gl-tooltip');
|
||||||
|
expect(tooltip.value.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('image title and path', () => {
|
||||||
|
it('contains a link to the details page', () => {
|
||||||
|
mountComponent();
|
||||||
|
const link = findDetailsLink();
|
||||||
|
expect(link.html()).toContain(item.path);
|
||||||
|
expect(link.props('to').name).toBe('details');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains a clipboard button', () => {
|
||||||
|
mountComponent();
|
||||||
|
const button = findClipboardButton();
|
||||||
|
expect(button.exists()).toBe(true);
|
||||||
|
expect(button.props('text')).toBe(item.location);
|
||||||
|
expect(button.props('title')).toBe(item.location);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete button wrapper', () => {
|
||||||
|
it('has a tooltip', () => {
|
||||||
|
mountComponent();
|
||||||
|
const tooltip = getBinding(findDeleteButtonWrapper().element, 'gl-tooltip');
|
||||||
|
expect(tooltip).toBeDefined();
|
||||||
|
expect(tooltip.value.title).toBe(LIST_DELETE_BUTTON_DISABLED);
|
||||||
|
});
|
||||||
|
it('tooltip is enabled when destroy_path is falsy', () => {
|
||||||
|
mountComponent({ item: { ...item, destroy_path: null } });
|
||||||
|
const tooltip = getBinding(findDeleteButtonWrapper().element, 'gl-tooltip');
|
||||||
|
expect(tooltip.value.disabled).toBeFalsy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('delete button', () => {
|
||||||
|
it('exists', () => {
|
||||||
|
mountComponent();
|
||||||
|
expect(findDeleteBtn().exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a delete event', () => {
|
||||||
|
mountComponent();
|
||||||
|
findDeleteBtn().vm.$emit('click');
|
||||||
|
expect(wrapper.emitted('delete')).toEqual([[item]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each`
|
||||||
|
destroy_path | deleting | state
|
||||||
|
${null} | ${null} | ${'true'}
|
||||||
|
${null} | ${true} | ${'true'}
|
||||||
|
${'foo'} | ${true} | ${'true'}
|
||||||
|
${'foo'} | ${false} | ${undefined}
|
||||||
|
`(
|
||||||
|
'disabled is $state when destroy_path is $destroy_path and deleting is $deleting',
|
||||||
|
({ destroy_path, deleting, state }) => {
|
||||||
|
mountComponent({ item: { ...item, destroy_path, deleting } });
|
||||||
|
expect(findDeleteBtn().attributes('disabled')).toBe(state);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tags count', () => {
|
||||||
|
it('exists', () => {
|
||||||
|
mountComponent();
|
||||||
|
expect(findTagsCount().exists()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains a tag icon', () => {
|
||||||
|
mountComponent();
|
||||||
|
const icon = findTagsCount().find(GlIcon);
|
||||||
|
expect(icon.exists()).toBe(true);
|
||||||
|
expect(icon.props('name')).toBe('tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('tags count text', () => {
|
||||||
|
it('with one tag in the image', () => {
|
||||||
|
mountComponent({ item: { ...item, tags_count: 1 } });
|
||||||
|
expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
|
||||||
|
});
|
||||||
|
it('with more than one tag in the image', () => {
|
||||||
|
mountComponent({ item: { ...item, tags_count: 3 } });
|
||||||
|
expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -1,26 +1,18 @@
|
||||||
import { shallowMount } from '@vue/test-utils';
|
import { shallowMount } from '@vue/test-utils';
|
||||||
import { GlPagination } from '@gitlab/ui';
|
import { GlPagination } from '@gitlab/ui';
|
||||||
import Component from '~/registry/explorer/components/image_list.vue';
|
import Component from '~/registry/explorer/components/image_list.vue';
|
||||||
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
|
import ImageListRow from '~/registry/explorer/components/image_list_row.vue';
|
||||||
import { RouterLink } from '../stubs';
|
|
||||||
import { imagesListResponse, imagePagination } from '../mock_data';
|
import { imagesListResponse, imagePagination } from '../mock_data';
|
||||||
|
|
||||||
describe('Image List', () => {
|
describe('Image List', () => {
|
||||||
let wrapper;
|
let wrapper;
|
||||||
|
|
||||||
const firstElement = imagesListResponse.data[0];
|
const findRow = () => wrapper.findAll(ImageListRow);
|
||||||
|
|
||||||
const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]');
|
|
||||||
const findRowItems = () => wrapper.findAll('[data-testid="rowItem"]');
|
|
||||||
const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]');
|
|
||||||
const findClipboardButton = () => wrapper.find(ClipboardButton);
|
|
||||||
const findPagination = () => wrapper.find(GlPagination);
|
const findPagination = () => wrapper.find(GlPagination);
|
||||||
|
|
||||||
const mountComponent = () => {
|
const mountComponent = () => {
|
||||||
wrapper = shallowMount(Component, {
|
wrapper = shallowMount(Component, {
|
||||||
stubs: {
|
|
||||||
RouterLink,
|
|
||||||
},
|
|
||||||
propsData: {
|
propsData: {
|
||||||
images: imagesListResponse.data,
|
images: imagesListResponse.data,
|
||||||
pagination: imagePagination,
|
pagination: imagePagination,
|
||||||
|
@ -32,26 +24,17 @@ describe('Image List', () => {
|
||||||
mountComponent();
|
mountComponent();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('contains one list element for each image', () => {
|
describe('list', () => {
|
||||||
expect(findRowItems().length).toBe(imagesListResponse.data.length);
|
it('contains one list element for each image', () => {
|
||||||
});
|
expect(findRow().length).toBe(imagesListResponse.data.length);
|
||||||
|
});
|
||||||
|
|
||||||
it('contains a link to the details page', () => {
|
it('when delete event is emitted on the row it emits up a delete event', () => {
|
||||||
const link = findDetailsLink();
|
findRow()
|
||||||
expect(link.html()).toContain(firstElement.path);
|
.at(0)
|
||||||
expect(link.props('to').name).toBe('details');
|
.vm.$emit('delete', 'foo');
|
||||||
});
|
expect(wrapper.emitted('delete')).toEqual([['foo']]);
|
||||||
|
});
|
||||||
it('contains a clipboard button', () => {
|
|
||||||
const button = findClipboardButton();
|
|
||||||
expect(button.exists()).toBe(true);
|
|
||||||
expect(button.props('text')).toBe(firstElement.location);
|
|
||||||
expect(button.props('title')).toBe(firstElement.location);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be possible to delete a repo', () => {
|
|
||||||
const deleteBtn = findDeleteBtn();
|
|
||||||
expect(deleteBtn.exists()).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pagination', () => {
|
describe('pagination', () => {
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
|
||||||
|
describe Gitlab::GlRepository::Identifier do
|
||||||
|
let_it_be(:project) { create(:project) }
|
||||||
|
let_it_be(:personal_snippet) { create(:personal_snippet, author: project.owner) }
|
||||||
|
let_it_be(:project_snippet) { create(:project_snippet, project: project, author: project.owner) }
|
||||||
|
|
||||||
|
describe 'project repository' do
|
||||||
|
it_behaves_like 'parsing gl_repository identifier' do
|
||||||
|
let(:record_id) { project.id }
|
||||||
|
let(:identifier) { "project-#{record_id}" }
|
||||||
|
let(:expected_container) { project }
|
||||||
|
let(:expected_type) { Gitlab::GlRepository::PROJECT }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'wiki' do
|
||||||
|
it_behaves_like 'parsing gl_repository identifier' do
|
||||||
|
let(:record_id) { project.id }
|
||||||
|
let(:identifier) { "wiki-#{record_id}" }
|
||||||
|
let(:expected_container) { project }
|
||||||
|
let(:expected_type) { Gitlab::GlRepository::WIKI }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'snippet' do
|
||||||
|
context 'when PersonalSnippet' do
|
||||||
|
it_behaves_like 'parsing gl_repository identifier' do
|
||||||
|
let(:record_id) { personal_snippet.id }
|
||||||
|
let(:identifier) { "snippet-#{record_id}" }
|
||||||
|
let(:expected_container) { personal_snippet }
|
||||||
|
let(:expected_type) { Gitlab::GlRepository::SNIPPET }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when ProjectSnippet' do
|
||||||
|
it_behaves_like 'parsing gl_repository identifier' do
|
||||||
|
let(:record_id) { project_snippet.id }
|
||||||
|
let(:identifier) { "snippet-#{record_id}" }
|
||||||
|
let(:expected_container) { project_snippet }
|
||||||
|
let(:expected_type) { Gitlab::GlRepository::SNIPPET }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'design' do
|
||||||
|
it_behaves_like 'parsing gl_repository identifier' do
|
||||||
|
let(:record_id) { project.id }
|
||||||
|
let(:identifier) { "design-#{project.id}" }
|
||||||
|
let(:expected_container) { project }
|
||||||
|
let(:expected_type) { Gitlab::GlRepository::DESIGN }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'incorrect format' do
|
||||||
|
def expect_error_raised_for(identifier)
|
||||||
|
expect { described_class.new(identifier) }.to raise_error(ArgumentError)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error for incorrect id' do
|
||||||
|
expect_error_raised_for('wiki-noid')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error for incorrect type' do
|
||||||
|
expect_error_raised_for('foo-2')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error for incorrect three-segment container' do
|
||||||
|
expect_error_raised_for('snippet-2-wiki')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error for one segment' do
|
||||||
|
expect_error_raised_for('snippet')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises error for more than three segments' do
|
||||||
|
expect_error_raised_for('project-1-wiki-bar')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -13,7 +13,7 @@ describe Gitlab::GlRepository::RepoType do
|
||||||
|
|
||||||
describe Gitlab::GlRepository::PROJECT do
|
describe Gitlab::GlRepository::PROJECT do
|
||||||
it_behaves_like 'a repo type' do
|
it_behaves_like 'a repo type' do
|
||||||
let(:expected_id) { project.id.to_s }
|
let(:expected_id) { project.id }
|
||||||
let(:expected_identifier) { "project-#{expected_id}" }
|
let(:expected_identifier) { "project-#{expected_id}" }
|
||||||
let(:expected_suffix) { '' }
|
let(:expected_suffix) { '' }
|
||||||
let(:expected_container) { project }
|
let(:expected_container) { project }
|
||||||
|
@ -42,7 +42,7 @@ describe Gitlab::GlRepository::RepoType do
|
||||||
|
|
||||||
describe Gitlab::GlRepository::WIKI do
|
describe Gitlab::GlRepository::WIKI do
|
||||||
it_behaves_like 'a repo type' do
|
it_behaves_like 'a repo type' do
|
||||||
let(:expected_id) { project.id.to_s }
|
let(:expected_id) { project.id }
|
||||||
let(:expected_identifier) { "wiki-#{expected_id}" }
|
let(:expected_identifier) { "wiki-#{expected_id}" }
|
||||||
let(:expected_suffix) { '.wiki' }
|
let(:expected_suffix) { '.wiki' }
|
||||||
let(:expected_container) { project }
|
let(:expected_container) { project }
|
||||||
|
@ -72,7 +72,7 @@ describe Gitlab::GlRepository::RepoType do
|
||||||
describe Gitlab::GlRepository::SNIPPET do
|
describe Gitlab::GlRepository::SNIPPET do
|
||||||
context 'when PersonalSnippet' do
|
context 'when PersonalSnippet' do
|
||||||
it_behaves_like 'a repo type' do
|
it_behaves_like 'a repo type' do
|
||||||
let(:expected_id) { personal_snippet.id.to_s }
|
let(:expected_id) { personal_snippet.id }
|
||||||
let(:expected_identifier) { "snippet-#{expected_id}" }
|
let(:expected_identifier) { "snippet-#{expected_id}" }
|
||||||
let(:expected_suffix) { '' }
|
let(:expected_suffix) { '' }
|
||||||
let(:expected_repository) { personal_snippet.repository }
|
let(:expected_repository) { personal_snippet.repository }
|
||||||
|
@ -101,7 +101,7 @@ describe Gitlab::GlRepository::RepoType do
|
||||||
|
|
||||||
context 'when ProjectSnippet' do
|
context 'when ProjectSnippet' do
|
||||||
it_behaves_like 'a repo type' do
|
it_behaves_like 'a repo type' do
|
||||||
let(:expected_id) { project_snippet.id.to_s }
|
let(:expected_id) { project_snippet.id }
|
||||||
let(:expected_identifier) { "snippet-#{expected_id}" }
|
let(:expected_identifier) { "snippet-#{expected_id}" }
|
||||||
let(:expected_suffix) { '' }
|
let(:expected_suffix) { '' }
|
||||||
let(:expected_repository) { project_snippet.repository }
|
let(:expected_repository) { project_snippet.repository }
|
||||||
|
@ -131,7 +131,7 @@ describe Gitlab::GlRepository::RepoType do
|
||||||
describe Gitlab::GlRepository::DESIGN do
|
describe Gitlab::GlRepository::DESIGN do
|
||||||
it_behaves_like 'a repo type' do
|
it_behaves_like 'a repo type' do
|
||||||
let(:expected_identifier) { "design-#{project.id}" }
|
let(:expected_identifier) { "design-#{project.id}" }
|
||||||
let(:expected_id) { project.id.to_s }
|
let(:expected_id) { project.id }
|
||||||
let(:expected_suffix) { '.design' }
|
let(:expected_suffix) { '.design' }
|
||||||
let(:expected_repository) { project.design_repository }
|
let(:expected_repository) { project.design_repository }
|
||||||
let(:expected_container) { project }
|
let(:expected_container) { project }
|
||||||
|
|
|
@ -11,7 +11,7 @@ describe ::Gitlab::GlRepository do
|
||||||
expect(described_class.parse("project-#{project.id}")).to eq([project, project, Gitlab::GlRepository::PROJECT])
|
expect(described_class.parse("project-#{project.id}")).to eq([project, project, Gitlab::GlRepository::PROJECT])
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'parses a wiki gl_repository' do
|
it 'parses a project wiki gl_repository' do
|
||||||
expect(described_class.parse("wiki-#{project.id}")).to eq([project, project, Gitlab::GlRepository::WIKI])
|
expect(described_class.parse("wiki-#{project.id}")).to eq([project, project, Gitlab::GlRepository::WIKI])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,177 @@ describe API::Users, :do_not_mock_admin_mode do
|
||||||
let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 }
|
let(:not_existing_pat_id) { (PersonalAccessToken.maximum('id') || 0 ) + 10 }
|
||||||
let(:private_user) { create(:user, private_profile: true) }
|
let(:private_user) { create(:user, private_profile: true) }
|
||||||
|
|
||||||
|
context 'admin notes' do
|
||||||
|
let(:admin) { create(:admin, note: '2019-10-06 | 2FA added | user requested | www.gitlab.com') }
|
||||||
|
let(:user) { create(:user, note: '2018-11-05 | 2FA removed | user requested | www.gitlab.com') }
|
||||||
|
|
||||||
|
describe 'POST /users' do
|
||||||
|
context 'when unauthenticated' do
|
||||||
|
it 'return authentication error' do
|
||||||
|
post api('/users')
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when authenticated' do
|
||||||
|
context 'as an admin' do
|
||||||
|
it 'contains the note of the user' do
|
||||||
|
optional_attributes = { note: 'Awesome Note' }
|
||||||
|
attributes = attributes_for(:user).merge(optional_attributes)
|
||||||
|
|
||||||
|
post api('/users', admin), params: attributes
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:created)
|
||||||
|
expect(json_response['note']).to eq(optional_attributes[:note])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as a regular user' do
|
||||||
|
it 'does not allow creating new user' do
|
||||||
|
post api('/users', user), params: attributes_for(:user)
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /users/:id' do
|
||||||
|
context 'when unauthenticated' do
|
||||||
|
it 'does not contain the note of the user' do
|
||||||
|
get api("/users/#{user.id}")
|
||||||
|
|
||||||
|
expect(json_response).not_to have_key('note')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when authenticated' do
|
||||||
|
context 'as an admin' do
|
||||||
|
it 'contains the note of the user' do
|
||||||
|
get api("/users/#{user.id}", admin)
|
||||||
|
|
||||||
|
expect(json_response).to have_key('note')
|
||||||
|
expect(json_response['note']).to eq(user.note)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as a regular user' do
|
||||||
|
it 'does not contain the note of the user' do
|
||||||
|
get api("/users/#{user.id}", user)
|
||||||
|
|
||||||
|
expect(json_response).not_to have_key('note')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "PUT /users/:id" do
|
||||||
|
context 'when user is an admin' do
|
||||||
|
it "updates note of the user" do
|
||||||
|
new_note = '2019-07-07 | Email changed | user requested | www.gitlab.com'
|
||||||
|
|
||||||
|
expect do
|
||||||
|
put api("/users/#{user.id}", admin), params: { note: new_note }
|
||||||
|
end.to change { user.reload.note }
|
||||||
|
.from('2018-11-05 | 2FA removed | user requested | www.gitlab.com')
|
||||||
|
.to(new_note)
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:success)
|
||||||
|
expect(json_response['note']).to eq(new_note)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when user is not an admin' do
|
||||||
|
it "cannot update their own note" do
|
||||||
|
expect do
|
||||||
|
put api("/users/#{user.id}", user), params: { note: 'new note' }
|
||||||
|
end.not_to change { user.reload.note }
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:forbidden)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /users/' do
|
||||||
|
context 'when unauthenticated' do
|
||||||
|
it "does not contain the note of users" do
|
||||||
|
get api("/users"), params: { username: user.username }
|
||||||
|
|
||||||
|
expect(json_response.first).not_to have_key('note')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when authenticated' do
|
||||||
|
context 'as a regular user' do
|
||||||
|
it 'does not contain the note of users' do
|
||||||
|
get api("/users", user), params: { username: user.username }
|
||||||
|
|
||||||
|
expect(json_response.first).not_to have_key('note')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as an admin' do
|
||||||
|
it 'contains the note of users' do
|
||||||
|
get api("/users", admin), params: { username: user.username }
|
||||||
|
|
||||||
|
expect(response).to have_gitlab_http_status(:success)
|
||||||
|
expect(json_response.first).to have_key('note')
|
||||||
|
expect(json_response.first['note']).to eq '2018-11-05 | 2FA removed | user requested | www.gitlab.com'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'GET /user' do
|
||||||
|
context 'when authenticated' do
|
||||||
|
context 'as an admin' do
|
||||||
|
context 'accesses their own profile' do
|
||||||
|
it 'contains the note of the user' do
|
||||||
|
get api("/user", admin)
|
||||||
|
|
||||||
|
expect(json_response).to have_key('note')
|
||||||
|
expect(json_response['note']).to eq(admin.note)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'sudo' do
|
||||||
|
let(:admin_personal_access_token) { create(:personal_access_token, user: admin, scopes: %w[api sudo]).token }
|
||||||
|
|
||||||
|
context 'accesses the profile of another regular user' do
|
||||||
|
it 'does not contain the note of the user' do
|
||||||
|
get api("/user?private_token=#{admin_personal_access_token}&sudo=#{user.id}")
|
||||||
|
|
||||||
|
expect(json_response['id']).to eq(user.id)
|
||||||
|
expect(json_response).not_to have_key('note')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'accesses the profile of another admin' do
|
||||||
|
let(:admin_2) {create(:admin, note: '2010-10-10 | 2FA added | admin requested | www.gitlab.com')}
|
||||||
|
|
||||||
|
it 'contains the note of the user' do
|
||||||
|
get api("/user?private_token=#{admin_personal_access_token}&sudo=#{admin_2.id}")
|
||||||
|
|
||||||
|
expect(json_response['id']).to eq(admin_2.id)
|
||||||
|
expect(json_response).to have_key('note')
|
||||||
|
expect(json_response['note']).to eq(admin_2.note)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'as a regular user' do
|
||||||
|
it 'does not contain the note of the user' do
|
||||||
|
get api("/user", user)
|
||||||
|
|
||||||
|
expect(json_response).not_to have_key('note')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
shared_examples 'rendering user status' do
|
shared_examples 'rendering user status' do
|
||||||
it 'returns the status if there was one' do
|
it 'returns the status if there was one' do
|
||||||
create(:user_status, user: user)
|
create(:user_status, user: user)
|
||||||
|
|
|
@ -1621,6 +1621,29 @@ describe QuickActions::InterpretService do
|
||||||
expect(message).to eq("Created branch '#{branch_name}' and a merge request to resolve this issue.")
|
expect(message).to eq("Created branch '#{branch_name}' and a merge request to resolve this issue.")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'submit_review command' do
|
||||||
|
using RSpec::Parameterized::TableSyntax
|
||||||
|
|
||||||
|
where(:note) do
|
||||||
|
[
|
||||||
|
'I like it',
|
||||||
|
'/submit_review'
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
with_them do
|
||||||
|
let(:content) { '/submit_review' }
|
||||||
|
let!(:draft_note) { create(:draft_note, note: note, merge_request: merge_request, author: developer) }
|
||||||
|
|
||||||
|
it 'submits the users current review' do
|
||||||
|
_, _, message = service.execute(content, merge_request)
|
||||||
|
|
||||||
|
expect { draft_note.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||||
|
expect(message).to eq('Submitted the current review.')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#explain' do
|
describe '#explain' do
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
RSpec.shared_examples 'parsing gl_repository identifier' do
|
||||||
|
subject { described_class.new(identifier) }
|
||||||
|
|
||||||
|
it 'returns correct information' do
|
||||||
|
aggregate_failures do
|
||||||
|
expect(subject.repo_type).to eq(expected_type)
|
||||||
|
expect(subject.fetch_container!).to eq(expected_container)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -7,26 +7,6 @@ RSpec.shared_examples 'a repo type' do
|
||||||
it { is_expected.to eq(expected_identifier) }
|
it { is_expected.to eq(expected_identifier) }
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#fetch_id' do
|
|
||||||
it 'finds an id match in the identifier' do
|
|
||||||
expect(described_class.fetch_id(expected_identifier)).to eq(expected_id)
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'does not break on other identifiers' do
|
|
||||||
expect(described_class.fetch_id('wiki-noid')).to eq(nil)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#fetch_container!' do
|
|
||||||
it 'returns the container' do
|
|
||||||
expect(described_class.fetch_container!(expected_identifier)).to eq expected_container
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'raises an exception if the identifier is invalid' do
|
|
||||||
expect { described_class.fetch_container!('project-noid') }.to raise_error ArgumentError
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
describe '#path_suffix' do
|
describe '#path_suffix' do
|
||||||
subject { described_class.path_suffix }
|
subject { described_class.path_suffix }
|
||||||
|
|
||||||
|
|
|
@ -49,4 +49,14 @@ describe NewNoteWorker do
|
||||||
described_class.new.perform(unexistent_note_id)
|
described_class.new.perform(unexistent_note_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when note is with review' do
|
||||||
|
it 'does not create a new note notification' do
|
||||||
|
note = create(:note, :with_review)
|
||||||
|
|
||||||
|
expect_any_instance_of(NotificationService).not_to receive(:new_note)
|
||||||
|
|
||||||
|
subject.perform(note.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -355,7 +355,7 @@ describe PostReceive do
|
||||||
|
|
||||||
context "webhook" do
|
context "webhook" do
|
||||||
it "fetches the correct project" do
|
it "fetches the correct project" do
|
||||||
expect(Project).to receive(:find_by).with(id: project.id.to_s)
|
expect(Project).to receive(:find_by).with(id: project.id)
|
||||||
|
|
||||||
perform
|
perform
|
||||||
end
|
end
|
||||||
|
|
Loading…
Reference in New Issue