diff --git a/app/assets/javascripts/diffs/components/diff_line_note_form.vue b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
index 9fd02acbd6e0e626164d6e931a0ff7d06df88951..e7569ba7b84d9894606b403d50a645045f3aaa71 100644
--- a/app/assets/javascripts/diffs/components/diff_line_note_form.vue
+++ b/app/assets/javascripts/diffs/components/diff_line_note_form.vue
@@ -94,6 +94,7 @@ export default {
ref="noteForm"
:is-editing="true"
:line-code="line.line_code"
+ :line="line"
save-button-title="Comment"
class="diff-comment-form"
@cancelForm="handleCancelCommentForm"
diff --git a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
index aa40b24950ae23001d6cc4d1cfeb0269286028a8..814ee0b7c02f35b04c7fc386a31b0b817c5de0a2 100644
--- a/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/inline_diff_comment_row.vue
@@ -16,6 +16,11 @@ export default {
type: String,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
className() {
@@ -38,7 +43,12 @@ export default {
|
-
+
diff --git a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
index b98463d3dd359fcd486ab775c16c31424cd40ecb..a65cf025cdedb8d7cee8bf3352c6b1f20e70ce50 100644
--- a/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
+++ b/app/assets/javascripts/diffs/components/parallel_diff_comment_row.vue
@@ -20,6 +20,11 @@ export default {
type: Number,
required: true,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
hasExpandedDiscussionOnLeft() {
@@ -87,6 +92,8 @@ export default {
diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js
index 06ef4207d85c6e381fdcddc3ca15fecd04e6f182..41e6bd8107286b3f31202586a9287e01fa4a98b7 100644
--- a/app/assets/javascripts/diffs/index.js
+++ b/app/assets/javascripts/diffs/index.js
@@ -16,6 +16,7 @@ export default function initDiffsApp(store) {
return {
endpoint: dataset.endpoint,
projectPath: dataset.projectPath,
+ helpPagePath: dataset.helpPagePath,
currentUser: JSON.parse(dataset.currentUserData) || {},
};
},
@@ -30,6 +31,7 @@ export default function initDiffsApp(store) {
endpoint: this.endpoint,
currentUser: this.currentUser,
projectPath: this.projectPath,
+ helpPagePath: this.helpPagePath,
shouldShow: this.activeTab === 'diffs',
},
});
diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js
index 952963e071186d9787c7bcd76946984b2fc66149..00a4bb6d3a3a80b2ba58f89a248bfcc00ebc0dd7 100644
--- a/app/assets/javascripts/diffs/store/actions.js
+++ b/app/assets/javascripts/diffs/store/actions.js
@@ -3,8 +3,9 @@ import axios from '~/lib/utils/axios_utils';
import Cookies from 'js-cookie';
import createFlash from '~/flash';
import { s__ } from '~/locale';
-import { handleLocationHash, historyPushState } from '~/lib/utils/common_utils';
+import { handleLocationHash, historyPushState, scrollToElement } from '~/lib/utils/common_utils';
import { mergeUrlParams, getLocationHash } from '~/lib/utils/url_utility';
+import eventHub from '../../notes/event_hub';
import { getDiffPositionByLineCode, getNoteFormData } from './utils';
import * as types from './mutation_types';
import {
@@ -53,6 +54,10 @@ export const assignDiscussionsToDiff = (
diffPositionByLineCode,
});
});
+
+ Vue.nextTick(() => {
+ eventHub.$emit('scrollToDiscussion');
+ });
};
export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
@@ -60,6 +65,27 @@ export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => {
commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash: file_hash, lineCode: line_code, id });
};
+export const renderFileForDiscussionId = ({ commit, rootState, state }, discussionId) => {
+ const discussion = rootState.notes.discussions.find(d => d.id === discussionId);
+
+ if (discussion) {
+ const file = state.diffFiles.find(f => f.file_hash === discussion.diff_file.file_hash);
+
+ if (file) {
+ if (!file.renderIt) {
+ commit(types.RENDER_FILE, file);
+ }
+
+ if (file.collapsed) {
+ eventHub.$emit(`loadCollapsedDiff/${file.file_hash}`);
+ scrollToElement(document.getElementById(file.file_hash));
+ } else {
+ eventHub.$emit('scrollToDiscussion');
+ }
+ }
+ }
+};
+
export const startRenderDiffsQueue = ({ state, commit }) => {
const checkItem = () =>
new Promise(resolve => {
diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js
index 331fb052371898965332a1fd13e82f4456eba23a..2ea884d1293b56e97b6023dff3165f203bae1d73 100644
--- a/app/assets/javascripts/diffs/store/mutations.js
+++ b/app/assets/javascripts/diffs/store/mutations.js
@@ -123,22 +123,23 @@ export default {
diffPosition: diffPositionByLineCode[line.line_code],
latestDiff,
});
+ const mapDiscussions = (line, extraCheck = () => true) => ({
+ ...line,
+ discussions: extraCheck()
+ ? line.discussions
+ .filter(() => !line.discussions.some(({ id }) => discussion.id === id))
+ .concat(lineCheck(line) ? discussion : line.discussions)
+ : [],
+ });
state.diffFiles = state.diffFiles.map(diffFile => {
if (diffFile.file_hash === fileHash) {
const file = { ...diffFile };
if (file.highlighted_diff_lines) {
- file.highlighted_diff_lines = file.highlighted_diff_lines.map(line => {
- if (!line.discussions.some(({ id }) => discussion.id === id) && lineCheck(line)) {
- return {
- ...line,
- discussions: line.discussions.concat(discussion),
- };
- }
-
- return line;
- });
+ file.highlighted_diff_lines = file.highlighted_diff_lines.map(line =>
+ mapDiscussions(line),
+ );
}
if (file.parallel_diff_lines) {
@@ -148,20 +149,8 @@ export default {
if (left || right) {
return {
- left: {
- ...line.left,
- discussions:
- left && !line.left.discussions.some(({ id }) => id === discussion.id)
- ? line.left.discussions.concat(discussion)
- : (line.left && line.left.discussions) || [],
- },
- right: {
- ...line.right,
- discussions:
- right && !left && !line.right.discussions.some(({ id }) => id === discussion.id)
- ? line.right.discussions.concat(discussion)
- : (line.right && line.right.discussions) || [],
- },
+ left: line.left ? mapDiscussions(line.left) : null,
+ right: line.right ? mapDiscussions(line.right, () => !left) : null,
};
}
@@ -170,7 +159,7 @@ export default {
}
if (!file.parallel_diff_lines || !file.highlighted_diff_lines) {
- file.discussions = file.discussions.concat(discussion);
+ file.discussions = (file.discussions || []).concat(discussion);
}
return file;
@@ -180,7 +169,7 @@ export default {
});
},
- [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode, id }) {
+ [types.REMOVE_LINE_DISCUSSIONS_FOR_FILE](state, { fileHash, lineCode }) {
const selectedFile = state.diffFiles.find(f => f.file_hash === fileHash);
if (selectedFile) {
if (selectedFile.parallel_diff_lines) {
@@ -193,7 +182,7 @@ export default {
const side = targetLine.left && targetLine.left.line_code === lineCode ? 'left' : 'right';
Object.assign(targetLine[side], {
- discussions: [],
+ discussions: targetLine[side].discussions.filter(discussion => discussion.notes.length),
});
}
}
@@ -205,14 +194,14 @@ export default {
if (targetInlineLine) {
Object.assign(targetInlineLine, {
- discussions: [],
+ discussions: targetInlineLine.discussions.filter(discussion => discussion.notes.length),
});
}
}
if (selectedFile.discussions && selectedFile.discussions.length) {
selectedFile.discussions = selectedFile.discussions.filter(
- discussion => discussion.id !== id,
+ discussion => discussion.notes.length,
);
}
}
diff --git a/app/assets/javascripts/image_diff/helpers/badge_helper.js b/app/assets/javascripts/image_diff/helpers/badge_helper.js
index eddaeda9578c81d8c02318620767305aee505cd9..000157efad0d0cae87df25cf33436be14cade440 100644
--- a/app/assets/javascripts/image_diff/helpers/badge_helper.js
+++ b/app/assets/javascripts/image_diff/helpers/badge_helper.js
@@ -12,7 +12,7 @@ export function createImageBadge(noteId, { x, y }, classNames = []) {
}
export function addImageBadge(containerEl, { coordinate, badgeText, noteId }) {
- const buttonEl = createImageBadge(noteId, coordinate, ['badge']);
+ const buttonEl = createImageBadge(noteId, coordinate, ['badge', 'badge-pill']);
buttonEl.innerText = badgeText;
containerEl.appendChild(buttonEl);
diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js
index 040d0bc659e6acea42b6a6fa63eec68b00b5d58e..9e22cdc04e9b128c1f33515bbda0ba89f1529cfa 100644
--- a/app/assets/javascripts/lib/utils/common_utils.js
+++ b/app/assets/javascripts/lib/utils/common_utils.js
@@ -192,8 +192,12 @@ export const contentTop = () => {
const mrTabsHeight = $('.merge-request-tabs').height() || 0;
const headerHeight = $('.navbar-gitlab').height() || 0;
const diffFilesChanged = $('.js-diff-files-changed').height() || 0;
+ const diffFileLargeEnoughScreen =
+ 'matchMedia' in window ? window.matchMedia('min-width: 768') : true;
+ const diffFileTitleBar =
+ (diffFileLargeEnoughScreen && $('.diff-file .file-title-flex-parent:visible').height()) || 0;
- return perfBar + mrTabsHeight + headerHeight + diffFilesChanged;
+ return perfBar + mrTabsHeight + headerHeight + diffFilesChanged + diffFileTitleBar;
};
export const scrollToElement = element => {
diff --git a/app/assets/javascripts/lib/utils/text_markdown.js b/app/assets/javascripts/lib/utils/text_markdown.js
index 3618c6af7e2b86e5c3db7269ecaf7547629eb1b1..c095a017866c9d67c4c53cfdb4ee20bafbc7e6ea 100644
--- a/app/assets/javascripts/lib/utils/text_markdown.js
+++ b/app/assets/javascripts/lib/utils/text_markdown.js
@@ -39,7 +39,14 @@ function blockTagText(text, textArea, blockTag, selected) {
}
}
-function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, select }) {
+function moveCursor({
+ textArea,
+ tag,
+ cursorOffset,
+ positionBetweenTags,
+ removedLastNewLine,
+ select,
+}) {
var pos;
if (!textArea.setSelectionRange) {
return;
@@ -61,11 +68,24 @@ function moveCursor({ textArea, tag, positionBetweenTags, removedLastNewLine, se
pos -= 1;
}
+ if (cursorOffset) {
+ pos -= cursorOffset;
+ }
+
return textArea.setSelectionRange(pos, pos);
}
}
-export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select }) {
+export function insertMarkdownText({
+ textArea,
+ text,
+ tag,
+ cursorOffset,
+ blockTag,
+ selected,
+ wrap,
+ select,
+}) {
var textToInsert,
selectedSplit,
startChar,
@@ -154,20 +174,30 @@ export function insertMarkdownText({ textArea, text, tag, blockTag, selected, wr
return moveCursor({
textArea,
tag: tag.replace(textPlaceholder, selected),
+ cursorOffset,
positionBetweenTags: wrap && selected.length === 0,
removedLastNewLine,
select,
});
}
-function updateText({ textArea, tag, blockTag, wrap, select }) {
+function updateText({ textArea, tag, cursorOffset, blockTag, wrap, select, tagContent }) {
var $textArea, selected, text;
$textArea = $(textArea);
textArea = $textArea.get(0);
text = $textArea.val();
- selected = selectedText(text, textArea);
+ selected = selectedText(text, textArea) || tagContent;
$textArea.focus();
- return insertMarkdownText({ textArea, text, tag, blockTag, selected, wrap, select });
+ return insertMarkdownText({
+ textArea,
+ text,
+ tag,
+ cursorOffset,
+ blockTag,
+ selected,
+ wrap,
+ select,
+ });
}
export function addMarkdownListeners(form) {
@@ -178,9 +208,11 @@ export function addMarkdownListeners(form) {
return updateText({
textArea: $this.closest('.md-area').find('textarea'),
tag: $this.data('mdTag'),
+ cursorOffset: $this.data('mdCursorOffset'),
blockTag: $this.data('mdBlock'),
wrap: !$this.data('mdPrepend'),
select: $this.data('mdSelect'),
+ tagContent: $this.data('mdTagContent').toString(),
});
});
}
diff --git a/app/assets/javascripts/lib/utils/users_cache.js b/app/assets/javascripts/lib/utils/users_cache.js
index c0d45e017b4683e7917d099c07eab95d37367984..9f980fd48998cd54610132562c1d8353d5941462 100644
--- a/app/assets/javascripts/lib/utils/users_cache.js
+++ b/app/assets/javascripts/lib/utils/users_cache.js
@@ -22,6 +22,34 @@ class UsersCache extends Cache {
});
// missing catch is intentional, error handling depends on use case
}
+
+ retrieveById(userId) {
+ if (this.hasData(userId) && this.get(userId).username) {
+ return Promise.resolve(this.get(userId));
+ }
+
+ return Api.user(userId).then(({ data }) => {
+ this.internalStorage[userId] = data;
+ return data;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
+
+ retrieveStatusById(userId) {
+ if (this.hasData(userId) && this.get(userId).status) {
+ return Promise.resolve(this.get(userId).status);
+ }
+
+ return Api.userStatus(userId).then(({ data }) => {
+ if (!this.hasData(userId)) {
+ this.internalStorage[userId] = {};
+ }
+ this.internalStorage[userId].status = data;
+
+ return data;
+ });
+ // missing catch is intentional, error handling depends on use case
+ }
}
export default new UsersCache();
diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js
index a88b575ad99f9015c4f62d541f3b223874ceb3c2..c866e8d180ac1e34da1a2796b3e9c31cb2f2799b 100644
--- a/app/assets/javascripts/main.js
+++ b/app/assets/javascripts/main.js
@@ -30,6 +30,7 @@ import initUsagePingConsent from './usage_ping_consent';
import initPerformanceBar from './performance_bar';
import initSearchAutocomplete from './search_autocomplete';
import GlFieldErrors from './gl_field_errors';
+import initUserPopovers from './user_popovers';
// expose jQuery as global (TODO: remove these)
window.jQuery = jQuery;
@@ -78,6 +79,7 @@ document.addEventListener('DOMContentLoaded', () => {
initTodoToggle();
initLogoAnimation();
initUsagePingConsent();
+ initUserPopovers();
if (document.querySelector('.search')) initSearchAutocomplete();
if (document.querySelector('#js-peek')) initPerformanceBar({ container: '#js-peek' });
diff --git a/app/assets/javascripts/monitoring/components/charts/area.vue b/app/assets/javascripts/monitoring/components/charts/area.vue
new file mode 100644
index 0000000000000000000000000000000000000000..12224e36ba2e5646ca2113a76ad92e6ea9bd89b9
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/area.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 218c508a608f0f2ddb61165f283a4f8f0b79357c..2d9c5050c9b4cc27ba07dfdc8ce20bb4699cf46f 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -4,6 +4,7 @@ import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import Flash from '../../flash';
import MonitoringService from '../services/monitoring_service';
+import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
@@ -12,6 +13,7 @@ import eventHub from '../event_hub';
export default {
components: {
+ MonitorAreaChart,
Graph,
GraphGroup,
EmptyState,
@@ -102,6 +104,9 @@ export default {
};
},
computed: {
+ graphComponent() {
+ return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
+ },
forceRedraw() {
return this.elWidth;
},
@@ -207,7 +212,8 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
-
{{ null }}
-
+
+import { mapActions } from 'vuex';
import $ from 'jquery';
import noteEditedText from './note_edited_text.vue';
import noteAwardsList from './note_awards_list.vue';
import noteAttachment from './note_attachment.vue';
import noteForm from './note_form.vue';
import autosave from '../mixins/autosave';
+import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
export default {
components: {
@@ -12,6 +14,7 @@ export default {
noteAwardsList,
noteAttachment,
noteForm,
+ Suggestions,
},
mixins: [autosave],
props: {
@@ -19,6 +22,11 @@ export default {
type: Object,
required: true,
},
+ line: {
+ type: Object,
+ required: false,
+ default: null,
+ },
canEdit: {
type: Boolean,
required: true,
@@ -28,11 +36,22 @@ export default {
required: false,
default: false,
},
+ helpPagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
computed: {
noteBody() {
return this.note.note;
},
+ hasSuggestion() {
+ return this.note.suggestions && this.note.suggestions.length;
+ },
+ lineType() {
+ return this.line ? this.line.type : null;
+ },
},
mounted() {
this.renderGFM();
@@ -53,6 +72,7 @@ export default {
}
},
methods: {
+ ...mapActions(['submitSuggestion']),
renderGFM() {
$(this.$refs['note-body']).renderGFM();
},
@@ -62,19 +82,35 @@ export default {
formCancelHandler(shouldConfirm, isDirty) {
this.$emit('cancelForm', shouldConfirm, isDirty);
},
+ applySuggestion({ suggestionId, flashContainer, callback }) {
+ const { discussion_id: discussionId, id: noteId } = this.note;
+
+ this.submitSuggestion({ discussionId, noteId, suggestionId, flashContainer, callback });
+ },
},
};
-
+
+
{{ actionText }}
- by {{ editedBy.name }}
+ by
+
+ {{ editedBy.name }}
+
{{ actionDetailText }}
diff --git a/app/assets/javascripts/notes/components/note_form.vue b/app/assets/javascripts/notes/components/note_form.vue
index 95164183ccb6618268f80b42109f99b51dc11aa2..9b7f3d3588d63f6c35ce9bababf7271f9f607d68 100644
--- a/app/assets/javascripts/notes/components/note_form.vue
+++ b/app/assets/javascripts/notes/components/note_form.vue
@@ -1,4 +1,5 @@
-
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7e79e63aa1ec46e47951ccb955d7c1313c6dddcc
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_assignees.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+ {{ __('Assignee') }} {{ assignee.name }}
+ @{{ assignee.username }}
+
+
+ {{ assigneeCounterLabel }}
+
+
diff --git a/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
new file mode 100644
index 0000000000000000000000000000000000000000..d5d967e25bf4b8e91ffc5eb646abba84393084f9
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/issue/issue_milestone.vue
@@ -0,0 +1,90 @@
+
+
+
+
+ {{ milestone.title }}
+
+ {{ __('Milestone') }}
+ {{ milestone.title }}
+ {{ milestoneDatesHuman }} {{ milestoneDatesAbsolute }}
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue
index 43def2673eb274425527b1131acc48d3ff027e80..2f7ed4a982c849abc931654c45cfa076fc3f64d1 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/field.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue
@@ -1,17 +1,21 @@
+
+
+
+
+
+
+
+
+ | {{ fromLine }} |
+ |
+
+ {{ fromContent }}
+ |
+
+
+
+ |
+ {{ line.lineNumber }} |
+
+ {{ line.content }}
+ |
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
new file mode 100644
index 0000000000000000000000000000000000000000..563e2f94fcc78defb3b3ecfa0d709520797489d2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestion_diff_header.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
new file mode 100644
index 0000000000000000000000000000000000000000..7c6dbee3e19fa3b64d0165d2b34d26f464af6ae8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/markdown/suggestions.vue
@@ -0,0 +1,136 @@
+
+
+
+
+
diff --git a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
index a6d2cecdf7e7d0ddd15be266de0721e377c03191..4572caa907b58a54d6847d24c82861ec26106dfa 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/toolbar_button.vue
@@ -37,6 +37,16 @@ export default {
required: false,
default: false,
},
+ tagContent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ cursorOffset: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
},
};
@@ -45,8 +55,10 @@ export default {
+import { GlPopover, GlSkeletonLoading } from '@gitlab/ui';
+import { __, sprintf } from '~/locale';
+import UserAvatarImage from '../user_avatar/user_avatar_image.vue';
+import { glEmojiTag } from '../../../emoji';
+
+export default {
+ name: 'UserPopover',
+ components: {
+ GlPopover,
+ GlSkeletonLoading,
+ UserAvatarImage,
+ },
+ props: {
+ target: {
+ type: HTMLAnchorElement,
+ required: true,
+ },
+ user: {
+ type: Object,
+ required: true,
+ default: null,
+ },
+ loaded: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ jobLine() {
+ if (this.user.bio && this.user.organization) {
+ return sprintf(__('%{bio} at %{organization}'), {
+ bio: this.user.bio,
+ organization: this.user.organization,
+ });
+ } else if (this.user.bio) {
+ return this.user.bio;
+ } else if (this.user.organization) {
+ return this.user.organization;
+ }
+ return null;
+ },
+ statusHtml() {
+ if (this.user.status.emoji && this.user.status.message) {
+ return `${glEmojiTag(this.user.status.emoji)} ${this.user.status.message}`;
+ } else if (this.user.status.message) {
+ return this.user.status.message;
+ }
+ return '';
+ },
+ nameIsLoading() {
+ return !this.user.name;
+ },
+ jobInfoIsLoading() {
+ return !this.user.loaded && this.user.organization === null;
+ },
+ locationIsLoading() {
+ return !this.user.loaded && this.user.location === null;
+ },
+ },
+};
+
+
+
+
+
+
+
+
+
+
+ {{ user.name }}
+
+
+
+ @{{ user.username }}
+
+
+
+ {{ jobLine }}
+
+
+
+ {{ user.location }}
+
+
+
+
+
+
+
diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss
index bd1cca69c03b833c4cdab4ebdeb4906175d751c8..985fac11c87f7d20b562097de2e9c94d6f9e710a 100644
--- a/app/assets/stylesheets/application.scss
+++ b/app/assets/stylesheets/application.scss
@@ -34,6 +34,11 @@
*/
@import "pages/**/*";
+/*
+ * Component specific styles, will be moved to gitlab-ui
+ */
+@import "components/**/*";
+
/*
* Code highlight
*/
diff --git a/app/assets/stylesheets/bootstrap_migration.scss b/app/assets/stylesheets/bootstrap_migration.scss
index 62024b8c555805bcbec5faf613d250e1c04c3982..f0671e361308465db24c0d2c3bd4923056979708 100644
--- a/app/assets/stylesheets/bootstrap_migration.scss
+++ b/app/assets/stylesheets/bootstrap_migration.scss
@@ -18,8 +18,10 @@ $input-border: $border-color;
$padding-base-vertical: $gl-vert-padding;
$padding-base-horizontal: $gl-padding;
-html {
- // Override default font size used in bs4
+body,
+.form-control,
+.search form {
+ // Override default font size used in non-csslab UI
font-size: 14px;
}
diff --git a/app/assets/stylesheets/components/popover.scss b/app/assets/stylesheets/components/popover.scss
new file mode 100644
index 0000000000000000000000000000000000000000..2f4d30fe9234c52f13b062e7cba07be499020d67
--- /dev/null
+++ b/app/assets/stylesheets/components/popover.scss
@@ -0,0 +1,9 @@
+.popover {
+ min-width: 300px;
+
+ .popover-body .user-popover {
+ padding: $gl-padding-8;
+ font-size: $gl-font-size-small;
+ line-height: $gl-line-height;
+ }
+}
diff --git a/app/assets/stylesheets/csslab.scss b/app/assets/stylesheets/csslab.scss
new file mode 100644
index 0000000000000000000000000000000000000000..acaa41e2677a50a43b3e68a5f3b2b8d76cbe91e8
--- /dev/null
+++ b/app/assets/stylesheets/csslab.scss
@@ -0,0 +1 @@
+@import "../../../node_modules/@gitlab/csslab/dist/css/csslab-slim";
diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss
index e36f99ac577997eabdb059557d5b457f9f4e3e61..a4a9276c5806bc6c99c217d7d3a4519dbd3244ef 100644
--- a/app/assets/stylesheets/framework/buttons.scss
+++ b/app/assets/stylesheets/framework/buttons.scss
@@ -148,8 +148,8 @@
&.btn-xs {
padding: 2px $gl-btn-padding;
- font-size: $gl-btn-small-font-size;
- line-height: $gl-btn-small-line-height;
+ font-size: $gl-btn-xs-font-size;
+ line-height: $gl-btn-xs-line-height;
}
&.btn-success,
diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss
index f3c44f32d6f2eae574eaabb6970caddf9f681aeb..f273eb9533d3d6d3f424b796d0924620561f60cf 100644
--- a/app/assets/stylesheets/framework/dropdowns.scss
+++ b/app/assets/stylesheets/framework/dropdowns.scss
@@ -176,9 +176,9 @@
display: block;
font-weight: $gl-font-weight-normal;
position: relative;
- padding: 8px 16px;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
color: $gl-text-color;
- line-height: normal;
+ line-height: $gl-btn-line-height;
white-space: normal;
overflow: hidden;
text-align: left;
@@ -319,8 +319,8 @@
.dropdown-header {
color: $gl-text-color-secondary;
font-size: 13px;
- line-height: 22px;
- padding: 8px 16px;
+ line-height: $gl-line-height;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
&.capitalize-header .dropdown-header {
@@ -329,13 +329,8 @@
.dropdown-bold-header {
font-weight: $gl-font-weight-bold;
- line-height: 22px;
- padding: 0 16px;
- }
-
- .separator + .dropdown-header,
- .separator + .dropdown-bold-header {
- padding-top: 10px;
+ line-height: $gl-line-height;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
}
.unclickable {
diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss
index 037a5adfb7eca65e43f8fce9bc37d0711f06dc6c..3ac7b6b704b6d473bd37afe8f0246ebc6a42f879 100644
--- a/app/assets/stylesheets/framework/files.scss
+++ b/app/assets/stylesheets/framework/files.scss
@@ -24,7 +24,7 @@
}
}
- table {
+ &:not(.use-csslab) table {
@extend .table;
}
diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss
index c0cda29e239f05f615c7fd67401424f8d0bdad1b..45a52d993027be4e589a9e2e8b07df3760b8b978 100644
--- a/app/assets/stylesheets/framework/header.scss
+++ b/app/assets/stylesheets/framework/header.scss
@@ -90,12 +90,6 @@
padding: 2px 8px;
margin: 5px 2px 5px -8px;
border-radius: $border-radius-default;
-
- .tanuki-logo {
- @include media-breakpoint-up(sm) {
- margin-right: 8px;
- }
- }
}
.project-item-select {
@@ -127,12 +121,6 @@
}
}
- li.dropdown-bold-header {
- color: $gl-text-color-secondary;
- font-size: 12px;
- padding: 0 16px;
- }
-
.navbar-collapse {
flex: 0 0 auto;
border-top: 0;
@@ -541,7 +529,7 @@
left: auto;
li.current-user {
- padding: 5px 18px;
+ padding: $dropdown-item-padding-y $dropdown-item-padding-x;
.user-name {
display: block;
diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss
index 0f6fb16774c2b69621cad7cf4a31538d1461d264..5609a2086e643efbf360505a133274c019d29a7e 100644
--- a/app/assets/stylesheets/framework/markdown_area.scss
+++ b/app/assets/stylesheets/framework/markdown_area.scss
@@ -131,7 +131,7 @@
width: 100%;
}
-.md {
+.md:not(.use-csslab) {
&.md-preview-holder {
// Reset ul style types since we're nested inside a ul already
@include bulleted-list;
@@ -277,6 +277,27 @@
}
}
+.md-suggestion-diff {
+ display: table !important;
+ border: 1px solid $border-color !important;
+}
+
+.md-suggestion-header {
+ height: $suggestion-header-height;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background-color: $gray-light;
+ border: 1px solid $border-color;
+ padding: $gl-padding;
+ border-radius: $border-radius-default $border-radius-default 0 0;
+
+ svg {
+ vertical-align: middle;
+ margin-bottom: 3px;
+ }
+}
+
@include media-breakpoint-down(xs) {
.atwho-view-ul {
width: 350px;
diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss
index b3b99df5790258528855bd9cb2b282576874b610..0c81dc2e15613b3bc9d50ba347e5570757d54c20 100644
--- a/app/assets/stylesheets/framework/typography.scss
+++ b/app/assets/stylesheets/framework/typography.scss
@@ -368,11 +368,11 @@ code {
* Apply Markdown typography
*
*/
-.wiki {
+.wiki:not(.use-csslab) {
@include md-typography;
}
-.md {
+.md:not(.use-csslab) {
@include md-typography;
}
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 134b3a4521b8d45589700b35b4a2e6ffad8645fe..4449193c1048a019365b2a3a529d45ded70b3a80 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -172,6 +172,7 @@ $theme-light-red-700: #a62e21;
$black: #000;
$black-transparent: rgba(0, 0, 0, 0.3);
+$shadow-color: rgba($black, 0.1);
$almost-black: #242424;
$border-white-light: darken($white-light, $darken-border-factor);
@@ -250,6 +251,7 @@ $browserScrollbarSize: 10px;
* Misc
*/
$header-height: 40px;
+$suggestion-header-height: 46px;
$ide-statusbar-height: 25px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
@@ -368,7 +370,9 @@ $gl-btn-line-height: 16px;
$gl-btn-vert-padding: 8px;
$gl-btn-horz-padding: 12px;
$gl-btn-small-font-size: 13px;
-$gl-btn-small-line-height: 13px;
+$gl-btn-small-line-height: 18px;
+$gl-btn-xs-font-size: 13px;
+$gl-btn-xs-line-height: 13px;
/*
* Badges
@@ -399,7 +403,7 @@ $award-emoji-positive-add-lines: #bb9c13;
* Search Box
*/
$search-input-border-color: rgba($blue-400, 0.8);
-$search-input-width: 240px;
+$search-input-width: 200px;
$search-input-active-width: 320px;
$location-icon-color: #e7e9ed;
diff --git a/app/assets/stylesheets/framework/variables_overrides.scss b/app/assets/stylesheets/framework/variables_overrides.scss
index fab1b361f14dfaf79e6663a1f0a8047a03e8e177..5ca76bb6c5a00c4baad611acea4760eeae0f18d3 100644
--- a/app/assets/stylesheets/framework/variables_overrides.scss
+++ b/app/assets/stylesheets/framework/variables_overrides.scss
@@ -21,3 +21,10 @@ $danger: $red-500;
$zindex-modal-backdrop: 1040;
$nav-divider-margin-y: ($grid-size / 2);
$dropdown-divider-bg: $theme-gray-200;
+$dropdown-item-padding-y: 8px;
+$dropdown-item-padding-x: 12px;
+$popover-max-width: 300px;
+$popover-border-width: 1px;
+$popover-border-color: $border-color;
+$popover-box-shadow: 0 $border-radius-small $border-radius-default 0 $shadow-color;
+$popover-arrow-outer-color: $shadow-color;
diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss
index 5405f20a760197e6a60f4789e21002e6a8431ef1..18c62cb4f1e686cb25977176ee1b6f38b5d71c1f 100644
--- a/app/assets/stylesheets/pages/diff.scss
+++ b/app/assets/stylesheets/pages/diff.scss
@@ -914,6 +914,7 @@
padding: 0;
width: (2px * $image-comment-cursor-left-offset);
height: (2px * $image-comment-cursor-top-offset);
+ color: $blue-400;
// center the indicator to match the top left click region
margin-top: (-1px * $image-comment-cursor-top-offset) + 2;
margin-left: (-1px * $image-comment-cursor-left-offset) + 1;
diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss
index 132f3fea92bdfa09a8d6cad3b4c7483d2fff3a33..a4831b643444d83ba5e22d15dd28fc39325925c4 100644
--- a/app/assets/stylesheets/pages/profile.scss
+++ b/app/assets/stylesheets/pages/profile.scss
@@ -98,7 +98,6 @@
// Limits the width of the user bio for readability.
max-width: 600px;
margin: 10px auto;
- padding: 0 16px;
}
.user-avatar-button {
@@ -222,7 +221,11 @@
}
.profile-header {
- margin: 0 auto;
+ margin: 0 $gl-padding;
+
+ &.with-no-profile-tabs {
+ margin-bottom: $gl-padding-24;
+ }
.avatar-holder {
width: 90px;
diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss
index 04151b1cd59cd2e812051c2f9ea9a6ddedec0d79..149c3254d8485c4b4c673d539a4132499a86f95f 100644
--- a/app/assets/stylesheets/pages/search.scss
+++ b/app/assets/stylesheets/pages/search.scss
@@ -101,8 +101,6 @@ input[type='checkbox']:hover {
.dropdown-header {
// Necessary because glDropdown doesn't support a second style of headers
font-weight: $gl-font-weight-bold;
- // .dropdown-menu li has 1px side padding
- padding: $gl-padding-8 17px;
color: $gl-text-color;
font-size: $gl-font-size;
line-height: 16px;
diff --git a/app/assets/stylesheets/pages/wiki.scss b/app/assets/stylesheets/pages/wiki.scss
index 800f5c68e397fb5877068f8d63590f510e3e2251..82e887aa62aabf749f46ed4723c10c7d0300ffb1 100644
--- a/app/assets/stylesheets/pages/wiki.scss
+++ b/app/assets/stylesheets/pages/wiki.scss
@@ -180,7 +180,7 @@ ul.wiki-pages-list.content-list {
}
}
-.wiki {
+.wiki:not(.use-csslab) {
table {
@include markdown-table;
}
diff --git a/app/controllers/concerns/issuable_collections.rb b/app/controllers/concerns/issuable_collections.rb
index a597996a3623eb91395f47ef420c2d6f04eb0a08..789e0dc736e2960c24b51bd26ef5e9a421648137 100644
--- a/app/controllers/concerns/issuable_collections.rb
+++ b/app/controllers/concerns/issuable_collections.rb
@@ -126,6 +126,8 @@ module IssuableCollections
sort_param = params[:sort]
sort_param ||= user_preference[issuable_sorting_field]
+ return sort_param if Gitlab::Database.read_only?
+
if user_preference[issuable_sorting_field] != sort_param
user_preference.update_attribute(issuable_sorting_field, sort_param)
end
diff --git a/app/controllers/concerns/preview_markdown.rb b/app/controllers/concerns/preview_markdown.rb
index c61b9fabe9ee2935452bf0d07e7602672b162aa3..4b0f0b8255c50dd34d1e0665977e90c6071926af 100644
--- a/app/controllers/concerns/preview_markdown.rb
+++ b/app/controllers/concerns/preview_markdown.rb
@@ -12,7 +12,7 @@ module PreviewMarkdown
when 'wikis' then { pipeline: :wiki, project_wiki: @project_wiki, page_slug: params[:id] }
when 'snippets' then { skip_project_check: true }
when 'groups' then { group: group }
- when 'projects' then { issuable_state_filter_enabled: true }
+ when 'projects' then projects_filter_params
else {}
end
@@ -22,9 +22,17 @@ module PreviewMarkdown
body: view_context.markdown(result[:text], markdown_params),
references: {
users: result[:users],
+ suggestions: result[:suggestions],
commands: view_context.markdown(result[:commands])
}
}
end
+
+ def projects_filter_params
+ {
+ issuable_state_filter_enabled: true,
+ suggestions_filter_enabled: params[:preview_suggestions].present?
+ }
+ end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
diff --git a/app/controllers/concerns/renders_commits.rb b/app/controllers/concerns/renders_commits.rb
index f48e0586211d46e1edbc89c0951685ba55d9ee8b..ed9b898a2a3fa94fc772fc79b1ba686817066fb0 100644
--- a/app/controllers/concerns/renders_commits.rb
+++ b/app/controllers/concerns/renders_commits.rb
@@ -26,4 +26,10 @@ module RendersCommits
commits
end
+
+ def valid_ref?(ref_name)
+ return true unless ref_name.present?
+
+ Gitlab::GitRefValidator.validate(ref_name)
+ end
end
diff --git a/app/controllers/projects/commits_controller.rb b/app/controllers/projects/commits_controller.rb
index e40a1a1d744ac2e0ee7b926ed597324d00c63384..2510a31c9b3f6f0f59d9fe7f1048f3a15080963b 100644
--- a/app/controllers/projects/commits_controller.rb
+++ b/app/controllers/projects/commits_controller.rb
@@ -11,6 +11,7 @@ class Projects::CommitsController < Projects::ApplicationController
before_action :require_non_empty_project
before_action :assign_ref_vars, except: :commits_root
before_action :authorize_download_code!
+ before_action :validate_ref!, except: :commits_root
before_action :set_commits, except: :commits_root
def commits_root
@@ -54,6 +55,10 @@ class Projects::CommitsController < Projects::ApplicationController
private
+ def validate_ref!
+ render_404 unless valid_ref?(@ref)
+ end
+
def set_commits
render_404 unless @path.empty? || request.format == :atom || @repository.blob_at(@commit.id, @path) || @repository.tree(@commit.id, @path).entries.present?
@limit, @offset = (params[:limit] || 40).to_i, (params[:offset] || 0).to_i
diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb
index 2917925947f35f7a0af01541f7803a3c2247519d..5586c2fc63162c5d446e4e7a5b0d1244fcfdbff9 100644
--- a/app/controllers/projects/compare_controller.rb
+++ b/app/controllers/projects/compare_controller.rb
@@ -65,12 +65,6 @@ class Projects::CompareController < Projects::ApplicationController
private
- def valid_ref?(ref_name)
- return true unless ref_name.present?
-
- Gitlab::GitRefValidator.validate(ref_name)
- end
-
def validate_refs!
valid = [head_ref, start_ref].map { |ref| valid_ref?(ref) }
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index e940f382a19a2923e1a8ec7dfbd83fa4e810a9f2..a63eea0ca0ed23309e8b63028fb01493cec4af00 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -11,6 +11,10 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :verify_api_request!, only: :terminal_websocket_authorize
before_action :expire_etag_cache, only: [:index]
+ before_action do
+ push_frontend_feature_flag(:area_chart, project)
+ end
+
def index
@environments = project.environments
.with_state(params[:scope] || :available)
diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb
index 4b6c5b215e8f43a09c86b79a5eb4dbe7287b78ba..8d8c62f129180bcc7321de2e20ea212199ebcbc0 100644
--- a/app/helpers/dropdowns_helper.rb
+++ b/app/helpers/dropdowns_helper.rb
@@ -11,6 +11,10 @@ module DropdownsHelper
dropdown_output = dropdown_toggle(toggle_text, data_attr, options)
+ if options.key?(:toggle_link)
+ dropdown_output = dropdown_toggle_link(toggle_text, data_attr, options)
+ end
+
dropdown_output << content_tag(:div, class: "dropdown-menu dropdown-select #{options[:dropdown_class] if options.key?(:dropdown_class)}") do
output = []
@@ -49,6 +53,11 @@ module DropdownsHelper
end
end
+ def dropdown_toggle_link(toggle_text, data_attr, options = {})
+ output = content_tag(:a, toggle_text, class: "dropdown-toggle-text #{options[:toggle_class] if options.key?(:toggle_class)}", id: (options[:id] if options.key?(:id)), data: data_attr)
+ output.html_safe
+ end
+
def dropdown_title(title, options: {})
content_tag :div, class: "dropdown-title" do
title_output = []
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index dfa86f52e4067403a6c46fdfb0758a8c64143d83..da991458ea763b5451c5ff4092239f5dfedef9c1 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -179,7 +179,7 @@ module IssuablesHelper
output << "Opened #{time_ago_with_tooltip(issuable.created_at)} by ".html_safe
output << content_tag(:strong) do
- author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline", tooltip: true)
+ author_output = link_to_member(project, issuable.author, size: 24, mobile_classes: "d-none d-sm-inline")
author_output << link_to_member(project, issuable.author, size: 24, by_username: true, avatar: false, mobile_classes: "d-block d-sm-none")
if status = user_status(issuable.author)
diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb
index a7fe8c3d59c43e1a6cddc88ce2304acb8b040405..05da5ebdb226d2b45d9e62a0232812a32fa9097e 100644
--- a/app/helpers/nav_helper.rb
+++ b/app/helpers/nav_helper.rb
@@ -47,8 +47,8 @@ module NavHelper
class_names
end
- def show_separator?
- Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics)
+ def has_extra_nav_icons?
+ Gitlab::Sherlock.enabled? || can?(current_user, :read_instance_statistics) || current_user.admin?
end
def page_has_markdown?
diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb
index 7ce6b04df7eae47e260f32fceff1cac38aa34455..87aebe415c8bb62859e47b53e3dc41e57a91f0ce 100644
--- a/app/helpers/projects_helper.rb
+++ b/app/helpers/projects_helper.rb
@@ -50,6 +50,12 @@ module ProjectsHelper
default_opts = { avatar: true, name: true, title: ":name" }
opts = default_opts.merge(opts)
+ data_attrs = {
+ user_id: author.id,
+ username: author.username,
+ name: author.name
+ }
+
return "(deleted)" unless author
author_html = []
@@ -65,7 +71,7 @@ module ProjectsHelper
author_html = author_html.join.html_safe
if opts[:name]
- link_to(author_html, user_path(author), class: "author-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe
+ link_to(author_html, user_path(author), class: "author-link js-user-link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}", data: data_attrs).html_safe
else
title = opts[:title].sub(":name", sanitize(author.name))
link_to(author_html, user_path(author), class: "author-link has-tooltip", title: title, data: { container: 'body' }).html_safe
@@ -385,6 +391,10 @@ module ProjectsHelper
end
end
+ def sidebar_operations_link_path(project = @project)
+ metrics_project_environments_path(project) if can?(current_user, :read_environment, project)
+ end
+
def project_last_activity(project)
if project.last_activity_at
time_ago_with_tooltip(project.last_activity_at, placement: 'bottom', html_class: 'last_activity_time_ago')
diff --git a/app/helpers/selects_helper.rb b/app/helpers/selects_helper.rb
index cf60696ef3954cbde4b922f1dc1c892bfea9466f..2f802e4eab879fd87a644797a970d350df793aa6 100644
--- a/app/helpers/selects_helper.rb
+++ b/app/helpers/selects_helper.rb
@@ -29,6 +29,11 @@ module SelectsHelper
classes = Array.wrap(opts[:class])
classes << 'ajax-groups-select'
+ # EE requires this line to be present, but there is no easy way of injecting
+ # this into EE without causing merge conflicts. Given this line is very
+ # simple and not really EE specific on its own, we just include it in CE.
+ classes << 'multiselect' if opts[:multiple]
+
opts[:class] = classes.join(' ')
select2_tag(id, opts)
diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb
index f51b96ba8ce402cb0735791eaada1582dff3f27a..67c808b167a1860629518bdfa7d399394d3679ac 100644
--- a/app/helpers/sorting_helper.rb
+++ b/app/helpers/sorting_helper.rb
@@ -164,7 +164,7 @@ module SortingHelper
reverse_sort = issuable_reverse_sort_order_hash[sort_value]
if reverse_sort
- reverse_url = page_filter_path(sort: reverse_sort)
+ reverse_url = page_filter_path(sort: reverse_sort, label: true)
else
reverse_url = '#'
link_class += ' disabled'
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index eb315058c3ac43034726477d640bf475e2c67576..f2cad09e779ee43c280add9f83e9ed077bad8cfb 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -26,6 +26,10 @@ module Noteable
DiscussionNote.noteable_types.include?(base_class_name)
end
+ def supports_suggestion?
+ false
+ end
+
def discussions_rendered_on_frontend?
false
end
diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb
index af699eeebcec22ac76cb8340200b21ff6e1f2da4..498996f4f800c2f67065e6646e7b0854f113b138 100644
--- a/app/models/concerns/storage/legacy_namespace.rb
+++ b/app/models/concerns/storage/legacy_namespace.rb
@@ -4,6 +4,8 @@ module Storage
module LegacyNamespace
extend ActiveSupport::Concern
+ include Gitlab::ShellAdapter
+
def move_dir
proj_with_tags = first_project_with_container_registry_tags
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index c32008aa9c74d570212cb3b07f56a8a75d9ad73c..279603496b0f2cd1154c152301d21792169a1653 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -66,10 +66,23 @@ class DiffNote < Note
self.original_position.diff_refs == diff_refs
end
+ def supports_suggestion?
+ return false unless noteable.supports_suggestion? && on_text?
+ # We don't want to trigger side-effects of `diff_file` call.
+ return false unless file = fetch_diff_file
+ return false unless line = file.line_for_position(self.original_position)
+
+ line&.suggestible?
+ end
+
def discussion_first_note?
self == discussion.first_note
end
+ def banzai_render_context(field)
+ super.merge(suggestions_filter_enabled: supports_suggestion?)
+ end
+
private
def enqueue_diff_file_creation_job
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 537f2a3a2311124b33640f724544f79a4a204b3a..016c18ce6c8a499a2b5a83d7518c5bbf01448d5b 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -3,8 +3,6 @@
class ProjectMember < Member
SOURCE_TYPE = 'Project'.freeze
- include Gitlab::ShellAdapter
-
belongs_to :project, foreign_key: 'source_id'
# Make sure project member points only to project as it source
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 77e48ce11e85027a5ea8e6b1ac5fff39b6884847..baf320d84a1283e9efab58e886184d84dda48eb6 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -363,6 +363,11 @@ class MergeRequest < ActiveRecord::Base
end
end
+ def supports_suggestion?
+ # Should be `true` when removing the FF.
+ Suggestion.feature_enabled?
+ end
+
# Calls `MergeWorker` to proceed with the merge process and
# updates `merge_jid` with the MergeWorker#jid.
# This helps tracking enqueued and ongoing merge jobs.
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 8865c164b112b906115d7ad2a96c5fe2c1d97121..3c9b1d32a537da8b62ed30d4d4fa2650e601a178 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -3,7 +3,6 @@
class Namespace < ActiveRecord::Base
include CacheMarkdownField
include Sortable
- include Gitlab::ShellAdapter
include Gitlab::VisibilityLevel
include Routable
include AfterCommitQueue
diff --git a/app/models/note.rb b/app/models/note.rb
index a6ae4f58ac48cb0ef9390054ec8797fc93fbd1f9..42237169722e24c98a5623dfdeed7e8339cea31e 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -69,6 +69,12 @@ class Note < ActiveRecord::Base
belongs_to :last_edited_by, class_name: 'User'
has_many :todos
+
+ # The delete_all definition is required here in order
+ # to generate the correct DELETE sql for
+ # suggestions.delete_all calls
+ has_many :suggestions, -> { order(:relative_order) },
+ inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_one :system_note_metadata
has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
@@ -110,7 +116,7 @@ class Note < ActiveRecord::Base
scope :inc_author, -> { includes(:author) }
scope :inc_relations_for_view, -> do
includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
- :system_note_metadata, :note_diff_file)
+ :system_note_metadata, :note_diff_file, :suggestions)
end
scope :with_notes_filter, -> (notes_filter) do
@@ -226,6 +232,10 @@ class Note < ActiveRecord::Base
Gitlab::HookData::NoteBuilder.new(self).build
end
+ def supports_suggestion?
+ false
+ end
+
def for_commit?
noteable_type == "Commit"
end
diff --git a/app/models/project.rb b/app/models/project.rb
index f5dc58cd67ff45c0bee89956a8526a1a2d865c04..9e65f7bdbca15517bc27a07985256be6f0675d94 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -655,6 +655,11 @@ class Project < ActiveRecord::Base
end
end
+ def latest_successful_build_for(job_name, ref = default_branch)
+ builds = latest_successful_builds_for(ref)
+ builds.find_by!(name: job_name)
+ end
+
def merge_base_commit(first_commit_id, second_commit_id)
sha = repository.merge_base(first_commit_id, second_commit_id)
commit_by(oid: sha) if sha
@@ -2009,12 +2014,12 @@ class Project < ActiveRecord::Base
def create_new_pool_repository
pool = begin
- create_or_find_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self)
+ create_pool_repository!(shard: Shard.by_name(repository_storage), source_project: self)
rescue ActiveRecord::RecordNotUnique
- retry
+ pool_repository(true)
end
- pool.schedule
+ pool.schedule unless pool.scheduled?
pool
end
diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb
index 6c1073265a17b228305f1b5d75dc2a3879a5cadf..d075440b147b1daa39c848384c9da38ec01464c3 100644
--- a/app/models/protected_branch.rb
+++ b/app/models/protected_branch.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ProtectedBranch < ActiveRecord::Base
- include Gitlab::ShellAdapter
include ProtectedRef
protected_ref_access_levels :merge, :push
diff --git a/app/models/protected_tag.rb b/app/models/protected_tag.rb
index 94746141945051e1330ac4291bfdda79bc17d5ea..d28ebabfe49c6531b392639a3db608d3e1252bb3 100644
--- a/app/models/protected_tag.rb
+++ b/app/models/protected_tag.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
class ProtectedTag < ActiveRecord::Base
- include Gitlab::ShellAdapter
include ProtectedRef
validates :name, uniqueness: { scope: :project_id }
diff --git a/app/models/repository.rb b/app/models/repository.rb
index a9c167373c30146fe55008ae1d67b24864ddfd9e..0ab7e711a0160cde299bf4bb16d6997133087dd3 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -17,7 +17,6 @@ class Repository
#{REF_ENVIRONMENTS}
].freeze
- include Gitlab::ShellAdapter
include Gitlab::RepositoryCacheAdapter
attr_accessor :full_path, :disk_path, :project, :is_wiki
diff --git a/app/models/suggestion.rb b/app/models/suggestion.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cec5ea30f9dc01451b1d06dc4df2e511fb04c114
--- /dev/null
+++ b/app/models/suggestion.rb
@@ -0,0 +1,61 @@
+# frozen_string_literal: true
+
+class Suggestion < ApplicationRecord
+ FEATURE_FLAG = :diff_suggestions
+
+ belongs_to :note, inverse_of: :suggestions
+ validates :note, presence: true
+ validates :commit_id, presence: true, if: :applied?
+
+ delegate :original_position, :position, :diff_file,
+ :noteable, to: :note
+
+ def self.feature_enabled?
+ Feature.enabled?(FEATURE_FLAG)
+ end
+
+ def project
+ noteable.source_project
+ end
+
+ def branch
+ noteable.source_branch
+ end
+
+ # For now, suggestions only serve as a way to send patches that
+ # will change a single line (being able to apply multiple in the same place),
+ # which explains `from_line` and `to_line` being the same line.
+ # We'll iterate on that in https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
+ # when allowing multi-line suggestions.
+ def from_line
+ position.new_line
+ end
+ alias_method :to_line, :from_line
+
+ def from_original_line
+ original_position.new_line
+ end
+ alias_method :to_original_line, :from_original_line
+
+ # `from_line_index` and `to_line_index` represents diff/blob line numbers in
+ # index-like way (N-1).
+ def from_line_index
+ from_line - 1
+ end
+ alias_method :to_line_index, :from_line_index
+
+ def appliable?
+ return false unless note.supports_suggestion?
+
+ !applied? &&
+ noteable.opened? &&
+ different_content? &&
+ note.active?
+ end
+
+ private
+
+ def different_content?
+ from_content != to_content
+ end
+end
diff --git a/app/policies/suggestion_policy.rb b/app/policies/suggestion_policy.rb
new file mode 100644
index 0000000000000000000000000000000000000000..301b7d965f5604ff630ba856493bb768bc18b990
--- /dev/null
+++ b/app/policies/suggestion_policy.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class SuggestionPolicy < BasePolicy
+ delegate { @subject.project }
+
+ condition(:can_push_to_branch) do
+ Gitlab::UserAccess.new(@user, project: @subject.project).can_push_to_branch?(@subject.branch)
+ end
+
+ rule { can_push_to_branch }.enable :apply_suggestion
+end
diff --git a/app/serializers/diff_line_entity.rb b/app/serializers/diff_line_entity.rb
index 942714b7787db55d3ea15dda8e7c0c6c95646348..bfef6d3bde82222e93a428d81b70ae709aec2218 100644
--- a/app/serializers/diff_line_entity.rb
+++ b/app/serializers/diff_line_entity.rb
@@ -11,4 +11,6 @@ class DiffLineEntity < Grape::Entity
expose :rich_text do |line|
ERB::Util.html_escape(line.rich_text || line.text)
end
+
+ expose :suggestible?, as: :can_receive_suggestion
end
diff --git a/app/serializers/issue_board_entity.rb b/app/serializers/issue_board_entity.rb
index 58ab804a3c8843226e2cb0ce759826571f71d39e..e3dc43240c60e80584a2365529585c77804ffb34 100644
--- a/app/serializers/issue_board_entity.rb
+++ b/app/serializers/issue_board_entity.rb
@@ -17,7 +17,7 @@ class IssueBoardEntity < Grape::Entity
end
expose :milestone, expose_nil: false do |issue|
- API::Entities::Project.represent issue.milestone, only: [:id, :title]
+ API::Entities::Milestone.represent issue.milestone, only: [:id, :title]
end
expose :assignees do |issue|
diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb
index f33a1654d5e0f2b1072bd4b210551c0e0770d01b..9731b52f1adcea2871aece78a2d62811bd17df09 100644
--- a/app/serializers/merge_request_widget_entity.rb
+++ b/app/serializers/merge_request_widget_entity.rb
@@ -238,6 +238,8 @@ class MergeRequestWidgetEntity < IssuableEntity
end
end
+ expose :supports_suggestion?, as: :can_receive_suggestion
+
private
delegate :current_user, to: :request
diff --git a/app/serializers/note_entity.rb b/app/serializers/note_entity.rb
index c6d2781741199ef3b3a7d1315b9d1c9d39e8c5b0..1d3b59eb1b702839ecae590b95e9af8ecc0a101a 100644
--- a/app/serializers/note_entity.rb
+++ b/app/serializers/note_entity.rb
@@ -36,6 +36,7 @@ class NoteEntity < API::Entities::Note
end
end
+ expose :suggestions, using: SuggestionEntity
expose :resolved?, as: :resolved
expose :resolvable?, as: :resolvable
diff --git a/app/serializers/suggestion_entity.rb b/app/serializers/suggestion_entity.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4d0d4da10bea944d3f598158dc84742fa13534b4
--- /dev/null
+++ b/app/serializers/suggestion_entity.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class SuggestionEntity < API::Entities::Suggestion
+ include RequestAwareEntity
+
+ expose :current_user do
+ expose :can_apply do |suggestion|
+ Ability.allowed?(current_user, :apply_suggestion, suggestion)
+ end
+ end
+
+ private
+
+ def current_user
+ request.current_user
+ end
+end
diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb
index e03789e3ca93fc50eb48ecce53cd13bf22e3a428..c4546f30235bb3021700b3b24aa77097f12622cb 100644
--- a/app/services/notes/create_service.rb
+++ b/app/services/notes/create_service.rb
@@ -36,6 +36,7 @@ module Notes
if !only_commands && note.save
todo_service.new_note(note, current_user)
clear_noteable_diffs_cache(note)
+ Suggestions::CreateService.new(note).execute
end
if command_params.present?
diff --git a/app/services/notes/update_service.rb b/app/services/notes/update_service.rb
index 35db409eb27e1ae135363a7f6902876c30df24f6..d2052bed646183a268fe75146c2653e6be353d5c 100644
--- a/app/services/notes/update_service.rb
+++ b/app/services/notes/update_service.rb
@@ -14,6 +14,17 @@ module Notes
TodoService.new.update_note(note, current_user, old_mentioned_users)
end
+ if note.supports_suggestion?
+ Suggestion.transaction do
+ note.suggestions.delete_all
+ Suggestions::CreateService.new(note).execute
+ end
+
+ # We need to refresh the previous suggestions call cache
+ # in order to get the new records.
+ note.reload
+ end
+
note
end
end
diff --git a/app/services/preview_markdown_service.rb b/app/services/preview_markdown_service.rb
index de8757006f135b71db67c64d37592614b71a5e77..a449a5dc3e9833a1a59e0e161728ec93024b7f6c 100644
--- a/app/services/preview_markdown_service.rb
+++ b/app/services/preview_markdown_service.rb
@@ -4,10 +4,12 @@ class PreviewMarkdownService < BaseService
def execute
text, commands = explain_quick_actions(params[:text])
users = find_user_references(text)
+ suggestions = find_suggestions(text)
success(
text: text,
users: users,
+ suggestions: suggestions,
commands: commands.join(' '),
markdown_engine: markdown_engine
)
@@ -28,6 +30,12 @@ class PreviewMarkdownService < BaseService
extractor.users.map(&:username)
end
+ def find_suggestions(text)
+ return [] unless params[:preview_suggestions]
+
+ Banzai::SuggestionsParser.parse(text)
+ end
+
def find_commands_target
QuickActions::TargetService
.new(project, current_user)
diff --git a/app/services/suggestions/apply_service.rb b/app/services/suggestions/apply_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d931d528c86aa614ed9b9d6327a7fe7d0c9e8517
--- /dev/null
+++ b/app/services/suggestions/apply_service.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class ApplyService < ::BaseService
+ def initialize(current_user)
+ @current_user = current_user
+ end
+
+ def execute(suggestion)
+ unless suggestion.appliable?
+ return error('Suggestion is not appliable')
+ end
+
+ params = file_update_params(suggestion)
+ result = ::Files::UpdateService.new(suggestion.project, @current_user, params).execute
+
+ if result[:status] == :success
+ suggestion.update(commit_id: result[:result], applied: true)
+ end
+
+ result
+ end
+
+ private
+
+ def file_update_params(suggestion)
+ diff_file = suggestion.diff_file
+
+ file_path = diff_file.file_path
+ branch_name = suggestion.noteable.source_branch
+ file_content = new_file_content(suggestion)
+ commit_message = "Apply suggestion to #{file_path}"
+
+ {
+ file_path: file_path,
+ branch_name: branch_name,
+ start_branch: branch_name,
+ commit_message: commit_message,
+ file_content: file_content
+ }
+ end
+
+ def new_file_content(suggestion)
+ range = suggestion.from_line_index..suggestion.to_line_index
+ blob = suggestion.diff_file.new_blob
+
+ blob.load_all_data!
+ content = blob.data.lines
+ content[range] = suggestion.to_content
+
+ content.join
+ end
+ end
+end
diff --git a/app/services/suggestions/create_service.rb b/app/services/suggestions/create_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..77e958cbe0c281ec230dcecaa874dba9fa4ca8eb
--- /dev/null
+++ b/app/services/suggestions/create_service.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+module Suggestions
+ class CreateService
+ def initialize(note)
+ @note = note
+ end
+
+ def execute
+ return unless @note.supports_suggestion?
+
+ suggestions = Banzai::SuggestionsParser.parse(@note.note)
+
+ # For single line suggestion we're only looking forward to
+ # change the line receiving the comment. Though, in
+ # https://gitlab.com/gitlab-org/gitlab-ce/issues/53310
+ # we'll introduce a ```suggestion:L-, so this will
+ # slightly change.
+ comment_line = @note.position.new_line
+
+ rows =
+ suggestions.map.with_index do |suggestion, index|
+ from_content = changing_lines(comment_line, comment_line)
+
+ # The parsed suggestion doesn't have information about the correct
+ # ending characters (we may have a line break, or not), so we take
+ # this information from the last line being changed (last
+ # characters).
+ endline_chars = line_break_chars(from_content.lines.last)
+ to_content = "#{suggestion}#{endline_chars}"
+
+ {
+ note_id: @note.id,
+ from_content: from_content,
+ to_content: to_content,
+ relative_order: index
+ }
+ end
+
+ rows.in_groups_of(100, false) do |rows|
+ Gitlab::Database.bulk_insert('suggestions', rows)
+ end
+ end
+
+ private
+
+ def changing_lines(from_line, to_line)
+ @note.diff_file.new_blob_lines_between(from_line, to_line).join
+ end
+
+ def line_break_chars(line)
+ match = /\r\n|\r|\n/.match(line)
+ match[0] if match
+ end
+ end
+end
diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb
index 216acf79cbdb870627118e1f562acdc48b9cf254..5feb0b0f05b5edf7aeddd3f08e9036e66a734b17 100644
--- a/app/validators/url_validator.rb
+++ b/app/validators/url_validator.rb
@@ -69,6 +69,7 @@ class UrlValidator < ActiveModel::EachValidator
ports: [],
allow_localhost: true,
allow_local_network: true,
+ ascii_only: false,
enforce_user: false
}
end
diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml
index ac5916d129c317636fb89c4f929d0adec3a13388..08a6359f77758f3c6525c7f103f6ce1f1b72bd0b 100644
--- a/app/views/layouts/_head.html.haml
+++ b/app/views/layouts/_head.html.haml
@@ -36,6 +36,7 @@
= stylesheet_link_tag "print", media: "print"
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
+ = stylesheet_link_tag 'csslab' if Feature.enabled?(:csslab)
= Gon::Base.render_data
diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml
index b7d69539eb73a370520802c5e57f7eb662e83cb3..e8d0d809181840867f8d747f4789dc9b3afcfc9c 100644
--- a/app/views/layouts/header/_default.html.haml
+++ b/app/views/layouts/header/_default.html.haml
@@ -15,7 +15,7 @@
= brand_header_logo
- logo_text = brand_header_logo_type
- if logo_text.present?
- %span.logo-text.d-none.d-sm-block
+ %span.logo-text.d-none.d-lg-block.prepend-left-8
= logo_text
- if current_user
diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml
index ea5f2b166b4b5c77108fdb955a63f01d49168305..7057a5a142f937ad658113a7d8aaed7d660b8d49 100644
--- a/app/views/layouts/nav/_dashboard.html.haml
+++ b/app/views/layouts/nav/_dashboard.html.haml
@@ -1,3 +1,5 @@
+-# WAIT! Before adding more items to the nav bar, please see
+-# https://gitlab.com/gitlab-org/gitlab-ce/issues/49713 for more information.
%ul.list-unstyled.navbar-sub-nav
- if dashboard_nav_link?(:projects)
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown header-projects qa-projects-dropdown" }) do
@@ -16,22 +18,22 @@
= render "layouts/nav/groups_dropdown/show"
- if dashboard_nav_link?(:activity)
- = nav_link(path: 'dashboard#activity', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = nav_link(path: 'dashboard#activity', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: _('Activity') do
= _('Activity')
- if dashboard_nav_link?(:milestones)
- = nav_link(controller: 'dashboard/milestones', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = nav_link(controller: 'dashboard/milestones', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
= link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: _('Milestones') do
= _('Milestones')
- if dashboard_nav_link?(:snippets)
- = nav_link(controller: 'dashboard/snippets', html_options: { class: "d-none d-lg-block d-xl-block" }) do
+ = nav_link(controller: 'dashboard/snippets', html_options: { class: ["d-none d-xl-block", ("d-lg-block" unless has_extra_nav_icons?)] }) do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
= _('Snippets')
- if any_dashboard_nav_link?([:groups, :milestones, :activity, :snippets])
- %li.header-more.dropdown.d-lg-none.d-xl-none
+ %li.header-more.dropdown.d-xl-none{ class: ('d-lg-none' unless has_extra_nav_icons?) }
%a{ href: "#", data: { toggle: "dropdown" } }
= _('More')
= sprite_icon('angle-down', css_class: 'caret-down')
@@ -52,6 +54,21 @@
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: _('Snippets') do
= _('Snippets')
+ = render_if_exists 'dashboard/operations/nav_link'
+ - if can?(current_user, :read_instance_statistics)
+ = nav_link(controller: [:conversational_development_index, :cohorts]) do
+ = link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = _('Instance Statistics')
+ - if current_user.admin?
+ = nav_link(controller: 'admin/dashboard') do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = _('Admin Area')
+ - if Gitlab::Sherlock.enabled?
+ %li
+ = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'),
+ data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = _('Sherlock Transactions')
+
-# Shortcut to Dashboard > Projects
- if dashboard_nav_link?(:projects)
%li.hidden
@@ -64,19 +81,17 @@
= link_to '#', class: 'dashboard-shortcuts-web-ide', title: _('Web IDE') do
= _('Web IDE')
- - if show_separator?
- %li.line-separator.d-none.d-sm-block
= render_if_exists 'dashboard/operations/nav_link'
- if can?(current_user, :read_instance_statistics)
- = nav_link(controller: [:conversational_development_index, :cohorts]) do
+ = nav_link(controller: [:conversational_development_index, :cohorts], html_options: { class: "d-none d-lg-block d-xl-block"}) do
= link_to instance_statistics_root_path, title: _('Instance Statistics'), aria: { label: _('Instance Statistics') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('chart', size: 18)
- if current_user.admin?
- = nav_link(controller: 'admin/dashboard') do
- = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin area'), aria: { label: _('Admin area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
+ = nav_link(controller: 'admin/dashboard', html_options: { class: "d-none d-lg-block d-xl-block"}) do
+ = link_to admin_root_path, class: 'admin-icon qa-admin-area-link', title: _('Admin Area'), aria: { label: _('Admin Area') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('admin', size: 18)
- if Gitlab::Sherlock.enabled?
%li
- = link_to sherlock_transactions_path, class: 'admin-icon', title: _('Sherlock Transactions'),
+ = link_to sherlock_transactions_path, class: 'admin-icon d-none d-lg-block d-xl-block', title: _('Sherlock Transactions'),
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml
index b89541a3c9fba50d4eccad62adbf8fc2f099c853..bdd0108db0d1562c758451883da5751ecd67b280 100644
--- a/app/views/layouts/nav/sidebar/_project.html.haml
+++ b/app/views/layouts/nav/sidebar/_project.html.haml
@@ -196,7 +196,7 @@
- if project_nav_tab? :operations
= nav_link(controller: sidebar_operations_paths) do
- = link_to metrics_project_environments_path(@project), class: 'shortcuts-operations' do
+ = link_to sidebar_operations_link_path, class: 'shortcuts-operations' do
.nav-icon-container
= sprite_icon('cloud-gear')
%span.nav-item-name
@@ -204,7 +204,7 @@
%ul.sidebar-sub-level-items
= nav_link(controller: sidebar_operations_paths, html_options: { class: "fly-out-top-item" } ) do
- = link_to metrics_project_environments_path(@project) do
+ = link_to sidebar_operations_link_path do
%strong.fly-out-top-item-name
= _('Operations')
%li.divider.fly-out-top-item
diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml
index cf273aab108bb96a14bb161a7ce8fcb41bdcecaf..95c5eb32c7f1aa1cff94a90a20d4266ce853df91 100644
--- a/app/views/projects/blob/_blob.html.haml
+++ b/app/views/projects/blob/_blob.html.haml
@@ -9,6 +9,6 @@
= render "projects/blob/auxiliary_viewer", blob: blob
#blob-content-holder.blob-content-holder
- %article.file-holder
+ %article.file-holder{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'projects/blob/header', blob: blob
= render 'projects/blob/content', blob: blob
diff --git a/app/views/projects/blob/preview.html.haml b/app/views/projects/blob/preview.html.haml
index eb65cd90ea8b5f5792bf79f3fb8b3649401038a2..ff460a3831c9735c171e924886150933aa3049f4 100644
--- a/app/views/projects/blob/preview.html.haml
+++ b/app/views/projects/blob/preview.html.haml
@@ -1,7 +1,7 @@
.diff-file.file-holder
.diff-content
- if markup?(@blob.name)
- .file-content.wiki
+ .file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(@blob.name, @content, legacy_render_context(params))
- else
.file-content.code.js-syntax-highlight
diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml
index bd12cadf2405dd4f2ebf80112e826373901dda2c..6edbfd91b21d7eb64590edebb2fb1836f311ccd6 100644
--- a/app/views/projects/blob/viewers/_markup.html.haml
+++ b/app/views/projects/blob/viewers/_markup.html.haml
@@ -2,5 +2,5 @@
- context = legacy_render_context(params)
- unless context[:markdown_engine] == :redcarpet
- context[:rendered] = blob.rendered_markup if blob.respond_to?(:rendered_markup)
-.file-content.wiki
+.file-content.wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= markup(blob.name, blob.data, context)
diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml
index 4ebb029e48bf0b98c8612729c1ebcc2d9f43194c..a0a03838b10461f8d012f0a47df23dd862f43972 100644
--- a/app/views/projects/merge_requests/show.html.haml
+++ b/app/views/projects/merge_requests/show.html.haml
@@ -67,6 +67,7 @@
noteable_data: serialize_issuable(@merge_request),
noteable_type: 'MergeRequest',
target_type: 'merge_request',
+ help_page_path: nil,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json} }
#commits.commits.tab-pane
@@ -76,6 +77,7 @@
= render 'projects/commit/pipelines_list', disable_initialization: true, endpoint: pipelines_project_merge_request_path(@project, @merge_request)
#js-diffs-app.diffs.tab-pane{ data: { "is-locked" => @merge_request.discussion_locked?,
endpoint: diffs_project_merge_request_path(@project, @merge_request, 'json', request.query_parameters),
+ help_page_path: nil,
current_user_data: UserSerializer.new(project: @project).represent(current_user, {}, MergeRequestUserEntity).to_json,
project_path: project_path(@merge_request.project)} }
diff --git a/app/views/projects/snippets/show.html.haml b/app/views/projects/snippets/show.html.haml
index f495b4eaf30364c0f6669c0efa3d14ba2368f1a2..da48cb207a4d577db3533f5e22516d95d3f1cfac 100644
--- a/app/views/projects/snippets/show.html.haml
+++ b/app/views/projects/snippets/show.html.haml
@@ -6,7 +6,7 @@
= render 'shared/snippets/header'
.project-snippets
- %article.file-holder.snippet-file-content
+ %article.file-holder.snippet-file-content{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render 'shared/snippets/blob'
.row-content-block.top-block.content-component-block
diff --git a/app/views/projects/wikis/show.html.haml b/app/views/projects/wikis/show.html.haml
index cc38ec12fd86ec02d9392d01d09c06609cd4f424..4d5fd55364c3b92e531201a58f10dbadb61ff0e3 100644
--- a/app/views/projects/wikis/show.html.haml
+++ b/app/views/projects/wikis/show.html.haml
@@ -26,7 +26,7 @@
= (s_("WikiHistoricalPage|You can view the %{most_recent_link} or browse the %{history_link}.") % { most_recent_link: most_recent_link, history_link: history_link }).html_safe
.prepend-top-default.append-bottom-default
- .wiki
+ .wiki.md{ class: ('use-csslab' if Feature.enabled?(:csslab)) }
= render_wiki_content(@page, legacy_render_context(params))
= render 'sidebar'
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 5295e656ab0564701025477cd55d53a618330646..9eecfa39390eb7ff279019b1b43c9239f148c195 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -16,7 +16,7 @@
- if current_user
.block.todo.hide-expanded
= render "shared/issuable/sidebar_todo", todo: todo, issuable: issuable, is_collapsed: true
- .block.assignee
+ .block.assignee.qa-assignee-block
= render "shared/issuable/sidebar_assignees", issuable: issuable, can_edit_issuable: can_edit_issuable, signed_in: current_user.present?
= render_if_exists 'shared/issuable/sidebar_item_epic', issuable: issuable
diff --git a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
index 3521f71f409f99f9acb35b48915ea26acaa4ccf6..60c34094108d144c4ef944a671451b43fbf05492 100644
--- a/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
+++ b/app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml
@@ -5,4 +5,4 @@
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
- = link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
+ = link_to 'Assign to me', '#', class: "assign-to-me-link qa-assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index d11476738e40be81dd5381c389a2e119471f7b47..dd2cd36eac25524ae5eafe25e46a283df2458591 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -31,12 +31,12 @@
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('users')
- .profile-header
+ .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?)] }
.avatar-holder
= link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do
= image_tag avatar_icon_for_user(@user, 90), class: "avatar s90", alt: ''
- .user-info.prepend-left-default.append-right-default
+ .user-info
.cover-title
= @user.name
@@ -81,10 +81,10 @@
= icon('briefcase')
= @user.organization
- - if @user.bio.present?
- .cover-desc
- %p.profile-user-bio
- = @user.bio
+ - if @user.bio.present?
+ .cover-desc
+ %p.profile-user-bio
+ = @user.bio
- unless profile_tabs.empty?
.scrolling-tabs-container
diff --git a/app/workers/repository_update_remote_mirror_worker.rb b/app/workers/repository_update_remote_mirror_worker.rb
index 9d4e67deb9cc2101f3d2d2ae43cf45ff90f1ca28..bd429d526bf4bf60a52e7c84cf52cf707807fd1c 100644
--- a/app/workers/repository_update_remote_mirror_worker.rb
+++ b/app/workers/repository_update_remote_mirror_worker.rb
@@ -5,7 +5,6 @@ class RepositoryUpdateRemoteMirrorWorker
UpdateError = Class.new(StandardError)
include ApplicationWorker
- include Gitlab::ShellAdapter
sidekiq_options retry: 3, dead: false
diff --git a/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml b/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0b5d1a6b05a5ca1d4c7547b0854ddc4e647b51d6
--- /dev/null
+++ b/changelogs/unreleased/49713-main-navbar-is-broken-in-certain-viewport-widths.yml
@@ -0,0 +1,5 @@
+---
+title: Resolve Main navbar is broken in certain viewport widths
+merge_request: 23348
+author:
+type: fixed
diff --git a/changelogs/unreleased/50157-extended-user-centric-tooltips.yml b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3b55a867b87a50c03cde808f6ce03bc020517a25
--- /dev/null
+++ b/changelogs/unreleased/50157-extended-user-centric-tooltips.yml
@@ -0,0 +1,5 @@
+---
+title: Extended user centric tooltips on issue and MR page
+merge_request: 23231
+author:
+type: added
diff --git a/changelogs/unreleased/51122-fix-navigating-discussions.yml b/changelogs/unreleased/51122-fix-navigating-discussions.yml
new file mode 100644
index 0000000000000000000000000000000000000000..94d766545895577131da468fe602e6cb5970cde2
--- /dev/null
+++ b/changelogs/unreleased/51122-fix-navigating-discussions.yml
@@ -0,0 +1,5 @@
+---
+title: Fix navigating by unresolved discussions on Merge Request page
+merge_request: 22789
+author:
+type: fixed
diff --git a/changelogs/unreleased/52774-fix-svgs-in-ie-11.yml b/changelogs/unreleased/52774-fix-svgs-in-ie-11.yml
new file mode 100644
index 0000000000000000000000000000000000000000..656a915a281bea5e167c0f5478cc6b428f35d3a2
--- /dev/null
+++ b/changelogs/unreleased/52774-fix-svgs-in-ie-11.yml
@@ -0,0 +1,5 @@
+---
+title: Ensure that SVG sprite icons are properly rendered in IE11
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml b/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fa905b47ca269526c760c772fa0fa330c214c30d
--- /dev/null
+++ b/changelogs/unreleased/54626-able-to-download-a-single-archive-file-with-api-by-ref-name.yml
@@ -0,0 +1,5 @@
+---
+title: Add new endpoint to download single artifact file for a ref
+merge_request: 23538
+author:
+type: added
diff --git a/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml b/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d2ff095ce55db681f0ab25e7771ded5971503590
--- /dev/null
+++ b/changelogs/unreleased/55402-broken-master-karma-test-failing-in-spec-javascripts-boards-components-issue_due_date_spec-js.yml
@@ -0,0 +1,5 @@
+---
+title: Fix due date test
+merge_request: 23845
+author:
+type: other
diff --git a/changelogs/unreleased/commit-badge-style-fix.yml b/changelogs/unreleased/commit-badge-style-fix.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d7b377178537ee07c69dce85819c0643c35cf82b
--- /dev/null
+++ b/changelogs/unreleased/commit-badge-style-fix.yml
@@ -0,0 +1,5 @@
+---
+title: Fixed styling of image comment badges on commits
+merge_request:
+author:
+type: fixed
diff --git a/changelogs/unreleased/define-default-value-for-only-except-keys.yml b/changelogs/unreleased/define-default-value-for-only-except-keys.yml
index 3e5ecdcf51e538191644ee0727977480fce1e3bf..ed0e982f0fc71bfb9ec404ce6c309b09aedd46af 100644
--- a/changelogs/unreleased/define-default-value-for-only-except-keys.yml
+++ b/changelogs/unreleased/define-default-value-for-only-except-keys.yml
@@ -1,5 +1,5 @@
---
title: Define the default value for only/except policies
-merge_request: 23531
+merge_request: 23765
author:
type: changed
diff --git a/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml b/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml
new file mode 100644
index 0000000000000000000000000000000000000000..09a10a86adc9364c5c1bb40ed8648a9db182f857
--- /dev/null
+++ b/changelogs/unreleased/osw-update-mr-metrics-with-events-data.yml
@@ -0,0 +1,5 @@
+---
+title: Populate MR metrics with events table information (migration)
+merge_request: 23564
+author:
+type: performance
diff --git a/changelogs/unreleased/profile-fixing.yml b/changelogs/unreleased/profile-fixing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..7e255d997d83dd39c59d4b75d9999df6546fa2e4
--- /dev/null
+++ b/changelogs/unreleased/profile-fixing.yml
@@ -0,0 +1,5 @@
+---
+title: Fix bottom paddings of profile header and some markup updates of profile
+merge_request: 23168
+author: Harry Kiselev
+type: other
diff --git a/changelogs/unreleased/suggest-change-to-diff-line.yml b/changelogs/unreleased/suggest-change-to-diff-line.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cb949f14e8c36a53529f6632bfb13792a94ceb2c
--- /dev/null
+++ b/changelogs/unreleased/suggest-change-to-diff-line.yml
@@ -0,0 +1,5 @@
+---
+title: Add ability to render suggestions
+merge_request: 23147
+author:
+type: added
diff --git a/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml b/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1389693b9a9242251e4c254135c5f8a72ab2b131
--- /dev/null
+++ b/changelogs/unreleased/upgrade-to-workhorse-7-6-0.yml
@@ -0,0 +1,5 @@
+---
+title: Upgrade workhorse to 7.6.0
+merge_request: 23694
+author:
+type: other
diff --git a/changelogs/unreleased/winh-dropdown-item-padding.yml b/changelogs/unreleased/winh-dropdown-item-padding.yml
new file mode 100644
index 0000000000000000000000000000000000000000..9f18abba9d1ec1189327e82db57c3cccc07d2325
--- /dev/null
+++ b/changelogs/unreleased/winh-dropdown-item-padding.yml
@@ -0,0 +1,5 @@
+---
+title: Adjust dropdown item and header padding to comply with design specs
+merge_request: 23552
+author:
+type: changed
diff --git a/changelogs/unreleased/winh-resolved-discussions-reply-field.yml b/changelogs/unreleased/winh-resolved-discussions-reply-field.yml
new file mode 100644
index 0000000000000000000000000000000000000000..01cf35ae8a721dbcf2dcc07e155a8cc9fddca213
--- /dev/null
+++ b/changelogs/unreleased/winh-resolved-discussions-reply-field.yml
@@ -0,0 +1,5 @@
+---
+title: Display reply field if resolved discussion has no replies
+merge_request: 23801
+author:
+type: fixed
diff --git a/config/application.rb b/config/application.rb
index 63a5b483fc2116238a8c30638f47b22ff122356b..f10b8ed5bd23503635a13c8cd737c92dd41f56a8 100644
--- a/config/application.rb
+++ b/config/application.rb
@@ -154,6 +154,7 @@ module Gitlab
config.assets.precompile << "locale/**/app.js"
config.assets.precompile << "emoji_sprites.css"
config.assets.precompile << "errors.css"
+ config.assets.precompile << "csslab.css"
# Import gitlab-svgs directly from vendored directory
config.assets.paths << "#{config.root}/node_modules/@gitlab/svgs/dist"
diff --git a/config/dependency_decisions.yml b/config/dependency_decisions.yml
index 6e4f7ce30a091c2dbf7e2a061371b23ae71e2785..af76bace577e0868f8ac469e42983f17f5700461 100644
--- a/config/dependency_decisions.yml
+++ b/config/dependency_decisions.yml
@@ -592,9 +592,10 @@
in compiled/distributed product so attribution not needed.
:versions: []
:when: 2018-10-02 19:23:54.840151000 Z
-- - :approve
+- - :license
- echarts
- - :who: Mike Greiling
+ - Apache 2.0
+ - :who: Adriel Santiago
:why: https://github.com/apache/incubator-echarts/blob/master/LICENSE
:versions: []
- :when: 2018-12-05 22:12:30.550027000 Z
+ :when: 2018-12-07 20:46:12.421256000 Z
diff --git a/db/migrate/20181123144235_create_suggestions.rb b/db/migrate/20181123144235_create_suggestions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bcc4d8c538b73a96c82ed1d1d15815e17be5a45e
--- /dev/null
+++ b/db/migrate/20181123144235_create_suggestions.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+class CreateSuggestions < ActiveRecord::Migration
+ DOWNTIME = false
+
+ def change
+ create_table :suggestions, id: :bigserial do |t|
+ t.references :note, foreign_key: { on_delete: :cascade }, null: false
+ t.integer :relative_order, null: false, limit: 2
+ t.boolean :applied, null: false, default: false
+ t.string :commit_id
+ t.text :from_content, null: false
+ t.text :to_content, null: false
+
+ t.index [:note_id, :relative_order],
+ name: 'index_suggestions_on_note_id_and_relative_order',
+ unique: true
+ end
+ end
+end
diff --git a/db/post_migrate/20161221153951_rename_reserved_project_names.rb b/db/post_migrate/20161221153951_rename_reserved_project_names.rb
index b7665e9849071667c5db51f9f8b7e62e18188de7..50e1c8449bacbb2ece164c1cea899199edd26610 100644
--- a/db/post_migrate/20161221153951_rename_reserved_project_names.rb
+++ b/db/post_migrate/20161221153951_rename_reserved_project_names.rb
@@ -1,6 +1,5 @@
class RenameReservedProjectNames < ActiveRecord::Migration[4.2]
include Gitlab::Database::MigrationHelpers
- include Gitlab::ShellAdapter
DOWNTIME = false
diff --git a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
index cac3fd713eb5849cacd3b1c784d5ce3d731ed080..bef669b459d86bd0ea1887fff52d88d35134b946 100644
--- a/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
+++ b/db/post_migrate/20170313133418_rename_more_reserved_project_names.rb
@@ -1,6 +1,5 @@
class RenameMoreReservedProjectNames < ActiveRecord::Migration[4.2]
include Gitlab::Database::MigrationHelpers
- include Gitlab::ShellAdapter
DOWNTIME = false
diff --git a/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb b/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1e43e3dd79074e02f3cb5f6df1ff62e0c4129d98
--- /dev/null
+++ b/db/post_migrate/20181204154019_populate_mr_metrics_with_events_data.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+# See http://doc.gitlab.com/ce/development/migration_style_guide.html
+# for more information on how to write migrations for GitLab.
+
+class PopulateMrMetricsWithEventsData < ActiveRecord::Migration[4.2]
+ include Gitlab::Database::MigrationHelpers
+
+ DOWNTIME = false
+ BATCH_SIZE = 10_000
+ MIGRATION = 'PopulateMergeRequestMetricsWithEventsDataImproved'
+ PREVIOUS_MIGRATION = 'PopulateMergeRequestMetricsWithEventsData'
+
+ disable_ddl_transaction!
+
+ def up
+ # Perform any ongoing background migration that might still be running from
+ # previous try (see https://gitlab.com/gitlab-org/gitlab-ce/issues/47676).
+ Gitlab::BackgroundMigration.steal(PREVIOUS_MIGRATION)
+
+ say 'Scheduling `PopulateMergeRequestMetricsWithEventsData` jobs'
+ # It will update around 4_000_000 records in batches of 10_000 merge
+ # requests (running between 5 minutes) and should take around 53 hours to complete.
+ # Apparently, production PostgreSQL is able to vacuum 10k-20k dead_tuples
+ # per minute. So this should give us enough space.
+ #
+ # More information about the updates in `PopulateMergeRequestMetricsWithEventsDataImproved` class.
+ #
+ MergeRequest.all.each_batch(of: BATCH_SIZE) do |relation, index|
+ range = relation.pluck('MIN(id)', 'MAX(id)').first
+
+ BackgroundMigrationWorker.perform_in(index * 8.minutes, MIGRATION, range)
+ end
+ end
+
+ def down
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index ff2dde3243c1babf9e403ca70d5203fa4fb63193..10b7aa8a99f56a06f7531296a2bd34270941188a 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20181203002526) do
+ActiveRecord::Schema.define(version: 20181204154019) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -1956,6 +1956,16 @@ ActiveRecord::Schema.define(version: 20181203002526) do
t.index ["subscribable_id", "subscribable_type", "user_id", "project_id"], name: "index_subscriptions_on_subscribable_and_user_id_and_project_id", unique: true, using: :btree
end
+ create_table "suggestions", id: :bigserial, force: :cascade do |t|
+ t.integer "note_id", null: false
+ t.integer "relative_order", limit: 2, null: false
+ t.boolean "applied", default: false, null: false
+ t.string "commit_id"
+ t.text "from_content", null: false
+ t.text "to_content", null: false
+ t.index ["note_id", "relative_order"], name: "index_suggestions_on_note_id_and_relative_order", unique: true, using: :btree
+ end
+
create_table "system_note_metadata", force: :cascade do |t|
t.integer "note_id", null: false
t.integer "commit_count"
@@ -2432,6 +2442,7 @@ ActiveRecord::Schema.define(version: 20181203002526) do
add_foreign_key "services", "projects", name: "fk_71cce407f9", on_delete: :cascade
add_foreign_key "snippets", "projects", name: "fk_be41fd4bb7", on_delete: :cascade
add_foreign_key "subscriptions", "projects", on_delete: :cascade
+ add_foreign_key "suggestions", "notes", on_delete: :cascade
add_foreign_key "system_note_metadata", "notes", name: "fk_d83a918cb1", on_delete: :cascade
add_foreign_key "term_agreements", "application_setting_terms", column: "term_id"
add_foreign_key "term_agreements", "users", on_delete: :cascade
diff --git a/doc/api/jobs.md b/doc/api/jobs.md
index aa290ff4cf8143335910f418b62008879905ce5c..589c48ee08de2a5ea43b72a7b2b12327a81b5219 100644
--- a/doc/api/jobs.md
+++ b/doc/api/jobs.md
@@ -404,7 +404,7 @@ Example response:
[ce-5347]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5347
-## Download a single artifact file
+## Download a single artifact file by job ID
> Introduced in GitLab 10.0
@@ -438,6 +438,41 @@ Example response:
| 400 | Invalid path provided |
| 404 | Build not found or no file/artifacts |
+## Download a single artifact file from specific tag or branch
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/23538) in GitLab 11.5.
+
+Download a single artifact file from a specific tag or branch from within the
+job's artifacts archive. The file is extracted from the archive and streamed to
+the client.
+
+```
+GET /projects/:id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name
+```
+
+Parameters:
+
+| Attribute | Type | Required | Description |
+|-----------------|----------------|----------|------------------------------------------------------------------------------------------------------------------|
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user. |
+| `ref_name` | string | yes | Branch or tag name in repository. HEAD or SHA references are not supported. |
+| `artifact_path` | string | yes | Path to a file inside the artifacts archive. |
+| `job` | string | yes | The name of the job. |
+
+Example request:
+
+```sh
+curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v4/projects/1/jobs/artifacts/master/raw/some/release/file.pdf?job=pdf"
+```
+
+Possible response status codes:
+
+| Status | Description |
+|-----------|--------------------------------------|
+| 200 | Sends a single artifact file |
+| 400 | Invalid path provided |
+| 404 | Build not found or no file/artifacts |
+
## Get a trace file
Get a trace of a specific job of a project
diff --git a/doc/ci/interactive_web_terminal/index.md b/doc/ci/interactive_web_terminal/index.md
index 2c799e83a5f14c22cbb6bfe6a5fd2f4d84a91686..d8136b80c11bf4e1da3c8f03dd50b560dd26c348 100644
--- a/doc/ci/interactive_web_terminal/index.md
+++ b/doc/ci/interactive_web_terminal/index.md
@@ -1,4 +1,4 @@
-# Getting started with interactive web terminals **[CORE ONLY]**
+# Interactive Web Terminals **[CORE ONLY]**
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/50144) in GitLab 11.3.
@@ -15,7 +15,7 @@ progress.
Two things need to be configured for the interactive web terminal to work:
- The Runner needs to have [`[session_server]` configured
- properly][session-server]
+ properly](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section)
- If you are using a reverse proxy with your GitLab instance, web terminals need to be
[enabled](../../administration/integration/terminal.md#enabling-and-disabling-terminal-support)
@@ -45,9 +45,7 @@ the terminal and type commands like a normal shell.
If you have the terminal open and the job has finished with its tasks, the
terminal will block the job from finishing for the duration configured in
-[`[session_server].terminal_max_retention_time`][session-server] until you
+[`[session_server].terminal_max_retention_time`](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section) until you
close the terminal window.

-
-[session-server]: https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-session_server-section
diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md
index e6b1f6b6aae40672f1591a517b453cbec5b4dc96..55e53b865af07bfba9c9bce26ea7d5763ea19531 100644
--- a/doc/user/project/web_ide/index.md
+++ b/doc/user/project/web_ide/index.md
@@ -1,6 +1,6 @@
# Web IDE
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ee/issues/4539) [GitLab Ultimate][ee] 10.4.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ee/issues/4539) in [GitLab Ultimate][ee] 10.4.
> [Brought to GitLab Core](https://gitlab.com/gitlab-org/gitlab-ce/issues/44157) in 10.7.
The Web IDE makes it faster and easier to contribute changes to your projects
@@ -15,7 +15,7 @@ and from merge requests.
## File finder
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18323) [GitLab Core][ce] 10.8.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/18323) in [GitLab Core][ce] 10.8.
The file finder allows you to quickly open files in the current branch by
searching. The file finder is launched using the keyboard shortcut `Command-p`,
@@ -65,7 +65,7 @@ shows you a preview of the merge request diff if you commit your changes.
## View CI job logs
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19279) [GitLab Core][ce] 11.0.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19279) in [GitLab Core][ce] 11.0.
The Web IDE can be used to quickly fix failing tests by opening the branch or
merge request in the Web IDE and opening the logs of the failed job. The status
@@ -77,7 +77,7 @@ left.
## Switching merge requests
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) in [GitLab Core][ce] 11.0.
Switching between your authored and assigned merge requests can be done without
leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
@@ -86,7 +86,7 @@ switching to a different merge request.
## Switching branches
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20850) [GitLab Core][ce] 11.2.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/20850) in [GitLab Core][ce] 11.2.
Switching between branches of the current project repository can be done without
leaving the Web IDE. Click the dropdown in the top of the sidebar to open a list
@@ -95,7 +95,7 @@ switching to a different branch.
## Client Side Evaluation
-> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19764) [GitLab Core][ce] 11.2.
+> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19764) in [GitLab Core][ce] 11.2.
The Web IDE can be used to preview JavaScript projects right in the browser.
This feature uses CodeSandbox to compile and bundle the JavaScript used to
diff --git a/lib/api/api.rb b/lib/api/api.rb
index 8abb24e6f69814489759fab89afac54feb1f01e7..f1448da7403c77d01f76d65c7005c4958bf96bdb 100644
--- a/lib/api/api.rb
+++ b/lib/api/api.rb
@@ -149,6 +149,7 @@ module API
mount ::API::Snippets
mount ::API::Submodules
mount ::API::Subscriptions
+ mount ::API::Suggestions
mount ::API::SystemHooks
mount ::API::Tags
mount ::API::Templates
diff --git a/lib/api/entities.rb b/lib/api/entities.rb
index 5dbfbb85e9ead9f8e41051eb415cb2476973170b..b83a5c1419015d65e91d96115708d5ccd2fff79b 100644
--- a/lib/api/entities.rb
+++ b/lib/api/entities.rb
@@ -1495,5 +1495,17 @@ module API
expose :label, using: Entities::LabelBasic
expose :action
end
+
+ class Suggestion < Grape::Entity
+ expose :id
+ expose :from_original_line
+ expose :to_original_line
+ expose :from_line
+ expose :to_line
+ expose :appliable?, as: :appliable
+ expose :applied
+ expose :from_content
+ expose :to_content
+ end
end
end
diff --git a/lib/api/job_artifacts.rb b/lib/api/job_artifacts.rb
index 7c2d8ff11bff038a3d19c78a9476d4d007dfeeb3..a4068a200b3cfd3729a75b6e47c51481030dd121 100644
--- a/lib/api/job_artifacts.rb
+++ b/lib/api/job_artifacts.rb
@@ -35,6 +35,29 @@ module API
end
# rubocop: enable CodeReuse/ActiveRecord
+ desc 'Download a specific file from artifacts archive from a ref' do
+ detail 'This feature was introduced in GitLab 11.5'
+ end
+ params do
+ requires :ref_name, type: String, desc: 'The ref from repository'
+ requires :job, type: String, desc: 'The name for the job'
+ requires :artifact_path, type: String, desc: 'Artifact path'
+ end
+ get ':id/jobs/artifacts/:ref_name/raw/*artifact_path',
+ format: false,
+ requirements: { ref_name: /.+/ } do
+ authorize_download_artifacts!
+
+ build = user_project.latest_successful_build_for(params[:job], params[:ref_name])
+
+ path = Gitlab::Ci::Build::Artifacts::Path
+ .new(params[:artifact_path])
+
+ bad_request! unless path.valid?
+
+ send_artifacts_entry(build, path)
+ end
+
desc 'Download the artifacts archive from a job' do
detail 'This feature was introduced in GitLab 8.5'
end
@@ -65,6 +88,7 @@ module API
path = Gitlab::Ci::Build::Artifacts::Path
.new(params[:artifact_path])
+
bad_request! unless path.valid?
send_artifacts_entry(build, path)
diff --git a/lib/api/suggestions.rb b/lib/api/suggestions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d008d1b9e97f00ad2a6b1b47742f4a53999d70b2
--- /dev/null
+++ b/lib/api/suggestions.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module API
+ class Suggestions < Grape::API
+ before { authenticate! }
+
+ resource :suggestions do
+ desc 'Apply suggestion patch in the Merge Request it was created' do
+ success Entities::Suggestion
+ end
+ params do
+ requires :id, type: String, desc: 'The suggestion ID'
+ end
+ put ':id/apply' do
+ suggestion = Suggestion.find_by_id(params[:id])
+
+ not_found! unless suggestion
+ authorize! :apply_suggestion, suggestion
+
+ result = ::Suggestions::ApplyService.new(current_user).execute(suggestion)
+
+ if result[:status] == :success
+ present suggestion, with: Entities::Suggestion, current_user: current_user
+ else
+ http_status = result[:http_status] || 400
+ render_api_error!(result[:message], http_status)
+ end
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/suggestion_filter.rb b/lib/banzai/filter/suggestion_filter.rb
new file mode 100644
index 0000000000000000000000000000000000000000..822db7cf26e174d3b8bb0070a14272ed38df1cf6
--- /dev/null
+++ b/lib/banzai/filter/suggestion_filter.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Banzai
+ module Filter
+ class SuggestionFilter < HTML::Pipeline::Filter
+ # Class used for tagging elements that should be rendered
+ TAG_CLASS = 'js-render-suggestion'.freeze
+
+ def call
+ return doc unless Suggestion.feature_enabled?
+ return doc unless suggestions_filter_enabled?
+
+ doc.search('pre.suggestion > code').each do |node|
+ node.add_class(TAG_CLASS)
+ end
+
+ doc
+ end
+
+ def suggestions_filter_enabled?
+ context[:suggestions_filter_enabled]
+ end
+ end
+ end
+end
diff --git a/lib/banzai/filter/syntax_highlight_filter.rb b/lib/banzai/filter/syntax_highlight_filter.rb
index 8a7f9045c24f218f2d6e9d5344c6ff7c4379e8b9..18e5e9185de6d8206afc7c6dd6caa13446f808c3 100644
--- a/lib/banzai/filter/syntax_highlight_filter.rb
+++ b/lib/banzai/filter/syntax_highlight_filter.rb
@@ -69,7 +69,7 @@ module Banzai
end
def use_rouge?(language)
- %w(math mermaid plantuml).exclude?(language)
+ %w(math mermaid plantuml suggestion).exclude?(language)
end
end
end
diff --git a/lib/banzai/filter/user_reference_filter.rb b/lib/banzai/filter/user_reference_filter.rb
index 11960047e5baedcb9ae53dd86892c56ca494115a..8cda67867a8cbbbc6155d4c8d036cfc027356e0a 100644
--- a/lib/banzai/filter/user_reference_filter.rb
+++ b/lib/banzai/filter/user_reference_filter.rb
@@ -106,7 +106,7 @@ module Banzai
end
def link_class
- reference_class(:project_member)
+ reference_class(:project_member, tooltip: false)
end
def link_to_all(link_content: nil)
diff --git a/lib/banzai/pipeline/gfm_pipeline.rb b/lib/banzai/pipeline/gfm_pipeline.rb
index 96bea7ca9351ba50fa533d34558e1ab9c46aea6c..5f13a6d6cdeff99ddb494a70720c7afc5123649e 100644
--- a/lib/banzai/pipeline/gfm_pipeline.rb
+++ b/lib/banzai/pipeline/gfm_pipeline.rb
@@ -29,6 +29,7 @@ module Banzai
Filter::TableOfContentsFilter,
Filter::AutolinkFilter,
Filter::ExternalLinkFilter,
+ Filter::SuggestionFilter,
*reference_filters,
diff --git a/lib/banzai/pipeline/post_process_pipeline.rb b/lib/banzai/pipeline/post_process_pipeline.rb
index 63a998a2c1f66600bbeecbe94f62249a3173d0a6..7eaad6d75608e4c212c20586ae1bc23e52a53e62 100644
--- a/lib/banzai/pipeline/post_process_pipeline.rb
+++ b/lib/banzai/pipeline/post_process_pipeline.rb
@@ -14,7 +14,8 @@ module Banzai
[
Filter::RedactorFilter,
Filter::RelativeLinkFilter,
- Filter::IssuableStateFilter
+ Filter::IssuableStateFilter,
+ Filter::SuggestionFilter
]
end
diff --git a/lib/banzai/suggestions_parser.rb b/lib/banzai/suggestions_parser.rb
new file mode 100644
index 0000000000000000000000000000000000000000..09f3663502094813b6133560de2b3748e7107d5e
--- /dev/null
+++ b/lib/banzai/suggestions_parser.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module Banzai
+ module SuggestionsParser
+ # Returns the content of each suggestion code block.
+ #
+ def self.parse(text)
+ html = Banzai.render(text, project: nil, no_original_data: true)
+ doc = Nokogiri::HTML(html)
+
+ doc.search('pre.suggestion').map { |node| node.text }
+ end
+ end
+end
diff --git a/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb
new file mode 100644
index 0000000000000000000000000000000000000000..37592d67dd98c2b082b226dba3ea6565d6e2ac76
--- /dev/null
+++ b/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+# rubocop:disable Style/Documentation
+
+module Gitlab
+ module BackgroundMigration
+ class PopulateMergeRequestMetricsWithEventsDataImproved
+ CLOSED_EVENT_ACTION = 3
+ MERGED_EVENT_ACTION = 7
+
+ def perform(min_merge_request_id, max_merge_request_id)
+ insert_metrics_for_range(min_merge_request_id, max_merge_request_id)
+ update_metrics_with_events_data(min_merge_request_id, max_merge_request_id)
+ end
+
+ # Inserts merge_request_metrics records for merge_requests without it for
+ # a given merge request batch.
+ def insert_metrics_for_range(min, max)
+ metrics_not_exists_clause =
+ <<-SQL.strip_heredoc
+ NOT EXISTS (SELECT 1 FROM merge_request_metrics
+ WHERE merge_request_metrics.merge_request_id = merge_requests.id)
+ SQL
+
+ MergeRequest.where(metrics_not_exists_clause).where(id: min..max).each_batch do |batch|
+ select_sql = batch.select(:id, :created_at, :updated_at).to_sql
+
+ execute("INSERT INTO merge_request_metrics (merge_request_id, created_at, updated_at) #{select_sql}")
+ end
+ end
+
+ def update_metrics_with_events_data(min, max)
+ if Gitlab::Database.postgresql?
+ psql_update_metrics_with_events_data(min, max)
+ else
+ mysql_update_metrics_with_events_data(min, max)
+ end
+ end
+
+ def psql_update_metrics_with_events_data(min, max)
+ update_sql = <<-SQL.strip_heredoc
+ UPDATE merge_request_metrics
+ SET (latest_closed_at,
+ latest_closed_by_id) =
+ ( SELECT updated_at,
+ author_id
+ FROM events
+ WHERE target_id = merge_request_id
+ AND target_type = 'MergeRequest'
+ AND action = #{CLOSED_EVENT_ACTION}
+ ORDER BY id DESC
+ LIMIT 1 ),
+ merged_by_id =
+ ( SELECT author_id
+ FROM events
+ WHERE target_id = merge_request_id
+ AND target_type = 'MergeRequest'
+ AND action = #{MERGED_EVENT_ACTION}
+ ORDER BY id DESC
+ LIMIT 1 )
+ WHERE merge_request_id BETWEEN #{min} AND #{max}
+ SQL
+
+ execute(update_sql)
+ end
+
+ def mysql_update_metrics_with_events_data(min, max)
+ closed_updated_at_subquery = mysql_events_select(:updated_at, CLOSED_EVENT_ACTION)
+ closed_author_id_subquery = mysql_events_select(:author_id, CLOSED_EVENT_ACTION)
+ merged_author_id_subquery = mysql_events_select(:author_id, MERGED_EVENT_ACTION)
+
+ update_sql = <<-SQL.strip_heredoc
+ UPDATE merge_request_metrics
+ SET latest_closed_at = (#{closed_updated_at_subquery}),
+ latest_closed_by_id = (#{closed_author_id_subquery}),
+ merged_by_id = (#{merged_author_id_subquery})
+ WHERE merge_request_id BETWEEN #{min} AND #{max}
+ SQL
+
+ execute(update_sql)
+ end
+
+ def mysql_events_select(column, action)
+ <<-SQL.strip_heredoc
+ SELECT #{column} FROM events
+ WHERE target_id = merge_request_id
+ AND target_type = 'MergeRequest'
+ AND action = #{action}
+ ORDER BY id DESC
+ LIMIT 1
+ SQL
+ end
+
+ def execute(sql)
+ @connection ||= ActiveRecord::Base.connection
+ @connection.execute(sql)
+ end
+ end
+ end
+end
diff --git a/lib/gitlab/bitbucket_server_import/importer.rb b/lib/gitlab/bitbucket_server_import/importer.rb
index d4080536d81a76d319e49f07b6830fcfad3a6497..28cfb46e2d4984da6ff611df909c9e44929d7815 100644
--- a/lib/gitlab/bitbucket_server_import/importer.rb
+++ b/lib/gitlab/bitbucket_server_import/importer.rb
@@ -3,8 +3,6 @@
module Gitlab
module BitbucketServerImport
class Importer
- include Gitlab::ShellAdapter
-
attr_reader :recover_missing_commits
attr_reader :project, :project_key, :repository_slug, :client, :errors, :users
attr_accessor :logger
diff --git a/lib/gitlab/ci/build/policy/refs.rb b/lib/gitlab/ci/build/policy/refs.rb
index 109345365360df6a331343f5cb1bb2b7c99514e4..0e9bb5c94bb5206a83e859cb148904beff2e5a3a 100644
--- a/lib/gitlab/ci/build/policy/refs.rb
+++ b/lib/gitlab/ci/build/policy/refs.rb
@@ -32,10 +32,14 @@ module Gitlab
return true if pipeline.source == pattern
return true if pipeline.source&.pluralize == pattern
- if pattern.first == "/" && pattern.last == "/"
- Regexp.new(pattern[1...-1]) =~ pipeline.ref
- else
- pattern == pipeline.ref
+ # patterns can be matched only when branch or tag is used
+ # the pattern matching does not work for merge requests pipelines
+ if pipeline.branch? || pipeline.tag?
+ if pattern.first == "/" && pattern.last == "/"
+ Regexp.new(pattern[1...-1]) =~ pipeline.ref
+ else
+ pattern == pipeline.ref
+ end
end
end
end
diff --git a/lib/gitlab/ci/config/entry/job.rb b/lib/gitlab/ci/config/entry/job.rb
index 085be5da08dfe470057d6404663d947ca45bf52c..22400798e9ed6895169a91fe9f3dccd3c73cc4eb 100644
--- a/lib/gitlab/ci/config/entry/job.rb
+++ b/lib/gitlab/ci/config/entry/job.rb
@@ -16,6 +16,13 @@ module Gitlab
dependencies before_script after_script variables
environment coverage retry parallel extends].freeze
+ DEFAULT_ONLY_POLICY = {
+ refs: %w(branches tags)
+ }.freeze
+
+ DEFAULT_EXCEPT_POLICY = {
+ }.freeze
+
validations do
validates :config, allowed_keys: ALLOWED_KEYS
validates :config, presence: true
@@ -154,8 +161,8 @@ module Gitlab
services: services_value,
stage: stage_value,
cache: cache_value,
- only: only_value,
- except: except_value,
+ only: DEFAULT_ONLY_POLICY.deep_merge(only_value.to_h),
+ except: DEFAULT_EXCEPT_POLICY.deep_merge(except_value.to_h),
variables: variables_defined? ? variables_value : nil,
environment: environment_defined? ? environment_value : nil,
environment_name: environment_defined? ? environment_value[:name] : nil,
diff --git a/lib/gitlab/ci/config/entry/policy.rb b/lib/gitlab/ci/config/entry/policy.rb
index 81e74a639fc928e1376c638a6f41054b8462d659..d98b60f07d6a5d65bb89661e3a9ad036f9bee4e9 100644
--- a/lib/gitlab/ci/config/entry/policy.rb
+++ b/lib/gitlab/ci/config/entry/policy.rb
@@ -8,6 +8,9 @@ module Gitlab
# Base class for OnlyPolicy and ExceptPolicy
#
class Policy < ::Gitlab::Config::Entry::Simplifiable
+ strategy :RefsPolicy, if: -> (config) { config.is_a?(Array) }
+ strategy :ComplexPolicy, if: -> (config) { config.is_a?(Hash) }
+
class RefsPolicy < ::Gitlab::Config::Entry::Node
include ::Gitlab::Config::Entry::Validatable
diff --git a/lib/gitlab/diff/file.rb b/lib/gitlab/diff/file.rb
index 8ba44dff06f413895ae4ea3df9ab66e2120173de..a85c5386d987d37e7c1a0096cc84b7433fc3314d 100644
--- a/lib/gitlab/diff/file.rb
+++ b/lib/gitlab/diff/file.rb
@@ -122,6 +122,16 @@ module Gitlab
old_blob_lazy&.itself
end
+ def new_blob_lines_between(from_line, to_line)
+ return [] unless new_blob
+
+ from_index = from_line - 1
+ to_index = to_line - 1
+
+ new_blob.load_all_data!
+ new_blob.data.lines[from_index..to_index]
+ end
+
def content_sha
new_content_sha || old_content_sha
end
diff --git a/lib/gitlab/diff/line.rb b/lib/gitlab/diff/line.rb
index f0c4977fc50d64b1544d259e2539a2a50af7275c..001748afb4166a85458d978a55981e4f90c9ab9b 100644
--- a/lib/gitlab/diff/line.rb
+++ b/lib/gitlab/diff/line.rb
@@ -73,6 +73,10 @@ module Gitlab
!meta?
end
+ def suggestible?
+ !removed?
+ end
+
def rich_text
@parent_file.try(:highlight_lines!) if @parent_file && !@rich_text
diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb
index 255601382b10e9c75bb753cb86cc86ddb5539616..11021ee06b3da509b8d500edf4bf549e142ef47b 100644
--- a/lib/gitlab/gitaly_client.rb
+++ b/lib/gitlab/gitaly_client.rb
@@ -193,7 +193,7 @@ module Gitlab
feature = feature_stack && feature_stack[0]
metadata['call_site'] = feature.to_s if feature
metadata['gitaly-servers'] = address_metadata(remote_storage) if remote_storage
- metadata['correlation_id'] = Gitlab::CorrelationId.current_id if Gitlab::CorrelationId.current_id
+ metadata['x-gitlab-correlation-id'] = Gitlab::CorrelationId.current_id if Gitlab::CorrelationId.current_id
metadata.merge!(server_feature_flags)
diff --git a/lib/gitlab/import_export/repo_restorer.rb b/lib/gitlab/import_export/repo_restorer.rb
index 921a06b40237ec2f036b18bbae646bafb0c9d5c2..91167a9c4fb3f6a49ba7403906ee0a077dd5cf28 100644
--- a/lib/gitlab/import_export/repo_restorer.rb
+++ b/lib/gitlab/import_export/repo_restorer.rb
@@ -4,7 +4,6 @@ module Gitlab
module ImportExport
class RepoRestorer
include Gitlab::ImportExport::CommandLineUtil
- include Gitlab::ShellAdapter
def initialize(project:, shared:, path_to_bundle:)
@project = project
diff --git a/lib/gitlab/url_blocker.rb b/lib/gitlab/url_blocker.rb
index b8040f73ceeaf0479e85d75ab10c2793884b034d..44c71f8431d1518ba6dd263f44148bb8a76896fa 100644
--- a/lib/gitlab/url_blocker.rb
+++ b/lib/gitlab/url_blocker.rb
@@ -8,7 +8,7 @@ module Gitlab
BlockedUrlError = Class.new(StandardError)
class << self
- def validate!(url, allow_localhost: false, allow_local_network: true, enforce_user: false, ports: [], protocols: [])
+ def validate!(url, ports: [], protocols: [], allow_localhost: false, allow_local_network: true, ascii_only: false, enforce_user: false)
return true if url.nil?
# Param url can be a string, URI or Addressable::URI
@@ -22,6 +22,7 @@ module Gitlab
validate_port!(port, ports) if ports.any?
validate_user!(uri.user) if enforce_user
validate_hostname!(uri.hostname)
+ validate_unicode_restriction!(uri) if ascii_only
begin
addrs_info = Addrinfo.getaddrinfo(uri.hostname, port, nil, :STREAM).map do |addr|
@@ -91,6 +92,12 @@ module Gitlab
raise BlockedUrlError, "Hostname or IP address invalid"
end
+ def validate_unicode_restriction!(uri)
+ return if uri.to_s.ascii_only?
+
+ raise BlockedUrlError, "URI must be ascii only #{uri.to_s.dump}"
+ end
+
def validate_localhost!(addrs_info)
local_ips = ["::", "0.0.0.0"]
local_ips.concat(Socket.ip_address_list.map(&:ip_address))
diff --git a/lib/gitlab/usage_data.rb b/lib/gitlab/usage_data.rb
index 008e9cd1d24ad09b72b6a32a5c765a3cb92a2b98..083c620267ab9729d40e68b1df474beda06716fe 100644
--- a/lib/gitlab/usage_data.rb
+++ b/lib/gitlab/usage_data.rb
@@ -85,6 +85,8 @@ module Gitlab
releases: count(Release),
remote_mirrors: count(RemoteMirror),
snippets: count(Snippet),
+ suggestions: count(Suggestion),
+ todos: count(Todo),
uploads: count(Upload),
web_hooks: count(WebHook)
}.merge(services_usage).merge(approximate_counts)
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 4f55eac44ab091d6a18336814062efc11a5e450e..48a8bb391f576d6c88a44dd5ba592aa4dc829374 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -97,6 +97,9 @@ msgstr[1] ""
msgid "%{actionText} & %{openOrClose} %{noteable}"
msgstr ""
+msgid "%{bio} at %{organization}"
+msgstr ""
+
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
@@ -402,9 +405,6 @@ msgstr ""
msgid "Admin Overview"
msgstr ""
-msgid "Admin area"
-msgstr ""
-
msgid "AdminArea| You are about to permanently delete the user %{username}. Issues, merge requests, and groups linked to them will be transferred to a system-wide \"Ghost-user\". To avoid data loss, consider using the %{strong_start}block user%{strong_end} feature instead. Once you %{strong_start}Delete user%{strong_end}, it cannot be undone or recovered."
msgstr ""
@@ -657,6 +657,12 @@ msgstr ""
msgid "Applications"
msgstr ""
+msgid "Applied"
+msgstr ""
+
+msgid "Apply suggestion"
+msgstr ""
+
msgid "Apr"
msgstr ""
@@ -846,6 +852,9 @@ msgstr ""
msgid "Available specific runners"
msgstr ""
+msgid "Avatar for %{assigneeName}"
+msgstr ""
+
msgid "Avatar will be removed. Are you sure?"
msgstr ""
@@ -2917,6 +2926,9 @@ msgstr ""
msgid "Expiration date"
msgstr ""
+msgid "Expired %{expiredOn}"
+msgstr ""
+
msgid "Expires in %{expires_at}"
msgstr ""
@@ -3588,6 +3600,9 @@ msgstr ""
msgid "Input your repository URL"
msgstr ""
+msgid "Insert suggestion"
+msgstr ""
+
msgid "Install GitLab Runner"
msgstr ""
@@ -6068,6 +6083,9 @@ msgstr ""
msgid "Something went wrong when toggling the button"
msgstr ""
+msgid "Something went wrong while applying the suggestion. Please try again."
+msgstr ""
+
msgid "Something went wrong while closing the %{issuable}. Please try again later"
msgstr ""
@@ -6275,6 +6293,12 @@ msgstr ""
msgid "Started"
msgstr ""
+msgid "Started %{startsIn}"
+msgstr ""
+
+msgid "Starts %{startsIn}"
+msgstr ""
+
msgid "Starts at (UTC)"
msgstr ""
@@ -6326,6 +6350,9 @@ msgstr ""
msgid "Subscribed"
msgstr ""
+msgid "Suggested change"
+msgstr ""
+
msgid "Switch branch/tag"
msgstr ""
diff --git a/package.json b/package.json
index ac4d5174610132e0054d06378b9dbaaf3992c835..d546a32cba5fef3126c84537786ad5c8aac9d393 100644
--- a/package.json
+++ b/package.json
@@ -25,8 +25,9 @@
"@babel/plugin-syntax-dynamic-import": "^7.0.0",
"@babel/plugin-syntax-import-meta": "^7.0.0",
"@babel/preset-env": "^7.1.0",
- "@gitlab/svgs": "^1.40.0",
- "@gitlab/ui": "^1.14.0",
+ "@gitlab/csslab": "^1.8.0",
+ "@gitlab/svgs": "^1.42.0",
+ "@gitlab/ui": "^1.15.0",
"apollo-boost": "^0.1.20",
"apollo-client": "^2.4.5",
"autosize": "^4.0.0",
@@ -57,6 +58,7 @@
"diff": "^3.4.0",
"document-register-element": "1.3.0",
"dropzone": "^4.2.0",
+ "echarts": "^4.2.0-rc.2",
"emoji-unicode-version": "^0.2.1",
"exports-loader": "^0.7.0",
"file-loader": "^2.0.0",
diff --git a/qa/qa/page/merge_request/new.rb b/qa/qa/page/merge_request/new.rb
index 1f8f1fbca8e739d970f6067806f42b653574d0ab..20d9c3363672b43be42c2d2c3db034672388ba80 100644
--- a/qa/qa/page/merge_request/new.rb
+++ b/qa/qa/page/merge_request/new.rb
@@ -26,6 +26,10 @@ module QA
element :issuable_label
end
+ view 'app/views/shared/issuable/form/_metadata_merge_request_assignee.html.haml' do
+ element :assign_to_me_link
+ end
+
def create_merge_request
click_element :issuable_create_button
end
@@ -50,6 +54,10 @@ module QA
click_link label.title
end
+
+ def assign_to_me
+ click_element :assign_to_me_link
+ end
end
end
end
diff --git a/qa/qa/page/merge_request/show.rb b/qa/qa/page/merge_request/show.rb
index 2fd30e15ffb06e4bbde12a17211885c2014e2813..869dc0b9d21ff884951540effd0690c738fb7e8a 100644
--- a/qa/qa/page/merge_request/show.rb
+++ b/qa/qa/page/merge_request/show.rb
@@ -52,6 +52,7 @@ module QA
end
view 'app/views/shared/issuable/_sidebar.html.haml' do
+ element :assignee_block
element :labels_block
end
@@ -100,6 +101,12 @@ module QA
end
end
+ def has_assignee?(username)
+ page.within(element_selector_css(:assignee_block)) do
+ has_text?(username)
+ end
+ end
+
def has_label?(label)
page.within(element_selector_css(:labels_block)) do
element = find('span', text: label)
diff --git a/qa/qa/resource/merge_request.rb b/qa/qa/resource/merge_request.rb
index 77afb3cfcbac82b11c667502e0f7795d74bd1879..7150098a00a2c2e0e898a456a5f22c3f7208c08a 100644
--- a/qa/qa/resource/merge_request.rb
+++ b/qa/qa/resource/merge_request.rb
@@ -58,11 +58,15 @@ module QA
populate(:target, :source)
project.visit!
- Page::Project::Show.perform(&:new_merge_request)
+ Page::Project::Show.perform do |project|
+ project.wait_for_push
+ project.new_merge_request
+ end
Page::MergeRequest::New.perform do |page|
page.fill_title(@title)
page.fill_description(@description)
page.choose_milestone(@milestone) if @milestone
+ page.assign_to_me if @assignee == 'me'
labels.each do |label|
page.select_label(label)
end
diff --git a/qa/qa/runtime/browser.rb b/qa/qa/runtime/browser.rb
index 7fd2ba2552774367544ac82e9d347c77770d9c07..b706d6565d2263e691c9d154db2fba173764d66c 100644
--- a/qa/qa/runtime/browser.rb
+++ b/qa/qa/runtime/browser.rb
@@ -70,6 +70,13 @@ module QA
options.add_argument("disable-gpu")
end
+ # Use the same profile on QA runs if CHROME_REUSE_PROFILE is true.
+ # Useful to speed up local QA.
+ if QA::Runtime::Env.reuse_chrome_profile?
+ qa_profile_dir = ::File.expand_path('../../tmp/qa-profile', __dir__)
+ options.add_argument("user-data-dir=#{qa_profile_dir}")
+ end
+
# Disable /dev/shm use in CI. See https://gitlab.com/gitlab-org/gitlab-ee/issues/4252
options.add_argument("disable-dev-shm-usage") if QA::Runtime::Env.running_in_ci?
diff --git a/qa/qa/runtime/env.rb b/qa/qa/runtime/env.rb
index 3bc2b44ccd8d8632ee0d95f2bba14ea9d0e44a5a..dae5aa3f79402d7a3f9de79e27a08679c7509af6 100644
--- a/qa/qa/runtime/env.rb
+++ b/qa/qa/runtime/env.rb
@@ -30,6 +30,11 @@ module QA
enabled?(ENV['CHROME_HEADLESS'])
end
+ # set to 'true' to have Chrome use a fixed profile directory
+ def reuse_chrome_profile?
+ enabled?(ENV['CHROME_REUSE_PROFILE'], default: false)
+ end
+
def accept_insecure_certs?
enabled?(ENV['ACCEPT_INSECURE_CERTS'])
end
diff --git a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb
index d33947f41da185f4bcfa9ff20fa7a63ffe358f7b..6ddd7dde2cfb99cad1ce24e8e7e04367d3ca82b6 100644
--- a/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb
+++ b/qa/qa/specs/features/browser_ui/3_create/merge_request/create_merge_request_spec.rb
@@ -4,6 +4,8 @@ module QA
context 'Create' do
describe 'Merge request creation' do
it 'user creates a new merge request' do
+ gitlab_account_username = "@#{Runtime::User.username}"
+
Runtime::Browser.visit(:gitlab, Page::Main::Login)
Page::Main::Login.act { sign_in_using_credentials }
@@ -27,6 +29,7 @@ module QA
merge_request.description = 'Great feature with milestone'
merge_request.project = current_project
merge_request.milestone = current_milestone
+ merge_request.assignee = 'me'
merge_request.labels.push(new_label)
end
@@ -34,6 +37,7 @@ module QA
expect(merge_request).to have_content('This is a merge request with a milestone')
expect(merge_request).to have_content('Great feature with milestone')
expect(merge_request).to have_content(/Opened [\w\s]+ ago/)
+ expect(merge_request).to have_assignee(gitlab_account_username)
expect(merge_request).to have_label(new_label.title)
end
diff --git a/spec/controllers/projects/commits_controller_spec.rb b/spec/controllers/projects/commits_controller_spec.rb
index 5c72dab698c67ccbcf388ea1f1ae048e6a659619..805136506367a6616e45bc7039f691064e1f475f 100644
--- a/spec/controllers/projects/commits_controller_spec.rb
+++ b/spec/controllers/projects/commits_controller_spec.rb
@@ -53,6 +53,12 @@ describe Projects::CommitsController do
it { is_expected.to respond_with(:not_found) }
end
+
+ context "branch with invalid format, valid file" do
+ let(:id) { 'branch with space/README.md' }
+
+ it { is_expected.to respond_with(:not_found) }
+ end
end
context "when the ref name ends in .atom" do
@@ -94,6 +100,30 @@ describe Projects::CommitsController do
end
end
end
+
+ describe "GET /commits/:id/signatures" do
+ render_views
+
+ before do
+ get(:signatures,
+ namespace_id: project.namespace,
+ project_id: project,
+ id: id,
+ format: :json)
+ end
+
+ context "valid branch" do
+ let(:id) { 'master' }
+
+ it { is_expected.to respond_with(:success) }
+ end
+
+ context "invalid branch format" do
+ let(:id) { 'some branch' }
+
+ it { is_expected.to respond_with(:not_found) }
+ end
+ end
end
context 'token authentication' do
diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb
index 02930edbf72a1d584481ab5c91ce63be0307b986..6240ab6d8671da72ef8ae8b6788a7124fe7df2b1 100644
--- a/spec/controllers/projects/issues_controller_spec.rb
+++ b/spec/controllers/projects/issues_controller_spec.rb
@@ -42,6 +42,8 @@ describe Projects::IssuesController do
it_behaves_like "issuables list meta-data", :issue
+ it_behaves_like 'set sort order from user preference'
+
it "returns index" do
get :index, namespace_id: project.namespace, project_id: project
diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb
index 7f15da859e541a7c29ff7a79424276cc6ade6627..a37a831ddbb91b0707cc84432e136ed54d9226cc 100644
--- a/spec/controllers/projects/merge_requests_controller_spec.rb
+++ b/spec/controllers/projects/merge_requests_controller_spec.rb
@@ -160,6 +160,8 @@ describe Projects::MergeRequestsController do
it_behaves_like "issuables list meta-data", :merge_request
+ it_behaves_like 'set sort order from user preference'
+
context 'when page param' do
let(:last_page) { project.merge_requests.page().total_pages }
let!(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project) }
diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb
index e8584846b56543b747dcc44e592fb1f0c78615f2..7c505ee0d43b2385ead3801ffa5ec70de934ead9 100644
--- a/spec/db/schema_spec.rb
+++ b/spec/db/schema_spec.rb
@@ -54,7 +54,8 @@ describe 'Database schema' do
user_agent_details: %w[subject_id],
users: %w[color_scheme_id created_by_id theme_id],
users_star_projects: %w[user_id],
- web_hooks: %w[service_id]
+ web_hooks: %w[service_id],
+ suggestions: %w[commit_id]
}.with_indifferent_access.freeze
context 'for table' do
diff --git a/spec/factories/suggestions.rb b/spec/factories/suggestions.rb
new file mode 100644
index 0000000000000000000000000000000000000000..307523cc0617cbfae0aa51e27b34136a49b7ee04
--- /dev/null
+++ b/spec/factories/suggestions.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+ factory :suggestion do
+ relative_order 0
+ association :note, factory: :diff_note_on_merge_request
+ from_content " vars = {\n"
+ to_content " vars = [\n"
+
+ trait :unappliable do
+ from_content "foo"
+ to_content "foo"
+ end
+
+ trait :applied do
+ applied true
+ commit_id { RepoHelpers.sample_commit.id }
+ end
+ end
+end
diff --git a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
index 328f96e6ed70435a3d30dfb33b2998d34ac5fa98..ba4806821f916d508226868a751f2eeb41c4b196 100644
--- a/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
+++ b/spec/features/merge_request/user_resolves_diff_notes_and_discussions_resolve_spec.rb
@@ -361,8 +361,14 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
end
end
- it 'shows jump to next discussion button' do
- expect(page.all('.discussion-reply-holder', count: 2)).to all(have_selector('.discussion-next-btn'))
+ it 'shows jump to next discussion button except on last discussion' do
+ wait_for_requests
+
+ all_discussion_replies = page.all('.discussion-reply-holder')
+
+ expect(all_discussion_replies.count).to eq(2)
+ expect(all_discussion_replies.first.all('.discussion-next-btn').count).to eq(1)
+ expect(all_discussion_replies.last.all('.discussion-next-btn').count).to eq(0)
end
it 'displays next discussion even if hidden' do
@@ -380,7 +386,13 @@ describe 'Merge request > User resolves diff notes and discussions', :js do
page.find('.discussion-next-btn').click
end
- expect(find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
+ page.all('.note-discussion').first do
+ expect(page.find('.discussion-with-resolve-btn')).to have_selector('.btn', text: 'Resolve discussion')
+ end
+
+ page.all('.note-discussion').last do
+ expect(page.find('.discussion-with-resolve-btn')).not.to have_selector('.btn', text: 'Resolve discussion')
+ end
end
end
diff --git a/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..c19e299097e3b2a1c847162eb9b32cf4614611f5
--- /dev/null
+++ b/spec/features/merge_request/user_suggests_changes_on_diff_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'User comments on a diff', :js do
+ include MergeRequestDiffHelpers
+ include RepoHelpers
+
+ let(:project) { create(:project, :repository) }
+ let(:merge_request) do
+ create(:merge_request_with_diffs, source_project: project, target_project: project, source_branch: 'merge-test')
+ end
+ let(:user) { create(:user) }
+
+ before do
+ project.add_maintainer(user)
+ sign_in(user)
+
+ visit(diffs_project_merge_request_path(project, merge_request))
+ end
+
+ context 'single suggestion note' do
+ it 'suggestion is presented' do
+ click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
+
+ page.within('.js-discussion-note-form') do
+ fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.diff-discussions') do
+ expect(page).to have_button('Apply suggestion')
+ expect(page).to have_content('Suggested change')
+ expect(page).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
+ expect(page).to have_content('# change to a comment')
+ end
+ end
+
+ it 'suggestion is appliable' do
+ click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
+
+ page.within('.js-discussion-note-form') do
+ fill_in('note_note', with: "```suggestion\n# change to a comment\n```")
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.diff-discussions') do
+ expect(page).not_to have_content('Applied')
+
+ click_button('Apply suggestion')
+ wait_for_requests
+
+ expect(page).to have_content('Applied')
+ end
+ end
+ end
+
+ context 'multiple suggestions in a single note' do
+ it 'suggestions are presented' do
+ click_diff_line(find("[id='#{sample_compare.changes[1][:line_code]}']"))
+
+ page.within('.js-discussion-note-form') do
+ fill_in('note_note', with: "```suggestion\n# change to a comment\n```\n```suggestion\n# or that\n```")
+ click_button('Comment')
+ end
+
+ wait_for_requests
+
+ page.within('.diff-discussions') do
+ suggestion_1 = page.all(:css, '.md-suggestion-diff')[0]
+ suggestion_2 = page.all(:css, '.md-suggestion-diff')[1]
+
+ expect(suggestion_1).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
+ expect(suggestion_1).to have_content('# change to a comment')
+
+ expect(suggestion_2).to have_content(' url = https://github.com/gitlabhq/gitlab-shell.git')
+ expect(suggestion_2).to have_content('# or that')
+ end
+ end
+ end
+end
diff --git a/spec/features/projects/files/user_browses_files_spec.rb b/spec/features/projects/files/user_browses_files_spec.rb
index f3cf3a282e5b7af2cd932846e9793775ffb88708..66268355345d56bab5c2ad4613c1fcd5c0bedbdf 100644
--- a/spec/features/projects/files/user_browses_files_spec.rb
+++ b/spec/features/projects/files/user_browses_files_spec.rb
@@ -11,6 +11,7 @@ describe "User browses files" do
let(:user) { project.owner }
before do
+ stub_feature_flags(csslab: false)
sign_in(user)
end
diff --git a/spec/fixtures/api/schemas/entities/diff_line.json b/spec/fixtures/api/schemas/entities/diff_line.json
index 66e8b443e1b418cdf2bf57423e86c6037c6fee1f..9657004cd2db391f79ffcf19b9be12d336e96633 100644
--- a/spec/fixtures/api/schemas/entities/diff_line.json
+++ b/spec/fixtures/api/schemas/entities/diff_line.json
@@ -8,7 +8,8 @@
"new_line": { "type": ["integer", "null"] },
"text": { "type": ["string"] },
"rich_text": { "type": ["string"] },
- "meta_data": { "type": ["object", "null"] }
+ "meta_data": { "type": ["object", "null"] },
+ "can_receive_suggestion": { "type": "boolean" }
},
"additionalProperties": false
}
diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json
index 35971d564d5777e3406799a310c782bd1db4d3f8..193ab6821a54d3c0bd608b33bbee4a567f6c7a03 100644
--- a/spec/fixtures/api/schemas/entities/merge_request_widget.json
+++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json
@@ -119,7 +119,8 @@
"can_push_to_source_branch": { "type": "boolean" },
"rebase_path": { "type": ["string", "null"] },
"squash": { "type": "boolean" },
- "test_reports_path": { "type": ["string", "null"] }
+ "test_reports_path": { "type": ["string", "null"] },
+ "can_receive_suggestion": { "type": "boolean" }
},
"additionalProperties": false
}
diff --git a/spec/helpers/sorting_helper_spec.rb b/spec/helpers/sorting_helper_spec.rb
index cba0d93e1440b6e1b2c3c14eec53ea57a962eddb..f405268d198116bbb7cf21602e3f4ef39886dcb0 100644
--- a/spec/helpers/sorting_helper_spec.rb
+++ b/spec/helpers/sorting_helper_spec.rb
@@ -21,7 +21,11 @@ describe SortingHelper do
describe '#issuable_sort_direction_button' do
before do
- allow(self).to receive(:request).and_return(double(path: 'http://test.com', query_parameters: {}))
+ allow(self).to receive(:request).and_return(double(path: 'http://test.com', query_parameters: { label_name: 'test_label' }))
+ end
+
+ it 'keeps label filter param' do
+ expect(issuable_sort_direction_button('created_date')).to include('label_name=test_label')
end
it 'returns icon with sort-highest when sort is created_date' do
diff --git a/spec/javascripts/api_spec.js b/spec/javascripts/api_spec.js
index 46f722148310de4e8fe6f67b5a205e471ed8856c..9d55c615450b5663343eb207a92b9bd72b355859 100644
--- a/spec/javascripts/api_spec.js
+++ b/spec/javascripts/api_spec.js
@@ -333,6 +333,40 @@ describe('Api', () => {
});
});
+ describe('user', () => {
+ it('fetches single user', done => {
+ const userId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`;
+ mock.onGet(expectedUrl).reply(200, {
+ name: 'testuser',
+ });
+
+ Api.user(userId)
+ .then(({ data }) => {
+ expect(data.name).toBe('testuser');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('user status', () => {
+ it('fetches single user status', done => {
+ const userId = '123456';
+ const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`;
+ mock.onGet(expectedUrl).reply(200, {
+ message: 'testmessage',
+ });
+
+ Api.userStatus(userId)
+ .then(({ data }) => {
+ expect(data.message).toBe('testmessage');
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
describe('commitPipelines', () => {
it('fetches pipelines for a given commit', done => {
const projectId = 'example/foobar';
diff --git a/spec/javascripts/boards/components/issue_due_date_spec.js b/spec/javascripts/boards/components/issue_due_date_spec.js
index 9e49330c052d0c40850031924ea14b72ef3513d1..054cf8c5b7d078d13d2ff3e04ac21c323e44dcfe 100644
--- a/spec/javascripts/boards/components/issue_due_date_spec.js
+++ b/spec/javascripts/boards/components/issue_due_date_spec.js
@@ -49,10 +49,11 @@ describe('Issue Due Date component', () => {
it('should render month and day for other dates', () => {
date.setDate(date.getDate() + 17);
vm = createComponent(date);
+ const today = new Date();
+ const isDueInCurrentYear = today.getFullYear() === date.getFullYear();
+ const format = isDueInCurrentYear ? 'mmm d' : 'mmm d, yyyy';
- expect(vm.$el.querySelector('time').textContent.trim()).toEqual(
- dateFormat(date, 'mmm d', true),
- );
+ expect(vm.$el.querySelector('time').textContent.trim()).toEqual(dateFormat(date, format, true));
});
it('should contain the correct `.text-danger` css class for overdue issue', () => {
diff --git a/spec/javascripts/boards/mock_data.js b/spec/javascripts/boards/mock_data.js
index c28e41ec175081e439eac82d93f60230f31e3b90..14fff9223f45985b2abe5b96a76b4c892f733488 100644
--- a/spec/javascripts/boards/mock_data.js
+++ b/spec/javascripts/boards/mock_data.js
@@ -1,5 +1,11 @@
import BoardService from '~/boards/services/board_service';
+export const boardObj = {
+ id: 1,
+ name: 'test',
+ milestone_id: null,
+};
+
export const listObj = {
id: 300,
position: 0,
@@ -40,6 +46,12 @@ export const BoardsMockData = {
},
],
},
+ '/test/issue-boards/milestones.json': [
+ {
+ id: 1,
+ title: 'test',
+ },
+ ],
},
POST: {
'/test/-/boards/1/lists': listObj,
@@ -70,3 +82,60 @@ export const mockBoardService = (opts = {}) => {
boardId,
});
};
+
+export const mockAssigneesList = [
+ {
+ id: 2,
+ name: 'Terrell Graham',
+ username: 'monserrate.gleichner',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/598fd02741ac58b88854a99d16704309?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/monserrate.gleichner',
+ path: '/monserrate.gleichner',
+ },
+ {
+ id: 12,
+ name: 'Susy Johnson',
+ username: 'tana_harvey',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e021a7b0f3e4ae53b5068d487e68c031?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/tana_harvey',
+ path: '/tana_harvey',
+ },
+ {
+ id: 20,
+ name: 'Conchita Eichmann',
+ username: 'juliana_gulgowski',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/c43c506cb6fd7b37017d3b54b94aa937?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/juliana_gulgowski',
+ path: '/juliana_gulgowski',
+ },
+ {
+ id: 6,
+ name: 'Bryce Turcotte',
+ username: 'melynda',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/cc2518f2c6f19f8fac49e1a5ee092a9b?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/melynda',
+ path: '/melynda',
+ },
+ {
+ id: 1,
+ name: 'Administrator',
+ username: 'root',
+ state: 'active',
+ avatar_url: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ web_url: 'http://127.0.0.1:3001/root',
+ path: '/root',
+ },
+];
+
+export const mockMilestone = {
+ id: 1,
+ state: 'active',
+ title: 'Milestone title',
+ description: 'Harum corporis aut consequatur quae dolorem error sequi quia.',
+ start_date: '2018-01-01',
+ due_date: '2019-12-31',
+};
diff --git a/spec/javascripts/diffs/components/diff_content_spec.js b/spec/javascripts/diffs/components/diff_content_spec.js
index c25f6167163ccb47da32f9cfdc7ef5d9353a2c5c..d688560a260b8f67ff311abc18357f2057a8b64f 100644
--- a/spec/javascripts/diffs/components/diff_content_spec.js
+++ b/spec/javascripts/diffs/components/diff_content_spec.js
@@ -17,6 +17,7 @@ describe('DiffContent', () => {
current_user: {
can_create_note: false,
},
+ preview_note_path: 'path/to/preview',
};
vm = mountComponentWithStore(Component, {
diff --git a/spec/javascripts/diffs/mock_data/diff_discussions.js b/spec/javascripts/diffs/mock_data/diff_discussions.js
index 44313caba297867b3be9734a8345a313c60c6ae1..c1e9f791925455cf744353eb71d1db35e5429cc5 100644
--- a/spec/javascripts/diffs/mock_data/diff_discussions.js
+++ b/spec/javascripts/diffs/mock_data/diff_discussions.js
@@ -487,8 +487,19 @@ export default {
],
},
diff_discussion: true,
- truncated_diff_lines:
- '\n| \n \n | \n\n1\n | \n - Bad dates\n | \n \n\n| \n \n | \n\n2\n | \n\n | \n \n',
+ truncated_diff_lines: [
+ {
+ text: 'line',
+ rich_text:
+ '\n| \n \n | \n\n1\n | \n - Bad dates\n | \n \n\n| \n \n | \n\n2\n | \n\n | \n \n',
+ can_receive_suggestion: true,
+ line_code: '6f209374f7e565f771b95720abf46024c41d1885_1_1',
+ type: 'new',
+ old_line: null,
+ new_line: 1,
+ meta_data: null,
+ },
+ ],
};
export const imageDiffDiscussions = [
diff --git a/spec/javascripts/diffs/store/actions_spec.js b/spec/javascripts/diffs/store/actions_spec.js
index 55ce19927e095dd46637a9c2d19543c246e99951..033b5e86dbe81ac94c060f3e99d3f3f8033777ed 100644
--- a/spec/javascripts/diffs/store/actions_spec.js
+++ b/spec/javascripts/diffs/store/actions_spec.js
@@ -26,7 +26,9 @@ import actions, {
toggleTreeOpen,
scrollToFile,
toggleShowTreeList,
+ renderFileForDiscussionId,
} from '~/diffs/store/actions';
+import eventHub from '~/notes/event_hub';
import * as types from '~/diffs/store/mutation_types';
import axios from '~/lib/utils/axios_utils';
import mockDiffFile from 'spec/diffs/mock_data/diff_file';
@@ -735,4 +737,63 @@ describe('DiffsStoreActions', () => {
expect(localStorage.setItem).toHaveBeenCalledWith('mr_tree_show', true);
});
});
+
+ describe('renderFileForDiscussionId', () => {
+ const rootState = {
+ notes: {
+ discussions: [
+ {
+ id: '123',
+ diff_file: {
+ file_hash: 'HASH',
+ },
+ },
+ {
+ id: '456',
+ diff_file: {
+ file_hash: 'HASH',
+ },
+ },
+ ],
+ },
+ };
+ let commit;
+ let $emit;
+ let scrollToElement;
+ const state = ({ collapsed, renderIt }) => ({
+ diffFiles: [
+ {
+ file_hash: 'HASH',
+ collapsed,
+ renderIt,
+ },
+ ],
+ });
+
+ beforeEach(() => {
+ commit = jasmine.createSpy('commit');
+ scrollToElement = spyOnDependency(actions, 'scrollToElement').and.stub();
+ $emit = spyOn(eventHub, '$emit');
+ });
+
+ it('renders and expands file for the given discussion id', () => {
+ const localState = state({ collapsed: true, renderIt: false });
+
+ renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
+
+ expect(commit).toHaveBeenCalledWith('RENDER_FILE', localState.diffFiles[0]);
+ expect($emit).toHaveBeenCalledTimes(1);
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+ });
+
+ it('jumps to discussion on already rendered and expanded file', () => {
+ const localState = state({ collapsed: false, renderIt: true });
+
+ renderFileForDiscussionId({ rootState, state: localState, commit }, '123');
+
+ expect(commit).not.toHaveBeenCalled();
+ expect($emit).toHaveBeenCalledTimes(1);
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
});
diff --git a/spec/javascripts/diffs/store/mutations_spec.js b/spec/javascripts/diffs/store/mutations_spec.js
index 23e8761bc5508a4b9a5c15de9d9abd36da105114..f3449bec6ec79dd3a67e529c04833dbf5e2a31b6 100644
--- a/spec/javascripts/diffs/store/mutations_spec.js
+++ b/spec/javascripts/diffs/store/mutations_spec.js
@@ -277,6 +277,87 @@ describe('DiffsStoreMutations', () => {
expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1);
});
+ it('updates existing discussion', () => {
+ const diffPosition = {
+ base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
+ head_sha: 'b921914f9a834ac47e6fd9420f78db0f83559130',
+ new_line: null,
+ new_path: '500-lines-4.txt',
+ old_line: 5,
+ old_path: '500-lines-4.txt',
+ start_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
+ };
+
+ const state = {
+ latestDiff: true,
+ diffFiles: [
+ {
+ file_hash: 'ABC',
+ parallel_diff_lines: [
+ {
+ left: {
+ line_code: 'ABC_1',
+ discussions: [],
+ },
+ right: {
+ line_code: 'ABC_1',
+ discussions: [],
+ },
+ },
+ ],
+ highlighted_diff_lines: [
+ {
+ line_code: 'ABC_1',
+ discussions: [],
+ },
+ ],
+ },
+ ],
+ };
+ const discussion = {
+ id: 1,
+ line_code: 'ABC_1',
+ diff_discussion: true,
+ resolvable: true,
+ original_position: diffPosition,
+ position: diffPosition,
+ diff_file: {
+ file_hash: state.diffFiles[0].file_hash,
+ },
+ };
+
+ const diffPositionByLineCode = {
+ ABC_1: diffPosition,
+ };
+
+ mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
+ discussion,
+ diffPositionByLineCode,
+ });
+
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions.length).toEqual(1);
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].id).toEqual(1);
+ expect(state.diffFiles[0].parallel_diff_lines[0].right.discussions).toEqual([]);
+
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions.length).toEqual(1);
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toEqual(1);
+
+ mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, {
+ discussion: {
+ ...discussion,
+ resolved: true,
+ notes: ['test'],
+ },
+ diffPositionByLineCode,
+ });
+
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].notes.length).toBe(1);
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].notes.length).toBe(1);
+
+ expect(state.diffFiles[0].parallel_diff_lines[0].left.discussions[0].resolved).toBe(true);
+ expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].resolved).toBe(true);
+ });
+
it('should add legacy discussions to the given line', () => {
const diffPosition = {
base_sha: 'ed13df29948c41ba367caa757ab3ec4892509910',
@@ -356,10 +437,12 @@ describe('DiffsStoreMutations', () => {
{
id: 1,
line_code: 'ABC_1',
+ notes: [],
},
{
id: 2,
line_code: 'ABC_1',
+ notes: [],
},
],
},
@@ -376,10 +459,12 @@ describe('DiffsStoreMutations', () => {
{
id: 1,
line_code: 'ABC_1',
+ notes: [],
},
{
id: 2,
line_code: 'ABC_1',
+ notes: [],
},
],
},
diff --git a/spec/javascripts/image_diff/helpers/badge_helper_spec.js b/spec/javascripts/image_diff/helpers/badge_helper_spec.js
index 8ea05203d009351534a295089834591be5f5d2c5..b3001d45e3c4ab4693c59e880de283aa9503929a 100644
--- a/spec/javascripts/image_diff/helpers/badge_helper_spec.js
+++ b/spec/javascripts/image_diff/helpers/badge_helper_spec.js
@@ -61,6 +61,10 @@ describe('badge helper', () => {
expect(buttonEl).toBeDefined();
});
+ it('should add badge classes', () => {
+ expect(buttonEl.className).toContain('badge badge-pill');
+ });
+
it('should set the badge text', () => {
expect(buttonEl.innerText).toEqual(badgeText);
});
diff --git a/spec/javascripts/lib/utils/users_cache_spec.js b/spec/javascripts/lib/utils/users_cache_spec.js
index 6adc19bdd51375444922144039c681590399225e..acb5e024acd7dcbe9c3a20f3f87e9181066e16b3 100644
--- a/spec/javascripts/lib/utils/users_cache_spec.js
+++ b/spec/javascripts/lib/utils/users_cache_spec.js
@@ -3,7 +3,9 @@ import UsersCache from '~/lib/utils/users_cache';
describe('UsersCache', () => {
const dummyUsername = 'win';
- const dummyUser = 'has a farm';
+ const dummyUserId = 123;
+ const dummyUser = { name: 'has a farm', username: 'farmer' };
+ const dummyUserStatus = 'my status';
beforeEach(() => {
UsersCache.internalStorage = {};
@@ -135,4 +137,110 @@ describe('UsersCache', () => {
.catch(done.fail);
});
});
+
+ describe('retrieveById', () => {
+ let apiSpy;
+
+ beforeEach(() => {
+ spyOn(Api, 'user').and.callFake(id => apiSpy(id));
+ });
+
+ it('stores and returns data from API call if cache is empty', done => {
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.resolve({
+ data: dummyUser,
+ });
+ };
+
+ UsersCache.retrieveById(dummyUserId)
+ .then(user => {
+ expect(user).toBe(dummyUser);
+ expect(UsersCache.internalStorage[dummyUserId]).toBe(dummyUser);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns undefined if Ajax call fails and cache is empty', done => {
+ const dummyError = new Error('server exploded');
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.reject(dummyError);
+ };
+
+ UsersCache.retrieveById(dummyUserId)
+ .then(user => fail(`Received unexpected user: ${JSON.stringify(user)}`))
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('makes no Ajax call if matching data exists', done => {
+ UsersCache.internalStorage[dummyUserId] = dummyUser;
+ apiSpy = () => fail(new Error('expected no Ajax call!'));
+
+ UsersCache.retrieveById(dummyUserId)
+ .then(user => {
+ expect(user).toBe(dummyUser);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
+
+ describe('retrieveStatusById', () => {
+ let apiSpy;
+
+ beforeEach(() => {
+ spyOn(Api, 'userStatus').and.callFake(id => apiSpy(id));
+ });
+
+ it('stores and returns data from API call if cache is empty', done => {
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.resolve({
+ data: dummyUserStatus,
+ });
+ };
+
+ UsersCache.retrieveStatusById(dummyUserId)
+ .then(userStatus => {
+ expect(userStatus).toBe(dummyUserStatus);
+ expect(UsersCache.internalStorage[dummyUserId].status).toBe(dummyUserStatus);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('returns undefined if Ajax call fails and cache is empty', done => {
+ const dummyError = new Error('server exploded');
+ apiSpy = id => {
+ expect(id).toBe(dummyUserId);
+ return Promise.reject(dummyError);
+ };
+
+ UsersCache.retrieveStatusById(dummyUserId)
+ .then(userStatus => fail(`Received unexpected user: ${JSON.stringify(userStatus)}`))
+ .catch(error => {
+ expect(error).toBe(dummyError);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+
+ it('makes no Ajax call if matching data exists', done => {
+ UsersCache.internalStorage[dummyUserId] = { status: dummyUserStatus };
+ apiSpy = () => fail(new Error('expected no Ajax call!'));
+
+ UsersCache.retrieveStatusById(dummyUserId)
+ .then(userStatus => {
+ expect(userStatus).toBe(dummyUserStatus);
+ })
+ .then(done)
+ .catch(done.fail);
+ });
+ });
});
diff --git a/spec/javascripts/notes/components/note_edited_text_spec.js b/spec/javascripts/notes/components/note_edited_text_spec.js
index e0b991c32ec3a4581ecfcea6504d95331ec4b2ce..e4c8d954d50546bda1f42124f4edaf51fc018e72 100644
--- a/spec/javascripts/notes/components/note_edited_text_spec.js
+++ b/spec/javascripts/notes/components/note_edited_text_spec.js
@@ -39,7 +39,7 @@ describe('note_edited_text', () => {
});
it('should render provided user information', () => {
- const authorLink = vm.$el.querySelector('.js-vue-author');
+ const authorLink = vm.$el.querySelector('.js-user-link');
expect(authorLink.getAttribute('href')).toEqual(props.editedBy.path);
expect(authorLink.textContent.trim()).toEqual(props.editedBy.name);
diff --git a/spec/javascripts/notes/components/note_header_spec.js b/spec/javascripts/notes/components/note_header_spec.js
index 379780f43a00ac38e161b32121316730ab3e89a7..6d1a7ef370f4b96d146208c4a37a4a45f38b1c6a 100644
--- a/spec/javascripts/notes/components/note_header_spec.js
+++ b/spec/javascripts/notes/components/note_header_spec.js
@@ -42,6 +42,9 @@ describe('note_header component', () => {
it('should render user information', () => {
expect(vm.$el.querySelector('.note-header-author-name').textContent.trim()).toEqual('Root');
expect(vm.$el.querySelector('.note-header-info a').getAttribute('href')).toEqual('/root');
+ expect(vm.$el.querySelector('.note-header-info a').dataset.userId).toEqual('1');
+ expect(vm.$el.querySelector('.note-header-info a').dataset.username).toEqual('root');
+ expect(vm.$el.querySelector('.note-header-info a').classList).toContain('js-user-link');
});
it('should render timestamp link', () => {
diff --git a/spec/javascripts/notes/components/noteable_discussion_spec.js b/spec/javascripts/notes/components/noteable_discussion_spec.js
index ab9c52346d6fffb72e5c7a05cfa06bc9984af120..e4d29a3860c6da32616fa095d50c3c82c1f15a7b 100644
--- a/spec/javascripts/notes/components/noteable_discussion_spec.js
+++ b/spec/javascripts/notes/components/noteable_discussion_spec.js
@@ -83,6 +83,7 @@ describe('noteable_discussion component', () => {
it('expands next unresolved discussion', done => {
const discussion2 = getJSONFixture(discussionWithTwoUnresolvedNotes)[0];
discussion2.resolved = false;
+ discussion2.active = true;
discussion2.id = 'next'; // prepare this for being identified as next one (to be jumped to)
vm.$store.dispatch('setInitialNotes', [discussionMock, discussion2]);
window.mrTabs.currentAction = 'show';
diff --git a/spec/javascripts/notes/mock_data.js b/spec/javascripts/notes/mock_data.js
index ad0e793b9157e199a7d9e7efd4f9c410375b092c..7ae45c40c28320c2d6abeeeb7f1b81ecf9d3edf1 100644
--- a/spec/javascripts/notes/mock_data.js
+++ b/spec/javascripts/notes/mock_data.js
@@ -305,6 +305,7 @@ export const discussionMock = {
],
individual_note: false,
resolvable: true,
+ active: true,
};
export const loggedOutnoteableData = {
@@ -1173,6 +1174,7 @@ export const discussion1 = {
id: 'abc1',
resolvable: true,
resolved: false,
+ active: true,
diff_file: {
file_path: 'about.md',
},
@@ -1209,6 +1211,7 @@ export const discussion2 = {
id: 'abc2',
resolvable: true,
resolved: false,
+ active: true,
diff_file: {
file_path: 'README.md',
},
@@ -1226,6 +1229,7 @@ export const discussion2 = {
export const discussion3 = {
id: 'abc3',
resolvable: true,
+ active: true,
resolved: false,
diff_file: {
file_path: 'README.md',
diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js
index 24c2b3e6570621c1064b3b9f4b3fd80efce3b848..2e3cd5e8f363a030d1b6dddc380e1aacbe8e57b4 100644
--- a/spec/javascripts/notes/stores/actions_spec.js
+++ b/spec/javascripts/notes/stores/actions_spec.js
@@ -124,7 +124,7 @@ describe('Actions Notes Store', () => {
{ discussionId: discussionMock.id },
{ notes: [discussionMock] },
[{ type: 'EXPAND_DISCUSSION', payload: { discussionId: discussionMock.id } }],
- [],
+ [{ type: 'diffs/renderFileForDiscussionId', payload: discussionMock.id }],
done,
);
});
diff --git a/spec/javascripts/registry/components/app_spec.js b/spec/javascripts/registry/components/app_spec.js
index 92ff960277a5f8f305ae1ca07f6015d10e0ad3ee..67118ac03a5b5a4367e0e4483c0b64743c293c4c 100644
--- a/spec/javascripts/registry/components/app_spec.js
+++ b/spec/javascripts/registry/components/app_spec.js
@@ -1,37 +1,30 @@
-import _ from 'underscore';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import registry from '~/registry/components/app.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { TEST_HOST } from 'spec/test_constants';
import { reposServerResponse } from '../mock_data';
describe('Registry List', () => {
+ const Component = Vue.extend(registry);
let vm;
- let Component;
+ let mock;
beforeEach(() => {
- Component = Vue.extend(registry);
+ mock = new MockAdapter(axios);
});
afterEach(() => {
+ mock.restore();
vm.$destroy();
});
describe('with data', () => {
- const interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(reposServerResponse), {
- status: 200,
- }),
- );
- };
-
beforeEach(() => {
- Vue.http.interceptors.push(interceptor);
- vm = mountComponent(Component, { endpoint: 'foo' });
- });
+ mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, reposServerResponse);
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` });
});
it('should render a list of repos', done => {
@@ -64,9 +57,9 @@ describe('Registry List', () => {
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
Vue.nextTick(() => {
- expect(vm.$el.querySelector('.js-toggle-repo i').className).toEqual(
- 'fa fa-chevron-up',
- );
+ expect(
+ vm.$el.querySelector('.js-toggle-repo use').getAttribute('xlink:href'),
+ ).toContain('angle-up');
done();
});
});
@@ -76,21 +69,10 @@ describe('Registry List', () => {
});
describe('without data', () => {
- const interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify([]), {
- status: 200,
- }),
- );
- };
-
beforeEach(() => {
- Vue.http.interceptors.push(interceptor);
- vm = mountComponent(Component, { endpoint: 'foo' });
- });
+ mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` });
});
it('should render empty message', done => {
@@ -109,21 +91,10 @@ describe('Registry List', () => {
});
describe('while loading data', () => {
- const interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(reposServerResponse), {
- status: 200,
- }),
- );
- };
-
beforeEach(() => {
- Vue.http.interceptors.push(interceptor);
- vm = mountComponent(Component, { endpoint: 'foo' });
- });
+ mock.onGet(`${TEST_HOST}/foo`).replyOnce(200, []);
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
+ vm = mountComponent(Component, { endpoint: `${TEST_HOST}/foo` });
});
it('should render a loading spinner', done => {
diff --git a/spec/javascripts/registry/components/collapsible_container_spec.js b/spec/javascripts/registry/components/collapsible_container_spec.js
index 256a242f7849c3ec0ad3e2d0223acb9262e428d0..a3f7ff76dc7a78da84f8c91a71e5788b7964351b 100644
--- a/spec/javascripts/registry/components/collapsible_container_spec.js
+++ b/spec/javascripts/registry/components/collapsible_container_spec.js
@@ -1,14 +1,24 @@
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import Vue from 'vue';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import store from '~/registry/stores';
-import { repoPropsData } from '../mock_data';
+import * as types from '~/registry/stores/mutation_types';
+
+import { repoPropsData, registryServerResponse, reposServerResponse } from '../mock_data';
describe('collapsible registry container', () => {
let vm;
- let Component;
+ let mock;
+ const Component = Vue.extend(collapsibleComponent);
beforeEach(() => {
- Component = Vue.extend(collapsibleComponent);
+ mock = new MockAdapter(axios);
+
+ mock.onGet(repoPropsData.tagsPath).replyOnce(200, registryServerResponse, {});
+
+ store.commit(types.SET_REPOS_LIST, reposServerResponse);
+
vm = new Component({
store,
propsData: {
@@ -18,24 +28,23 @@ describe('collapsible registry container', () => {
});
afterEach(() => {
+ mock.restore();
vm.$destroy();
});
describe('toggle', () => {
it('should be closed by default', () => {
expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
- expect(vm.$el.querySelector('.container-image-head i').className).toEqual(
- 'fa fa-chevron-right',
- );
+ expect(vm.iconName).toEqual('angle-right');
});
it('should be open when user clicks on closed repo', done => {
vm.$el.querySelector('.js-toggle-repo').click();
+
Vue.nextTick(() => {
- expect(vm.$el.querySelector('.container-image-tags')).toBeDefined();
- expect(vm.$el.querySelector('.container-image-head i').className).toEqual(
- 'fa fa-chevron-up',
- );
+ expect(vm.$el.querySelector('.container-image-tags')).not.toBeNull();
+ expect(vm.iconName).toEqual('angle-up');
+
done();
});
});
@@ -45,12 +54,12 @@ describe('collapsible registry container', () => {
Vue.nextTick(() => {
vm.$el.querySelector('.js-toggle-repo').click();
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
- expect(vm.$el.querySelector('.container-image-head i').className).toEqual(
- 'fa fa-chevron-right',
- );
- done();
+ setTimeout(() => {
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.container-image-tags')).toBe(null);
+ expect(vm.iconName).toEqual('angle-right');
+ done();
+ });
});
});
});
@@ -58,7 +67,7 @@ describe('collapsible registry container', () => {
describe('delete repo', () => {
it('should be possible to delete a repo', () => {
- expect(vm.$el.querySelector('.js-remove-repo')).toBeDefined();
+ expect(vm.$el.querySelector('.js-remove-repo')).not.toBeNull();
});
});
});
diff --git a/spec/javascripts/registry/stores/actions_spec.js b/spec/javascripts/registry/stores/actions_spec.js
index bc4c444655a518d0bcd4380553d5ac23f619bd23..c9aa82dba905936ee537f8cc81831cf591c4acfd 100644
--- a/spec/javascripts/registry/stores/actions_spec.js
+++ b/spec/javascripts/registry/stores/actions_spec.js
@@ -1,42 +1,34 @@
-import Vue from 'vue';
-import VueResource from 'vue-resource';
-import _ from 'underscore';
+import MockAdapter from 'axios-mock-adapter';
+import axios from '~/lib/utils/axios_utils';
import * as actions from '~/registry/stores/actions';
import * as types from '~/registry/stores/mutation_types';
+import state from '~/registry/stores/state';
+import { TEST_HOST } from 'spec/test_constants';
import testAction from '../../helpers/vuex_action_helper';
import {
- defaultState,
reposServerResponse,
registryServerResponse,
parsedReposServerResponse,
} from '../mock_data';
-Vue.use(VueResource);
-
describe('Actions Registry Store', () => {
- let interceptor;
let mockedState;
+ let mock;
beforeEach(() => {
- mockedState = defaultState;
+ mockedState = state();
+ mockedState.endpoint = `${TEST_HOST}/endpoint.json`;
+ mock = new MockAdapter(axios);
});
- describe('server requests', () => {
- afterEach(() => {
- Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
- });
+ afterEach(() => {
+ mock.restore();
+ });
+ describe('server requests', () => {
describe('fetchRepos', () => {
beforeEach(() => {
- interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(reposServerResponse), {
- status: 200,
- }),
- );
- };
-
- Vue.http.interceptors.push(interceptor);
+ mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});
it('should set receveived repos', done => {
@@ -56,23 +48,15 @@ describe('Actions Registry Store', () => {
});
describe('fetchList', () => {
+ let repo;
beforeEach(() => {
- interceptor = (request, next) => {
- next(
- request.respondWith(JSON.stringify(registryServerResponse), {
- status: 200,
- }),
- );
- };
+ mockedState.repos = parsedReposServerResponse;
+ [, repo] = mockedState.repos;
- Vue.http.interceptors.push(interceptor);
+ mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});
it('should set received list', done => {
- mockedState.repos = parsedReposServerResponse;
-
- const repo = mockedState.repos[1];
-
testAction(
actions.fetchList,
{ repo },
diff --git a/spec/javascripts/user_popovers_spec.js b/spec/javascripts/user_popovers_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..6cf8dd81b36ec2baf4b6c37be4846ee446dac0f5
--- /dev/null
+++ b/spec/javascripts/user_popovers_spec.js
@@ -0,0 +1,66 @@
+import initUserPopovers from '~/user_popovers';
+import UsersCache from '~/lib/utils/users_cache';
+
+describe('User Popovers', () => {
+ const selector = '.js-user-link';
+
+ const dummyUser = { name: 'root' };
+ const dummyUserStatus = { message: 'active' };
+
+ const triggerEvent = (eventName, el) => {
+ const event = document.createEvent('MouseEvents');
+ event.initMouseEvent(eventName, true, true, window);
+
+ el.dispatchEvent(event);
+ };
+
+ beforeEach(() => {
+ setFixtures(`
+
+ Root
+
+ `);
+
+ const usersCacheSpy = () => Promise.resolve(dummyUser);
+ spyOn(UsersCache, 'retrieveById').and.callFake(userId => usersCacheSpy(userId));
+
+ const userStatusCacheSpy = () => Promise.resolve(dummyUserStatus);
+ spyOn(UsersCache, 'retrieveStatusById').and.callFake(userId => userStatusCacheSpy(userId));
+
+ initUserPopovers(document.querySelectorAll('.js-user-link'));
+ });
+
+ it('Should Show+Hide Popover on mouseenter and mouseleave', done => {
+ triggerEvent('mouseenter', document.querySelector(selector));
+
+ setTimeout(() => {
+ const shownPopover = document.querySelector('.popover');
+
+ expect(shownPopover).not.toBeNull();
+
+ expect(shownPopover.innerHTML).toContain(dummyUser.name);
+ expect(UsersCache.retrieveById).toHaveBeenCalledWith('1');
+
+ triggerEvent('mouseleave', document.querySelector(selector));
+
+ setTimeout(() => {
+ // After Mouse leave it should be hidden now
+ expect(document.querySelector('.popover')).toBeNull();
+ done();
+ });
+ }, 210); // We need to wait until the 200ms mouseover delay is over, only then the popover will be visible
+ });
+
+ it('Should Not show a popover on short mouse over', done => {
+ triggerEvent('mouseenter', document.querySelector(selector));
+
+ setTimeout(() => {
+ expect(document.querySelector('.popover')).toBeNull();
+ expect(UsersCache.retrieveById).not.toHaveBeenCalledWith('1');
+
+ triggerEvent('mouseleave', document.querySelector(selector));
+
+ done();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..9eac75fac967787d80b7561448a1f46fa6062859
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/issue/issue_assignees_spec.js
@@ -0,0 +1,114 @@
+import Vue from 'vue';
+
+import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { mockAssigneesList } from 'spec/boards/mock_data';
+
+const createComponent = (assignees = mockAssigneesList, cssClass = '') => {
+ const Component = Vue.extend(IssueAssignees);
+
+ return mountComponent(Component, {
+ assignees,
+ cssClass,
+ });
+};
+
+describe('IssueAssigneesComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('data', () => {
+ it('returns default data props', () => {
+ expect(vm.maxVisibleAssignees).toBe(2);
+ expect(vm.maxAssigneeAvatars).toBe(3);
+ expect(vm.maxAssignees).toBe(99);
+ });
+ });
+
+ describe('computed', () => {
+ describe('countOverLimit', () => {
+ it('should return difference between assignees count and maxVisibleAssignees', () => {
+ expect(vm.countOverLimit).toBe(mockAssigneesList.length - vm.maxVisibleAssignees);
+ });
+ });
+
+ describe('assigneesToShow', () => {
+ it('should return assignees containing only 2 items when count more than maxAssigneeAvatars', () => {
+ expect(vm.assigneesToShow.length).toBe(2);
+ });
+
+ it('should return all assignees as it is when count less than maxAssigneeAvatars', () => {
+ vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
+
+ expect(vm.assigneesToShow.length).toBe(3);
+ });
+ });
+
+ describe('assigneesCounterTooltip', () => {
+ it('should return string containing count of remaining assignees when count more than maxAssigneeAvatars', () => {
+ expect(vm.assigneesCounterTooltip).toBe('3 more assignees');
+ });
+ });
+
+ describe('shouldRenderAssigneesCounter', () => {
+ it('should return `false` when assignees count less than maxAssigneeAvatars', () => {
+ vm.assignees = mockAssigneesList.slice(0, 3); // Set 3 Assignees
+
+ expect(vm.shouldRenderAssigneesCounter).toBe(false);
+ });
+
+ it('should return `true` when assignees count more than maxAssigneeAvatars', () => {
+ expect(vm.shouldRenderAssigneesCounter).toBe(true);
+ });
+ });
+
+ describe('assigneeCounterLabel', () => {
+ it('should return count of additional assignees total assignees count more than maxAssigneeAvatars', () => {
+ expect(vm.assigneeCounterLabel).toBe('+3');
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('avatarUrlTitle', () => {
+ it('returns string containing alt text for assignee avatar', () => {
+ expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component root element with class `issue-assignees`', () => {
+ expect(vm.$el.classList.contains('issue-assignees')).toBe(true);
+ });
+
+ it('renders assignee avatars', () => {
+ expect(vm.$el.querySelectorAll('.user-avatar-link').length).toBe(2);
+ });
+
+ it('renders assignee tooltips', () => {
+ const tooltipText = vm.$el
+ .querySelectorAll('.user-avatar-link')[0]
+ .querySelector('.js-assignee-tooltip').innerText;
+
+ expect(tooltipText).toContain('Assignee');
+ expect(tooltipText).toContain('Terrell Graham');
+ expect(tooltipText).toContain('@monserrate.gleichner');
+ });
+
+ it('renders additional assignees count', () => {
+ const avatarCounterEl = vm.$el.querySelector('.avatar-counter');
+
+ expect(avatarCounterEl.innerText.trim()).toBe('+3');
+ expect(avatarCounterEl.getAttribute('data-original-title')).toBe('3 more assignees');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8fca26373269fb766aa81ef86a52db22adea5ebc
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/issue/issue_milestone_spec.js
@@ -0,0 +1,234 @@
+import Vue from 'vue';
+
+import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue';
+
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+import { mockMilestone } from 'spec/boards/mock_data';
+
+const createComponent = (milestone = mockMilestone) => {
+ const Component = Vue.extend(IssueMilestone);
+
+ return mountComponent(Component, {
+ milestone,
+ });
+};
+
+describe('IssueMilestoneComponent', () => {
+ let vm;
+
+ beforeEach(() => {
+ vm = createComponent();
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('computed', () => {
+ describe('isMilestoneStarted', () => {
+ it('should return `false` when milestoneStart prop is not defined', done => {
+ const vmStartUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStartUndefined.isMilestoneStarted).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStartUndefined.$destroy();
+ });
+
+ it('should return `true` when milestone start date is past current date', done => {
+ const vmStarted = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '1990-07-22',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStarted.isMilestoneStarted).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStarted.$destroy();
+ });
+ });
+
+ describe('isMilestonePastDue', () => {
+ it('should return `false` when milestoneDue prop is not defined', done => {
+ const vmDueUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDueUndefined.isMilestonePastDue).toBe(false);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDueUndefined.$destroy();
+ });
+
+ it('should return `true` when milestone due is past current date', done => {
+ const vmPastDue = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: '1990-07-22',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmPastDue.isMilestonePastDue).toBe(true);
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmPastDue.$destroy();
+ });
+ });
+
+ describe('milestoneDatesAbsolute', () => {
+ it('returns string containing absolute milestone due date', () => {
+ expect(vm.milestoneDatesAbsolute).toBe('(December 31, 2019)');
+ });
+
+ it('returns string containing absolute milestone start date when due date is not present', done => {
+ const vmDueUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDueUndefined.milestoneDatesAbsolute).toBe('(January 1, 2018)');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDueUndefined.$destroy();
+ });
+
+ it('returns empty string when both milestone start and due dates are not present', done => {
+ const vmDatesUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '',
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDatesUndefined.milestoneDatesAbsolute).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDatesUndefined.$destroy();
+ });
+ });
+
+ describe('milestoneDatesHuman', () => {
+ it('returns string containing milestone due date when date is yet to be due', done => {
+ const vmFuture = createComponent(
+ Object.assign({}, mockMilestone, {
+ due_date: `${new Date().getFullYear() + 10}-01-01`,
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmFuture.milestoneDatesHuman).toContain('years remaining');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmFuture.$destroy();
+ });
+
+ it('returns string containing milestone start date when date has already started and due date is not present', done => {
+ const vmStarted = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '1990-07-22',
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStarted.milestoneDatesHuman).toContain('Started');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStarted.$destroy();
+ });
+
+ it('returns string containing milestone start date when date is yet to start and due date is not present', done => {
+ const vmStarts = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: `${new Date().getFullYear() + 10}-01-01`,
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmStarts.milestoneDatesHuman).toContain('Starts');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmStarts.$destroy();
+ });
+
+ it('returns empty string when milestone start and due dates are not present', done => {
+ const vmDatesUndefined = createComponent(
+ Object.assign({}, mockMilestone, {
+ start_date: '',
+ due_date: '',
+ }),
+ );
+
+ Vue.nextTick()
+ .then(() => {
+ expect(vmDatesUndefined.milestoneDatesHuman).toBe('');
+ })
+ .then(done)
+ .catch(done.fail);
+
+ vmDatesUndefined.$destroy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component root element with class `issue-milestone-details`', () => {
+ expect(vm.$el.classList.contains('issue-milestone-details')).toBe(true);
+ });
+
+ it('renders milestone icon', () => {
+ expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('clock');
+ });
+
+ it('renders milestone title', () => {
+ expect(vm.$el.querySelector('.milestone-title').innerText.trim()).toBe(mockMilestone.title);
+ });
+
+ it('renders milestone tooltip', () => {
+ expect(vm.$el.querySelector('.js-item-milestone').innerText.trim()).toContain(
+ mockMilestone.title,
+ );
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/header_spec.js b/spec/javascripts/vue_shared/components/markdown/header_spec.js
index 59613faa49ffa9eadcb320646653c728548e8c82..e733a95288ec18f15eb534d78f078b8e40425514 100644
--- a/spec/javascripts/vue_shared/components/markdown/header_spec.js
+++ b/spec/javascripts/vue_shared/components/markdown/header_spec.js
@@ -28,6 +28,7 @@ describe('Markdown field header component', () => {
'Add a numbered list',
'Add a task list',
'Add a table',
+ 'Insert suggestion',
'Go full screen',
];
const elements = vm.$el.querySelectorAll('.toolbar-btn');
@@ -93,4 +94,18 @@ describe('Markdown field header component', () => {
'| header | header |\n| ------ | ------ |\n| cell | cell |\n| cell | cell |',
);
});
+
+ it('renders suggestion template', () => {
+ vm.lineContent = 'Some content';
+
+ expect(vm.mdSuggestion).toEqual('```suggestion\n{text}\n```');
+ });
+
+ it('does not render suggestion button if `canSuggest` is set to false', () => {
+ vm.canSuggest = false;
+
+ Vue.nextTick(() => {
+ expect(vm.$el.querySelector('.qa-suggestion-btn')).toBe(null);
+ });
+ });
});
diff --git a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_header_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..8187b3204b1fbbaffe340fb6b4eb8a41eca35078
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -0,0 +1,69 @@
+import Vue from 'vue';
+import SuggestionDiffHeaderComponent from '~/vue_shared/components/markdown/suggestion_diff_header.vue';
+
+const MOCK_DATA = {
+ canApply: true,
+ isApplied: false,
+ helpPagePath: 'path_to_docs',
+};
+
+describe('Suggestion Diff component', () => {
+ let vm;
+
+ function createComponent(propsData) {
+ const Component = Vue.extend(SuggestionDiffHeaderComponent);
+
+ return new Component({
+ propsData,
+ }).$mount();
+ }
+
+ beforeEach(done => {
+ vm = createComponent(MOCK_DATA);
+ Vue.nextTick(done);
+ });
+
+ describe('init', () => {
+ it('renders a suggestion header', () => {
+ const header = vm.$el.querySelector('.qa-suggestion-diff-header');
+
+ expect(header).not.toBeNull();
+ expect(header.innerHTML.includes('Suggested change')).toBe(true);
+ });
+
+ it('renders an apply button', () => {
+ const applyBtn = vm.$el.querySelector('.qa-apply-btn');
+
+ expect(applyBtn).not.toBeNull();
+ expect(applyBtn.innerHTML.includes('Apply suggestion')).toBe(true);
+ });
+
+ it('does not render an apply button if `canApply` is set to false', () => {
+ const props = Object.assign(MOCK_DATA, { canApply: false });
+
+ vm = createComponent(props);
+
+ expect(vm.$el.querySelector('.qa-apply-btn')).toBeNull();
+ });
+ });
+
+ describe('applySuggestion', () => {
+ it('emits when the apply button is clicked', () => {
+ const props = Object.assign(MOCK_DATA, { canApply: true });
+
+ vm = createComponent(props);
+ spyOn(vm, '$emit');
+ vm.applySuggestion();
+
+ expect(vm.$emit).toHaveBeenCalled();
+ });
+
+ it('does not emit when the canApply is set to false', () => {
+ spyOn(vm, '$emit');
+ vm.canApply = false;
+ vm.applySuggestion();
+
+ expect(vm.$emit).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..d4ed8f2f7a43924e2651ecf2bd15ab10c7c51700
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/suggestion_diff_spec.js
@@ -0,0 +1,79 @@
+import Vue from 'vue';
+import SuggestionDiffComponent from '~/vue_shared/components/markdown/suggestion_diff.vue';
+
+const MOCK_DATA = {
+ canApply: true,
+ newLines: [
+ { content: 'Line 1\n', lineNumber: 1 },
+ { content: 'Line 2\n', lineNumber: 2 },
+ { content: 'Line 3\n', lineNumber: 3 },
+ ],
+ fromLine: 1,
+ fromContent: 'Old content',
+ suggestion: {
+ id: 1,
+ },
+ helpPagePath: 'path_to_docs',
+};
+
+describe('Suggestion Diff component', () => {
+ let vm;
+
+ beforeEach(done => {
+ const Component = Vue.extend(SuggestionDiffComponent);
+
+ vm = new Component({
+ propsData: MOCK_DATA,
+ }).$mount();
+
+ Vue.nextTick(done);
+ });
+
+ describe('init', () => {
+ it('renders a suggestion header', () => {
+ expect(vm.$el.querySelector('.qa-suggestion-diff-header')).not.toBeNull();
+ });
+
+ it('renders a diff table', () => {
+ expect(vm.$el.querySelector('table.md-suggestion-diff')).not.toBeNull();
+ });
+
+ it('renders the oldLineNumber', () => {
+ const fromLine = vm.$el.querySelector('.qa-old-diff-line-number').innerHTML;
+
+ expect(parseInt(fromLine, 10)).toBe(vm.fromLine);
+ });
+
+ it('renders the oldLineContent', () => {
+ const fromContent = vm.$el.querySelector('.line_content.old').innerHTML;
+
+ expect(fromContent.includes(vm.fromContent)).toBe(true);
+ });
+
+ it('renders the contents of newLines', () => {
+ const newLines = vm.$el.querySelectorAll('.line_holder.new');
+
+ newLines.forEach((line, i) => {
+ expect(newLines[i].innerHTML.includes(vm.newLines[i].content)).toBe(true);
+ });
+ });
+
+ it('renders a line number for each line', () => {
+ const newLineNumbers = vm.$el.querySelectorAll('.qa-new-diff-line-number');
+
+ newLineNumbers.forEach((line, i) => {
+ expect(newLineNumbers[i].innerHTML.includes(vm.newLines[i].lineNumber)).toBe(true);
+ });
+ });
+ });
+
+ describe('applySuggestion', () => {
+ it('emits apply event when applySuggestion is called', () => {
+ const callback = () => {};
+ spyOn(vm, '$emit');
+ vm.applySuggestion(callback);
+
+ expect(vm.$emit).toHaveBeenCalledWith('apply', { suggestionId: vm.suggestion.id, callback });
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js b/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ab1b747c360ed157f1f7c5aebe9bad4587877d13
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/markdown/suggestions_spec.js
@@ -0,0 +1,125 @@
+import Vue from 'vue';
+import SuggestionsComponent from '~/vue_shared/components/markdown/suggestions.vue';
+
+const MOCK_DATA = {
+ fromLine: 1,
+ fromContent: 'Old content',
+ suggestions: [],
+ noteHtml: `
+
+
+
+ `,
+ isApplied: false,
+ helpPagePath: 'path_to_docs',
+};
+
+const generateLine = content => {
+ const line = document.createElement('div');
+ line.className = 'line';
+ line.innerHTML = content;
+
+ return line;
+};
+
+const generateMockLines = () => {
+ const line1 = generateLine('Line 1');
+ const line2 = generateLine('Line 2');
+ const line3 = generateLine('Line 3');
+ const container = document.createElement('div');
+
+ container.appendChild(line1);
+ container.appendChild(line2);
+ container.appendChild(line3);
+
+ return container;
+};
+
+describe('Suggestion component', () => {
+ let vm;
+ let extractedLines;
+ let diffTable;
+
+ beforeEach(done => {
+ const Component = Vue.extend(SuggestionsComponent);
+
+ vm = new Component({
+ propsData: MOCK_DATA,
+ }).$mount();
+
+ extractedLines = vm.extractNewLines(generateMockLines());
+ diffTable = vm.generateDiff(extractedLines).$mount().$el;
+
+ spyOn(vm, 'renderSuggestions');
+ vm.renderSuggestions();
+ Vue.nextTick(done);
+ });
+
+ describe('mounted', () => {
+ it('renders a flash container', () => {
+ expect(vm.$el.querySelector('.flash-container')).not.toBeNull();
+ });
+
+ it('renders a container for suggestions', () => {
+ expect(vm.$refs.container).not.toBeNull();
+ });
+
+ it('renders suggestions', () => {
+ expect(vm.renderSuggestions).toHaveBeenCalled();
+ expect(vm.$el.innerHTML.includes('Suggestion 1')).toBe(true);
+ expect(vm.$el.innerHTML.includes('Suggestion 2')).toBe(true);
+ });
+ });
+
+ describe('extractNewLines', () => {
+ it('extracts suggested lines', () => {
+ const expectedReturn = [
+ { content: 'Line 1\n', lineNumber: 1 },
+ { content: 'Line 2\n', lineNumber: 2 },
+ { content: 'Line 3\n', lineNumber: 3 },
+ ];
+
+ expect(vm.extractNewLines(generateMockLines())).toEqual(expectedReturn);
+ });
+
+ it('increments line number for each extracted line', () => {
+ expect(extractedLines[0].lineNumber).toEqual(1);
+ expect(extractedLines[1].lineNumber).toEqual(2);
+ expect(extractedLines[2].lineNumber).toEqual(3);
+ });
+
+ it('returns empty array if no lines are found', () => {
+ const el = document.createElement('div');
+
+ expect(vm.extractNewLines(el)).toEqual([]);
+ });
+ });
+
+ describe('generateDiff', () => {
+ it('generates a diff table', () => {
+ expect(diffTable.querySelector('.md-suggestion-diff')).not.toBeNull();
+ });
+
+ it('generates a diff table that contains contents of `oldLineContent`', () => {
+ expect(diffTable.innerHTML.includes(vm.fromContent)).toBe(true);
+ });
+
+ it('generates a diff table that contains contents the suggested lines', () => {
+ extractedLines.forEach((line, i) => {
+ expect(diffTable.innerHTML.includes(extractedLines[i].content)).toBe(true);
+ });
+ });
+
+ it('generates a diff table with the correct line number for each suggested line', () => {
+ const lines = diffTable.getElementsByClassName('qa-new-diff-line-number');
+
+ expect([...lines][0].innerHTML).toBe('1');
+ expect([...lines][1].innerHTML).toBe('2');
+ expect([...lines][2].innerHTML).toBe('3');
+ });
+ });
+});
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
index 5c4aa7cf844a51facff737ca531bb10e3eb7f867..c5045afc5b0d3306cba2fca9b7eea24275b97f74 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_image_spec.js
@@ -2,6 +2,7 @@ import Vue from 'vue';
import { placeholderImage } from '~/lazy_loader';
import userAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import mountComponent, { mountComponentWithSlots } from 'spec/helpers/vue_mount_component_helper';
+import defaultAvatarUrl from '~/../images/no_avatar.png';
const DEFAULT_PROPS = {
size: 99,
@@ -76,6 +77,18 @@ describe('User Avatar Image Component', function() {
});
});
+ describe('Initialization without src', function() {
+ beforeEach(function() {
+ vm = mountComponent(UserAvatarImage);
+ });
+
+ it('should have default avatar image', function() {
+ const imageElement = vm.$el.querySelector('img');
+
+ expect(imageElement.getAttribute('src')).toBe(defaultAvatarUrl);
+ });
+ });
+
describe('dynamic tooltip content', () => {
const props = DEFAULT_PROPS;
const slots = {
diff --git a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
index 0151ad23ba24daa4ea4304070d1be6d0d4fd63ca..f2472fd377c23c2e661401a1ae091fae574c4be1 100644
--- a/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
+++ b/spec/javascripts/vue_shared/components/user_avatar/user_avatar_link_spec.js
@@ -74,9 +74,7 @@ describe('User Avatar Link Component', function() {
describe('username', function() {
it('should not render avatar image tooltip', function() {
- expect(
- this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip').innerText.trim(),
- ).toEqual('');
+ expect(this.userAvatarLink.$el.querySelector('.js-user-avatar-image-toolip')).toBeNull();
});
it('should render username prop in ', function() {
diff --git a/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..1578b0f81f96d466d83163d8b0fc2b89b12b34c5
--- /dev/null
+++ b/spec/javascripts/vue_shared/components/user_popover/user_popover_spec.js
@@ -0,0 +1,133 @@
+import Vue from 'vue';
+import userPopover from '~/vue_shared/components/user_popover/user_popover.vue';
+import mountComponent from 'spec/helpers/vue_mount_component_helper';
+
+const DEFAULT_PROPS = {
+ loaded: true,
+ user: {
+ username: 'root',
+ name: 'Administrator',
+ location: 'Vienna',
+ bio: null,
+ organization: null,
+ status: null,
+ },
+};
+
+const UserPopover = Vue.extend(userPopover);
+
+describe('User Popover Component', () => {
+ let vm;
+
+ beforeEach(() => {
+ setFixtures(`
+
+ Root
+
+ `);
+ });
+
+ afterEach(() => {
+ vm.$destroy();
+ });
+
+ describe('Empty', () => {
+ beforeEach(() => {
+ vm = mountComponent(UserPopover, {
+ target: document.querySelector('.js-user-link'),
+ user: {
+ name: null,
+ username: null,
+ location: null,
+ bio: null,
+ organization: null,
+ status: null,
+ },
+ });
+ });
+
+ it('should return skeleton loaders', () => {
+ expect(vm.$el.querySelectorAll('.animation-container').length).toBe(4);
+ });
+ });
+
+ describe('basic data', () => {
+ it('should show basic fields', () => {
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.name);
+ expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.username);
+ expect(vm.$el.textContent).toContain(DEFAULT_PROPS.user.location);
+ });
+ });
+
+ describe('job data', () => {
+ it('should show only bio if no organization is available', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.bio = 'Engineer';
+
+ vm = mountComponent(UserPopover, {
+ ...testProps,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('Engineer');
+ });
+
+ it('should show only organization if no bio is available', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.organization = 'GitLab';
+
+ vm = mountComponent(UserPopover, {
+ ...testProps,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('GitLab');
+ });
+
+ it('should have full job line when we have bio and organization', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.bio = 'Engineer';
+ testProps.user.organization = 'GitLab';
+
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('Engineer at GitLab');
+ });
+ });
+
+ describe('status data', () => {
+ it('should show only message', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.status = { message: 'Hello World' };
+
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ });
+
+ expect(vm.$el.textContent).toContain('Hello World');
+ });
+
+ it('should show message and emoji', () => {
+ const testProps = Object.assign({}, DEFAULT_PROPS);
+ testProps.user.status = { emoji: 'basketball_player', message: 'Hello World' };
+
+ vm = mountComponent(UserPopover, {
+ ...DEFAULT_PROPS,
+ target: document.querySelector('.js-user-link'),
+ status: { emoji: 'basketball_player', message: 'Hello World' },
+ });
+
+ expect(vm.$el.textContent).toContain('Hello World');
+ expect(vm.$el.innerHTML).toContain('foo\n" }
+ let(:default_context) do
+ { suggestions_filter_enabled: true }
+ end
+
+ it 'includes `js-render-suggestion` class' do
+ doc = filter(input, default_context)
+ result = doc.css('code').first
+
+ expect(result[:class]).to include('js-render-suggestion')
+ end
+
+ it 'includes no `js-render-suggestion` when feature disabled' do
+ stub_feature_flags(diff_suggestions: false)
+
+ doc = filter(input, default_context)
+ result = doc.css('code').first
+
+ expect(result[:class]).to be_nil
+ end
+
+ it 'includes no `js-render-suggestion` when filter is disabled' do
+ doc = filter(input)
+ result = doc.css('code').first
+
+ expect(result[:class]).to be_nil
+ end
+end
diff --git a/spec/lib/banzai/filter/user_reference_filter_spec.rb b/spec/lib/banzai/filter/user_reference_filter_spec.rb
index 334d29a53687408ad2d632261aba1c3b7d08c76f..1e8a44b45497aea302fbefcb880785c1f77bb14d 100644
--- a/spec/lib/banzai/filter/user_reference_filter_spec.rb
+++ b/spec/lib/banzai/filter/user_reference_filter_spec.rb
@@ -120,7 +120,7 @@ describe Banzai::Filter::UserReferenceFilter do
it 'includes default classes' do
doc = reference_filter("Hey #{reference}")
- expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member has-tooltip'
+ expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-project_member'
end
context 'when a project is not specified' do
diff --git a/spec/lib/banzai/suggestions_parser_spec.rb b/spec/lib/banzai/suggestions_parser_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..79658d710ce2f9afb82328412f4ebfa324d37cc1
--- /dev/null
+++ b/spec/lib/banzai/suggestions_parser_spec.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Banzai::SuggestionsParser do
+ describe '.parse' do
+ it 'returns a list of suggestion contents' do
+ markdown = <<-MARKDOWN.strip_heredoc
+ ```suggestion
+ foo
+ bar
+ ```
+
+ ```
+ nothing
+ ```
+
+ ```suggestion
+ xpto
+ baz
+ ```
+
+ ```thing
+ this is not a suggestion, it's a thing
+ ```
+ MARKDOWN
+
+ expect(described_class.parse(markdown)).to eq([" foo\n bar",
+ " xpto\n baz"])
+ end
+ end
+end
diff --git a/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d1d64574627429045d99d3a37ca20cf3064d2c54
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_merge_request_metrics_with_events_data_improved_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe Gitlab::BackgroundMigration::PopulateMergeRequestMetricsWithEventsDataImproved, :migration, schema: 20181204154019 do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:users) { table(:users) }
+ let(:events) { table(:events) }
+
+ let(:user) { users.create!(email: 'test@example.com', projects_limit: 100, username: 'test') }
+
+ let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') }
+ let(:merge_requests) { table(:merge_requests) }
+
+ def create_merge_request(id, params = {})
+ params.merge!(id: id,
+ target_project_id: project.id,
+ target_branch: 'master',
+ source_project_id: project.id,
+ source_branch: 'mr name',
+ title: "mr name#{id}")
+
+ merge_requests.create(params)
+ end
+
+ def create_merge_request_event(id, params = {})
+ params.merge!(id: id,
+ project_id: project.id,
+ author_id: user.id,
+ target_type: 'MergeRequest')
+
+ events.create(params)
+ end
+
+ describe '#perform' do
+ it 'creates and updates closed and merged events' do
+ timestamp = Time.new('2018-01-01 12:00:00').utc
+
+ create_merge_request(1)
+ create_merge_request_event(1, target_id: 1, action: 3, updated_at: timestamp)
+ create_merge_request_event(2, target_id: 1, action: 3, updated_at: timestamp + 10.seconds)
+
+ create_merge_request_event(3, target_id: 1, action: 7, updated_at: timestamp)
+ create_merge_request_event(4, target_id: 1, action: 7, updated_at: timestamp + 10.seconds)
+
+ subject.perform(1, 1)
+
+ merge_request = MergeRequest.first
+
+ expect(merge_request.metrics).to have_attributes(latest_closed_by_id: user.id,
+ latest_closed_at: timestamp + 10.seconds,
+ merged_by_id: user.id)
+ end
+ end
+end
diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb
index 12f4b9dc6246f639ed00367c45ecd2f7bea22305..61d78f86b518302df2bd21f1a3a2d50becb19965 100644
--- a/spec/lib/gitlab/ci/config/entry/global_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb
@@ -161,7 +161,8 @@ describe Gitlab::Ci::Config::Entry::Global do
variables: { 'VAR' => 'value' },
ignore: false,
after_script: ['make clean'],
- only: { refs: %w[branches tags] } },
+ only: { refs: %w[branches tags] },
+ except: {} },
spinach: { name: :spinach,
before_script: [],
script: %w[spinach],
@@ -173,7 +174,8 @@ describe Gitlab::Ci::Config::Entry::Global do
variables: {},
ignore: false,
after_script: ['make clean'],
- only: { refs: %w[branches tags] } }
+ only: { refs: %w[branches tags] },
+ except: {} }
)
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/job_spec.rb b/spec/lib/gitlab/ci/config/entry/job_spec.rb
index c1f4a060063e12810d058cd30acb713445c8c5d6..8e32cede3b582682254ec7c632a148b23c950f25 100644
--- a/spec/lib/gitlab/ci/config/entry/job_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/job_spec.rb
@@ -259,7 +259,8 @@ describe Gitlab::Ci::Config::Entry::Job do
stage: 'test',
ignore: false,
after_script: %w[cleanup],
- only: { refs: %w[branches tags] })
+ only: { refs: %w[branches tags] },
+ except: {})
end
end
end
diff --git a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
index 2a753408f54f7dc0a39c59e82315385f3ab74be7..1a2c30d3571b85c023446e7b0281c597cce8513c 100644
--- a/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
+++ b/spec/lib/gitlab/ci/config/entry/jobs_spec.rb
@@ -68,13 +68,15 @@ describe Gitlab::Ci::Config::Entry::Jobs do
commands: 'rspec',
ignore: false,
stage: 'test',
- only: { refs: %w[branches tags] } },
+ only: { refs: %w[branches tags] },
+ except: {} },
spinach: { name: :spinach,
script: %w[spinach],
commands: 'spinach',
ignore: false,
stage: 'test',
- only: { refs: %w[branches tags] } })
+ only: { refs: %w[branches tags] },
+ except: {} })
end
end
diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml
index bae5b21c26f8af72c1d6ea60dc1fcc580535a627..72abd8973cb82c308dabfb4be8087101f56be5a1 100644
--- a/spec/lib/gitlab/import_export/all_models.yml
+++ b/spec/lib/gitlab/import_export/all_models.yml
@@ -37,6 +37,7 @@ notes:
- events
- system_note_metadata
- note_diff_file
+- suggestions
label_links:
- target
- label
diff --git a/spec/lib/gitlab/url_blocker_spec.rb b/spec/lib/gitlab/url_blocker_spec.rb
index 39e0a17a3070327d368f67c16bcf1aed23e07d56..62970bd8cb61a72c15eee75c1779999de7864bfc 100644
--- a/spec/lib/gitlab/url_blocker_spec.rb
+++ b/spec/lib/gitlab/url_blocker_spec.rb
@@ -249,6 +249,27 @@ describe Gitlab::UrlBlocker do
end
end
end
+
+ context 'when ascii_only is true' do
+ it 'returns true for unicode domain' do
+ expect(described_class.blocked_url?('https://𝕘itⅼαƄ.com/foo/foo.bar', ascii_only: true)).to be true
+ end
+
+ it 'returns true for unicode tld' do
+ expect(described_class.blocked_url?('https://gitlab.ᴄοm/foo/foo.bar', ascii_only: true)).to be true
+ end
+
+ it 'returns true for unicode path' do
+ expect(described_class.blocked_url?('https://gitlab.com/𝒇οο/𝒇οο.Ƅαꮁ', ascii_only: true)).to be true
+ end
+
+ it 'returns true for IDNA deviations' do
+ expect(described_class.blocked_url?('https://mißile.com/foo/foo.bar', ascii_only: true)).to be true
+ expect(described_class.blocked_url?('https://miςςile.com/foo/foo.bar', ascii_only: true)).to be true
+ expect(described_class.blocked_url?('https://gitlab.com/foo/foo.bar', ascii_only: true)).to be true
+ expect(described_class.blocked_url?('https://gitlab.com/foo/foo.bar', ascii_only: true)).to be true
+ end
+ end
end
describe '#validate_hostname!' do
diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb
index deb19fe1a4b14b18c7be78bceeadb5b16bc7dc31..2a09f581f68a882a7f7e45bbca066bf3f0db3828 100644
--- a/spec/lib/gitlab/usage_data_spec.rb
+++ b/spec/lib/gitlab/usage_data_spec.rb
@@ -117,6 +117,7 @@ describe Gitlab::UsageData do
releases
remote_mirrors
snippets
+ suggestions
todos
uploads
web_hooks
diff --git a/spec/migrations/populate_mr_metrics_with_events_data_spec.rb b/spec/migrations/populate_mr_metrics_with_events_data_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..291a52b904df16056dbd32d83158e281475b18a5
--- /dev/null
+++ b/spec/migrations/populate_mr_metrics_with_events_data_spec.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require Rails.root.join('db', 'post_migrate', '20181204154019_populate_mr_metrics_with_events_data.rb')
+
+describe PopulateMrMetricsWithEventsData, :migration, :sidekiq do
+ let(:namespaces) { table(:namespaces) }
+ let(:projects) { table(:projects) }
+ let(:namespace) { namespaces.create(name: 'gitlab', path: 'gitlab-org') }
+ let(:project) { projects.create(namespace_id: namespace.id, name: 'foo') }
+ let(:merge_requests) { table(:merge_requests) }
+
+ def create_merge_request(id)
+ params = {
+ id: id,
+ target_project_id: project.id,
+ target_branch: 'master',
+ source_project_id: project.id,
+ source_branch: 'mr name',
+ title: "mr name#{id}"
+ }
+
+ merge_requests.create!(params)
+ end
+
+ it 'correctly schedules background migrations' do
+ create_merge_request(1)
+ create_merge_request(2)
+ create_merge_request(3)
+
+ stub_const("#{described_class.name}::BATCH_SIZE", 2)
+
+ Sidekiq::Testing.fake! do
+ Timecop.freeze do
+ migrate!
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(8.minutes, 1, 2)
+
+ expect(described_class::MIGRATION)
+ .to be_scheduled_delayed_migration(16.minutes, 3, 3)
+
+ expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+ end
+ end
+ end
+end
diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb
index 8624f0daa4d8652319a0fcc47832e59a3e0960ef..40ce8ab736a82e0d251a33e6cae30503840c03e3 100644
--- a/spec/models/diff_note_spec.rb
+++ b/spec/models/diff_note_spec.rb
@@ -318,6 +318,24 @@ describe DiffNote do
end
end
+ describe '#supports_suggestion?' do
+ context 'when noteable does not support suggestions' do
+ it 'returns false' do
+ allow(subject.noteable).to receive(:supports_suggestion?) { false }
+
+ expect(subject.supports_suggestion?).to be(false)
+ end
+ end
+
+ context 'when line is not suggestible' do
+ it 'returns false' do
+ allow_any_instance_of(Gitlab::Diff::Line).to receive(:suggestible?) { false }
+
+ expect(subject.supports_suggestion?).to be(false)
+ end
+ end
+ end
+
describe "image diff notes" do
let(:path) { "files/images/any_image.png" }
diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb
index 097b1bb30dc17047c40dc8ffa0426b9ecbcae59d..99d3ab41b97aa7c92add08c7149782d4ba8897b6 100644
--- a/spec/models/members/project_member_spec.rb
+++ b/spec/models/members/project_member_spec.rb
@@ -11,10 +11,6 @@ describe ProjectMember do
it { is_expected.to validate_inclusion_of(:access_level).in_array(Gitlab::Access.values) }
end
- describe 'modules' do
- it { is_expected.to include_module(Gitlab::ShellAdapter) }
- end
-
describe '.access_level_roles' do
it 'returns Gitlab::Access.options' do
expect(described_class.access_level_roles).to eq(Gitlab::Access.options)
diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb
index 1c85411dc3b3afd4d9dde501783e6fd1ae449518..5e63f14b72066d05772bb78231c1b038d9f51f34 100644
--- a/spec/models/project_spec.rb
+++ b/spec/models/project_spec.rb
@@ -1897,7 +1897,7 @@ describe Project do
end
end
- describe '#latest_successful_builds_for' do
+ describe '#latest_successful_builds_for and #latest_successful_build_for' do
def create_pipeline(status = 'success')
create(:ci_pipeline, project: project,
sha: project.commit.sha,
@@ -1919,14 +1919,16 @@ describe Project do
it 'gives the latest builds from latest pipeline' do
pipeline1 = create_pipeline
pipeline2 = create_pipeline
- build1_p2 = create_build(pipeline2, 'test')
create_build(pipeline1, 'test')
create_build(pipeline1, 'test2')
+ build1_p2 = create_build(pipeline2, 'test')
build2_p2 = create_build(pipeline2, 'test2')
latest_builds = project.latest_successful_builds_for
+ single_build = project.latest_successful_build_for(build1_p2.name)
expect(latest_builds).to contain_exactly(build2_p2, build1_p2)
+ expect(single_build).to eq(build1_p2)
end
end
@@ -1936,16 +1938,22 @@ describe Project do
context 'standalone pipeline' do
it 'returns builds for ref for default_branch' do
builds = project.latest_successful_builds_for
+ single_build = project.latest_successful_build_for(build.name)
expect(builds).to contain_exactly(build)
+ expect(single_build).to eq(build)
end
- it 'returns empty relation if the build cannot be found' do
+ it 'returns empty relation if the build cannot be found for #latest_successful_builds_for' do
builds = project.latest_successful_builds_for('TAIL')
expect(builds).to be_kind_of(ActiveRecord::Relation)
expect(builds).to be_empty
end
+
+ it 'returns exception if the build cannot be found for #latest_successful_build_for' do
+ expect { project.latest_successful_build_for(build.name, 'TAIL') }.to raise_error(ActiveRecord::RecordNotFound)
+ end
end
context 'with some pending pipeline' do
@@ -1954,9 +1962,11 @@ describe Project do
end
it 'gives the latest build from latest pipeline' do
- latest_build = project.latest_successful_builds_for
+ latest_builds = project.latest_successful_builds_for
+ last_single_build = project.latest_successful_build_for(build.name)
- expect(latest_build).to contain_exactly(build)
+ expect(latest_builds).to contain_exactly(build)
+ expect(last_single_build).to eq(build)
end
end
end
@@ -4092,6 +4102,29 @@ describe Project do
end
end
+ describe '#object_pool_params' do
+ let(:project) { create(:project, :repository, :public) }
+
+ subject { project.object_pool_params }
+
+ before do
+ stub_application_setting(hashed_storage_enabled: true)
+ end
+
+ context 'when the objects cannot be pooled' do
+ let(:project) { create(:project, :repository, :private) }
+
+ it { is_expected.to be_empty }
+ end
+
+ context 'when a pool is created' do
+ it 'returns that pool repository' do
+ expect(subject).not_to be_empty
+ expect(subject[:pool_repository]).to be_persisted
+ end
+ end
+ end
+
describe '#git_objects_poolable?' do
subject { project }
diff --git a/spec/models/suggestion_spec.rb b/spec/models/suggestion_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..cafc725dddb2ef414066f1ea94aa79541f3317fb
--- /dev/null
+++ b/spec/models/suggestion_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Suggestion do
+ let(:suggestion) { create(:suggestion) }
+
+ describe 'associations' do
+ it { is_expected.to belong_to(:note) }
+ end
+
+ describe 'validations' do
+ it { is_expected.to validate_presence_of(:note) }
+
+ context 'when suggestion is applied' do
+ before do
+ allow(subject).to receive(:applied?).and_return(true)
+ end
+
+ it { is_expected.to validate_presence_of(:commit_id) }
+ end
+ end
+
+ describe '#appliable?' do
+ context 'when note does not support suggestions' do
+ it 'returns false' do
+ expect_next_instance_of(DiffNote) do |note|
+ allow(note).to receive(:supports_suggestion?) { false }
+ end
+
+ expect(suggestion).not_to be_appliable
+ end
+ end
+
+ context 'when patch is already applied' do
+ let(:suggestion) { create(:suggestion, :applied) }
+
+ it 'returns false' do
+ expect(suggestion).not_to be_appliable
+ end
+ end
+
+ context 'when merge request is not opened' do
+ let(:merge_request) { create(:merge_request, :merged) }
+ let(:note) do
+ create(:diff_note_on_merge_request, project: merge_request.project,
+ noteable: merge_request)
+ end
+
+ let(:suggestion) { create(:suggestion, note: note) }
+
+ it 'returns false' do
+ expect(suggestion).not_to be_appliable
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/jobs_spec.rb b/spec/requests/api/jobs_spec.rb
index 8770365c8939d7be18c953f6490a32af31e1d41c..cd4e480ca64de9d6232d2d3a8fdc19b46a63e6b5 100644
--- a/spec/requests/api/jobs_spec.rb
+++ b/spec/requests/api/jobs_spec.rb
@@ -586,6 +586,136 @@ describe API::Jobs do
end
end
+ describe 'GET id/jobs/artifacts/:ref_name/raw/*artifact_path?job=name' do
+ context 'when job has artifacts' do
+ let(:job) { create(:ci_build, :artifacts, pipeline: pipeline, user: api_user) }
+ let(:artifact) { 'other_artifacts_0.1.2/another-subdirectory/banana_sample.gif' }
+ let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:public_builds) { true }
+
+ before do
+ stub_artifacts_object_storage
+ job.success
+
+ project.update(visibility_level: visibility_level,
+ public_builds: public_builds)
+
+ get_artifact_file(artifact)
+ end
+
+ context 'when user is anonymous' do
+ let(:api_user) { nil }
+
+ context 'when project is public' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:public_builds) { true }
+
+ it 'allows to access artifacts' do
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers.to_h)
+ .to include('Content-Type' => 'application/json',
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'when project is public with builds access disabled' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PUBLIC }
+ let(:public_builds) { false }
+
+ it 'rejects access to artifacts' do
+ expect(response).to have_gitlab_http_status(403)
+ expect(json_response).to have_key('message')
+ expect(response.headers.to_h)
+ .not_to include('Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'when project is private' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
+ let(:public_builds) { true }
+
+ it 'rejects access and hides existence of artifacts' do
+ expect(response).to have_gitlab_http_status(404)
+ expect(json_response).to have_key('message')
+ expect(response.headers.to_h)
+ .not_to include('Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+ end
+
+ context 'when user is authorized' do
+ let(:visibility_level) { Gitlab::VisibilityLevel::PRIVATE }
+ let(:public_builds) { true }
+
+ it 'returns a specific artifact file for a valid path' do
+ expect(Gitlab::Workhorse)
+ .to receive(:send_artifacts_entry)
+ .and_call_original
+
+ get_artifact_file(artifact)
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers.to_h)
+ .to include('Content-Type' => 'application/json',
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'with branch name containing slash' do
+ before do
+ pipeline.reload
+ pipeline.update(ref: 'improve/awesome',
+ sha: project.commit('improve/awesome').sha)
+ end
+
+ it 'returns a specific artifact file for a valid path' do
+ get_artifact_file(artifact, 'improve/awesome')
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(response.headers.to_h)
+ .to include('Content-Type' => 'application/json',
+ 'Gitlab-Workhorse-Send-Data' => /artifacts-entry/)
+ end
+ end
+
+ context 'non-existing job' do
+ shared_examples 'not found' do
+ it { expect(response).to have_gitlab_http_status(:not_found) }
+ end
+
+ context 'has no such ref' do
+ before do
+ get_artifact_file('some/artifact', 'wrong-ref')
+ end
+
+ it_behaves_like 'not found'
+ end
+
+ context 'has no such job' do
+ before do
+ get_artifact_file('some/artifact', pipeline.ref, 'wrong-job-name')
+ end
+
+ it_behaves_like 'not found'
+ end
+ end
+ end
+
+ context 'when job does not have artifacts' do
+ let(:job) { create(:ci_build, pipeline: pipeline, user: api_user) }
+
+ it 'does not return job artifact file' do
+ get_artifact_file('some/artifact')
+
+ expect(response).to have_gitlab_http_status(404)
+ end
+ end
+
+ def get_artifact_file(artifact_path, ref = pipeline.ref, job_name = job.name)
+ get api("/projects/#{project.id}/jobs/artifacts/#{ref}/raw/#{artifact_path}", api_user), job: job_name
+ end
+ end
+
describe 'GET /projects/:id/jobs/:job_id/trace' do
before do
get api("/projects/#{project.id}/jobs/#{job.id}/trace", api_user)
diff --git a/spec/requests/api/suggestions_spec.rb b/spec/requests/api/suggestions_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8e9f737fbd5d18ffa5769b806dbc340b1ca431dd
--- /dev/null
+++ b/spec/requests/api/suggestions_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe API::Suggestions do
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user) }
+
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let(:position) do
+ Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 9,
+ diff_refs: merge_request.diff_refs)
+ end
+
+ let(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ describe "PUT /suggestions/:id/apply" do
+ let(:url) { "/suggestions/#{suggestion.id}/apply" }
+
+ context 'when successfully applies patch' do
+ let(:suggestion) do
+ create(:suggestion, note: diff_note,
+ from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
+ to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
+ end
+
+ it 'returns 200 with json content' do
+ project.add_maintainer(user)
+
+ put api(url, user), id: suggestion.id
+
+ expect(response).to have_gitlab_http_status(200)
+ expect(json_response)
+ .to include('id', 'from_original_line', 'to_original_line',
+ 'from_line', 'to_line', 'appliable', 'applied',
+ 'from_content', 'to_content')
+ end
+ end
+
+ context 'when not able to apply patch' do
+ let(:suggestion) do
+ create(:suggestion, :unappliable, note: diff_note)
+ end
+
+ it 'returns 400 with json content' do
+ project.add_maintainer(user)
+
+ put api(url, user), id: suggestion.id
+
+ expect(response).to have_gitlab_http_status(400)
+ expect(json_response).to eq({ 'message' => 'Suggestion is not appliable' })
+ end
+ end
+
+ context 'when unauthorized' do
+ let(:suggestion) do
+ create(:suggestion, note: diff_note,
+ from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
+ to_content: " raise RuntimeError, 'Explosion'\n # explosion?")
+ end
+
+ it 'returns 403 with json content' do
+ project.add_reporter(user)
+
+ put api(url, user), id: suggestion.id
+
+ expect(response).to have_gitlab_http_status(403)
+ expect(json_response).to eq({ 'message' => '403 Forbidden' })
+ end
+ end
+ end
+end
diff --git a/spec/serializers/issue_board_entity_spec.rb b/spec/serializers/issue_board_entity_spec.rb
index 06d9d3657e66dc6d2ea423620545fe2d26362a0a..f6fa2a794f6ed1da808170493eea0c8c34e46663 100644
--- a/spec/serializers/issue_board_entity_spec.rb
+++ b/spec/serializers/issue_board_entity_spec.rb
@@ -3,21 +3,40 @@
require 'spec_helper'
describe IssueBoardEntity do
- let(:project) { create(:project) }
- let(:resource) { create(:issue, project: project) }
- let(:user) { create(:user) }
-
- let(:request) { double('request', current_user: user) }
+ let(:project) { create(:project) }
+ let(:resource) { create(:issue, project: project) }
+ let(:user) { create(:user) }
+ let(:milestone) { create(:milestone, project: project) }
+ let(:label) { create(:label, project: project, title: 'Test Label') }
+ let(:request) { double('request', current_user: user) }
subject { described_class.new(resource, request: request).as_json }
it 'has basic attributes' do
expect(subject).to include(:id, :iid, :title, :confidential, :due_date, :project_id, :relative_position,
- :project, :labels)
+ :labels, :assignees, project: hash_including(:id, :path))
end
it 'has path and endpoints' do
expect(subject).to include(:reference_path, :real_path, :issue_sidebar_endpoint,
:toggle_subscription_endpoint, :assignable_labels_endpoint)
end
+
+ it 'has milestone attributes' do
+ resource.milestone = milestone
+
+ expect(subject).to include(milestone: hash_including(:id, :title))
+ end
+
+ it 'has assignee attributes' do
+ resource.assignees = [user]
+
+ expect(subject).to include(assignees: array_including(hash_including(:id, :name, :username, :avatar_url)))
+ end
+
+ it 'has label attributes' do
+ resource.labels = [label]
+
+ expect(subject).to include(labels: array_including(hash_including(:id, :title, :color, :description, :text_color, :priority)))
+ end
end
diff --git a/spec/serializers/suggestion_entity_spec.rb b/spec/serializers/suggestion_entity_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..047571f161c6a244b83b80e52cb13649e941b921
--- /dev/null
+++ b/spec/serializers/suggestion_entity_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe SuggestionEntity do
+ include RepoHelpers
+
+ let(:user) { create(:user) }
+ let(:request) { double('request', current_user: user) }
+ let(:suggestion) { create(:suggestion) }
+ let(:entity) { described_class.new(suggestion, request: request) }
+
+ subject { entity.as_json }
+
+ it 'exposes correct attributes' do
+ expect(subject).to include(:id, :from_original_line, :to_original_line, :from_line,
+ :to_line, :appliable, :applied, :from_content, :to_content)
+ end
+
+ it 'exposes current user abilities' do
+ expect(subject[:current_user]).to include(:can_apply)
+ end
+end
diff --git a/spec/services/ci/create_pipeline_service_spec.rb b/spec/services/ci/create_pipeline_service_spec.rb
index ccc6b0ef1c7d1b1256b0f3bdcd67d5ca754b85a3..ffa47d527f78a2789d82a7893994c640c5b7df64 100644
--- a/spec/services/ci/create_pipeline_service_spec.rb
+++ b/spec/services/ci/create_pipeline_service_spec.rb
@@ -810,6 +810,95 @@ describe Ci::CreatePipelineService do
end
end
end
+
+ context "when config uses regular expression for only keyword" do
+ let(:config) do
+ {
+ build: {
+ stage: 'build',
+ script: 'echo',
+ only: ["/^#{ref_name}$/"]
+ }
+ }
+ end
+
+ context 'when merge request is specified' do
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: ref_name,
+ target_project: project,
+ target_branch: 'master')
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(pipeline).not_to be_persisted
+
+ expect(pipeline.errors[:base])
+ .to eq(['No stages / jobs for this pipeline.'])
+ end
+ end
+ end
+
+ context "when config uses variables for only keyword" do
+ let(:config) do
+ {
+ build: {
+ stage: 'build',
+ script: 'echo',
+ only: {
+ variables: %w($CI)
+ }
+ }
+ }
+ end
+
+ context 'when merge request is specified' do
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: ref_name,
+ target_project: project,
+ target_branch: 'master')
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(pipeline).not_to be_persisted
+
+ expect(pipeline.errors[:base])
+ .to eq(['No stages / jobs for this pipeline.'])
+ end
+ end
+ end
+
+ context "when config has 'except: [tags]'" do
+ let(:config) do
+ {
+ build: {
+ stage: 'build',
+ script: 'echo',
+ except: ['tags']
+ }
+ }
+ end
+
+ context 'when merge request is specified' do
+ let(:merge_request) do
+ create(:merge_request,
+ source_project: project,
+ source_branch: ref_name,
+ target_project: project,
+ target_branch: 'master')
+ end
+
+ it 'does not create a merge request pipeline' do
+ expect(pipeline).not_to be_persisted
+
+ expect(pipeline.errors[:base])
+ .to eq(['No stages / jobs for this pipeline.'])
+ end
+ end
+ end
end
context 'when source is web' do
diff --git a/spec/services/notes/update_service_spec.rb b/spec/services/notes/update_service_spec.rb
index 533dcdcd6cd0430332ad97a922117c3159f913fa..fd9bff46a06fcef6374acd8d65b75aac79cde464 100644
--- a/spec/services/notes/update_service_spec.rb
+++ b/spec/services/notes/update_service_spec.rb
@@ -20,6 +20,29 @@ describe Notes::UpdateService do
@note.reload
end
+ context 'suggestions' do
+ it 'refreshes note suggestions' do
+ markdown = <<-MARKDOWN.strip_heredoc
+ ```suggestion
+ foo
+ ```
+
+ ```suggestion
+ bar
+ ```
+ MARKDOWN
+
+ suggestion = create(:suggestion)
+ note = suggestion.note
+
+ expect { described_class.new(project, user, note: markdown).execute(note) }
+ .to change { note.suggestions.count }.from(1).to(2)
+
+ expect(note.suggestions.order(:relative_order).map(&:to_content))
+ .to eq([" foo\n", " bar\n"])
+ end
+ end
+
context 'todos' do
let!(:todo) { create(:todo, :assigned, user: user, project: project, target: issue, author: user2) }
diff --git a/spec/services/preview_markdown_service_spec.rb b/spec/services/preview_markdown_service_spec.rb
index b69977c812a0bf38647f91a6f533d10f41d1cd33..458cb8f1f31693d876db719562abd0aa96e1e02f 100644
--- a/spec/services/preview_markdown_service_spec.rb
+++ b/spec/services/preview_markdown_service_spec.rb
@@ -19,6 +19,31 @@ describe PreviewMarkdownService do
end
end
+ describe 'suggestions' do
+ let(:params) { { text: "```suggestion\nfoo\n```", preview_suggestions: preview_suggestions } }
+ let(:service) { described_class.new(project, user, params) }
+
+ context 'when preview markdown param is present' do
+ let(:preview_suggestions) { true }
+
+ it 'returns users referenced in text' do
+ result = service.execute
+
+ expect(result[:suggestions]).to eq(['foo'])
+ end
+ end
+
+ context 'when preview markdown param is not present' do
+ let(:preview_suggestions) { false }
+
+ it 'returns users referenced in text' do
+ result = service.execute
+
+ expect(result[:suggestions]).to eq([])
+ end
+ end
+ end
+
context 'new note with quick actions' do
let(:issue) { create(:issue, project: project) }
let(:params) do
diff --git a/spec/services/suggestions/apply_service_spec.rb b/spec/services/suggestions/apply_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3a483717756b584f92303a8e526c936fb2485585
--- /dev/null
+++ b/spec/services/suggestions/apply_service_spec.rb
@@ -0,0 +1,229 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Suggestions::ApplyService do
+ include ProjectForksHelper
+
+ let(:project) { create(:project, :repository) }
+ let(:user) { create(:user, :commit_email) }
+
+ let(:position) do
+ Gitlab::Diff::Position.new(old_path: "files/ruby/popen.rb",
+ new_path: "files/ruby/popen.rb",
+ old_line: nil,
+ new_line: 9,
+ diff_refs: merge_request.diff_refs)
+ end
+
+ let(:suggestion) do
+ create(:suggestion, note: diff_note,
+ from_content: " raise RuntimeError, \"System commands must be given as an array of strings\"\n",
+ to_content: " raise RuntimeError, 'Explosion'\n # explosion?\n")
+ end
+
+ subject { described_class.new(user) }
+
+ context 'patch is appliable' do
+ let(:expected_content) do
+ <<-CONTENT.strip_heredoc
+ require 'fileutils'
+ require 'open3'
+
+ module Popen
+ extend self
+
+ def popen(cmd, path=nil)
+ unless cmd.is_a?(Array)
+ raise RuntimeError, 'Explosion'
+ # explosion?
+ end
+
+ path ||= Dir.pwd
+
+ vars = {
+ "PWD" => path
+ }
+
+ options = {
+ chdir: path
+ }
+
+ unless File.directory?(path)
+ FileUtils.mkdir_p(path)
+ end
+
+ @cmd_output = ""
+ @cmd_status = 0
+
+ Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|
+ @cmd_output << stdout.read
+ @cmd_output << stderr.read
+ @cmd_status = wait_thr.value.exitstatus
+ end
+
+ return @cmd_output, @cmd_status
+ end
+ end
+ CONTENT
+ end
+
+ context 'non-fork project' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let!(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates the file with the new contents' do
+ subject.execute(suggestion)
+
+ blob = project.repository.blob_at_branch(merge_request.source_branch,
+ position.new_path)
+
+ expect(blob.data).to eq(expected_content)
+ end
+
+ it 'returns success status' do
+ result = subject.execute(suggestion)
+
+ expect(result[:status]).to eq(:success)
+ end
+
+ it 'updates suggestion applied and commit_id columns' do
+ expect { subject.execute(suggestion) }
+ .to change(suggestion, :applied)
+ .from(false).to(true)
+ .and change(suggestion, :commit_id)
+ .from(nil)
+ end
+
+ it 'created commit has users email and name' do
+ subject.execute(suggestion)
+
+ commit = project.repository.commit
+
+ expect(user.commit_email).not_to eq(user.email)
+ expect(commit.author_email).to eq(user.commit_email)
+ expect(commit.committer_email).to eq(user.commit_email)
+ expect(commit.author_name).to eq(user.name)
+ end
+ end
+
+ context 'fork-project' do
+ let(:project) { create(:project, :public, :repository) }
+
+ let(:forked_project) do
+ fork_project_with_submodules(project, user)
+ end
+
+ let(:merge_request) do
+ create(:merge_request,
+ source_branch: 'conflict-resolvable-fork', source_project: forked_project,
+ target_branch: 'conflict-start', target_project: project)
+ end
+
+ let!(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request, position: position, project: project)
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ it 'updates file in the source project' do
+ expect(Files::UpdateService).to receive(:new)
+ .with(merge_request.source_project, user, anything)
+ .and_call_original
+
+ subject.execute(suggestion)
+ end
+ end
+ end
+
+ context 'no permission' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ context 'user cannot write in project repo' do
+ before do
+ project.add_reporter(user)
+ end
+
+ it 'returns error' do
+ result = subject.execute(suggestion)
+
+ expect(result).to eq(message: "You are not allowed to push into this branch",
+ status: :error)
+ end
+ end
+ end
+
+ context 'patch is not appliable' do
+ let(:merge_request) do
+ create(:merge_request, source_project: project,
+ target_project: project)
+ end
+
+ let(:diff_note) do
+ create(:diff_note_on_merge_request, noteable: merge_request,
+ position: position,
+ project: project)
+ end
+
+ before do
+ project.add_maintainer(user)
+ end
+
+ context 'suggestion was already applied' do
+ it 'returns success status' do
+ result = subject.execute(suggestion)
+
+ expect(result[:status]).to eq(:success)
+ end
+ end
+
+ context 'note is outdated' do
+ before do
+ allow(diff_note).to receive(:active?) { false }
+ end
+
+ it 'returns error message' do
+ result = subject.execute(suggestion)
+
+ expect(result).to eq(message: 'Suggestion is not appliable',
+ status: :error)
+ end
+ end
+
+ context 'suggestion was already applied' do
+ before do
+ suggestion.update!(applied: true, commit_id: 'sha')
+ end
+
+ it 'returns error message' do
+ result = subject.execute(suggestion)
+
+ expect(result).to eq(message: 'Suggestion is not appliable',
+ status: :error)
+ end
+ end
+ end
+end
diff --git a/spec/services/suggestions/create_service_spec.rb b/spec/services/suggestions/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f1142c88a69d3e22de3d1e45ba33b3eceda64fb5
--- /dev/null
+++ b/spec/services/suggestions/create_service_spec.rb
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Suggestions::CreateService do
+ let(:project_with_repo) { create(:project, :repository) }
+ let(:merge_request) do
+ create(:merge_request, source_project: project_with_repo,
+ target_project: project_with_repo)
+ end
+
+ 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: merge_request.diff_refs)
+ end
+
+ let(:markdown) do
+ <<-MARKDOWN.strip_heredoc
+ ```suggestion
+ foo
+ bar
+ ```
+
+ ```
+ nothing
+ ```
+
+ ```suggestion
+ xpto
+ baz
+ ```
+
+ ```thing
+ this is not a suggestion, it's a thing
+ ```
+ MARKDOWN
+ end
+
+ subject { described_class.new(note) }
+
+ describe '#execute' do
+ context 'should not try to parse suggestions' do
+ context 'when not a diff note for merge requests' do
+ let(:note) do
+ create(:diff_note_on_commit, project: project_with_repo,
+ note: markdown)
+ end
+
+ it 'does not try to parse suggestions' do
+ expect(Banzai::SuggestionsParser).not_to receive(:parse)
+
+ subject.execute
+ end
+ end
+
+ context 'when diff note is not for text' do
+ let(:note) do
+ create(:diff_note_on_merge_request, project: project_with_repo,
+ noteable: merge_request,
+ position: position,
+ note: markdown)
+ end
+
+ it 'does not try to parse suggestions' do
+ allow(note).to receive(:on_text?) { false }
+
+ expect(Banzai::SuggestionsParser).not_to receive(:parse)
+
+ subject.execute
+ end
+ end
+ end
+
+ context 'should create suggestions' do
+ let(:note) do
+ create(:diff_note_on_merge_request, project: project_with_repo,
+ noteable: merge_request,
+ position: position,
+ note: markdown)
+ end
+
+ context 'single line suggestions' do
+ it 'persists suggestion records' do
+ expect { subject.execute }
+ .to change { note.suggestions.count }
+ .from(0)
+ .to(2)
+ end
+
+ it 'persists original from_content lines and suggested lines' do
+ subject.execute
+
+ suggestions = note.suggestions.order(:relative_order)
+
+ suggestion_1 = suggestions.first
+ suggestion_2 = suggestions.last
+
+ expect(suggestion_1).to have_attributes(from_content: " vars = {\n",
+ to_content: " foo\n bar\n")
+
+ expect(suggestion_2).to have_attributes(from_content: " vars = {\n",
+ to_content: " xpto\n baz\n")
+ end
+ end
+ end
+ end
+end
diff --git a/spec/support/features/discussion_comments_shared_example.rb b/spec/support/features/discussion_comments_shared_example.rb
index 922f3df144dfe87153725936ad8952c4330ed63c..42a086d58d2f639cfe57672639b69ace3b3d1c92 100644
--- a/spec/support/features/discussion_comments_shared_example.rb
+++ b/spec/support/features/discussion_comments_shared_example.rb
@@ -178,6 +178,16 @@ shared_examples 'discussion comments' do |resource_name|
let(:note_id) { find("#{comments_selector} .note:first-child", match: :first)['data-note-id'] }
let(:reply_id) { find("#{comments_selector} .note:last-child", match: :first)['data-note-id'] }
+ it 'can be replied to after resolving' do
+ click_button "Resolve discussion"
+ wait_for_requests
+
+ refresh
+ wait_for_requests
+
+ submit_reply('to reply or not reply')
+ end
+
it 'shows resolved discussion when toggled' do
submit_reply('a')
diff --git a/spec/support/helpers/javascript_fixtures_helpers.rb b/spec/support/helpers/javascript_fixtures_helpers.rb
index 086a345dca823c0f4017da4c6fbfba899f175cd7..89c5ec7a7181b4deee4b2706f60f34b2d7243be3 100644
--- a/spec/support/helpers/javascript_fixtures_helpers.rb
+++ b/spec/support/helpers/javascript_fixtures_helpers.rb
@@ -6,6 +6,13 @@ module JavaScriptFixturesHelpers
FIXTURE_PATH = 'spec/javascripts/fixtures'.freeze
+ def self.included(base)
+ base.around do |example|
+ # pick an arbitrary date from the past, so tests are not time dependent
+ Timecop.freeze(Time.utc(2015, 7, 3, 10)) { example.run }
+ end
+ end
+
# Public: Removes all fixture files from given directory
#
# directory_name - directory of the fixtures (relative to FIXTURE_PATH)
diff --git a/spec/support/shared_examples/controllers/set_sort_order_from_user_preference_shared_examples.rb b/spec/support/shared_examples/controllers/set_sort_order_from_user_preference_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..b34948be670ab52bb671d86019dd8615ea68efa5
--- /dev/null
+++ b/spec/support/shared_examples/controllers/set_sort_order_from_user_preference_shared_examples.rb
@@ -0,0 +1,32 @@
+shared_examples 'set sort order from user preference' do
+ describe '#set_sort_order_from_user_preference' do
+ # There is no issuable_sorting_field defined in any CE controllers yet,
+ # however any other field present in user_preferences table can be used for testing.
+ let(:sorting_field) { :issue_notes_filter }
+ let(:sorting_param) { 'any' }
+
+ before do
+ allow(controller).to receive(:issuable_sorting_field).and_return(sorting_field)
+ end
+
+ context 'when database is in read-only mode' do
+ it 'it does not update user preference' do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(true)
+
+ expect_any_instance_of(UserPreference).not_to receive(:update_attribute).with(sorting_field, sorting_param)
+
+ get :index, namespace_id: project.namespace, project_id: project, sort: sorting_param
+ end
+ end
+
+ context 'when database is not in read-only mode' do
+ it 'updates user preference' do
+ allow(Gitlab::Database).to receive(:read_only?).and_return(false)
+
+ expect_any_instance_of(UserPreference).to receive(:update_attribute).with(sorting_field, sorting_param)
+
+ get :index, namespace_id: project.namespace, project_id: project, sort: sorting_param
+ end
+ end
+ end
+end
diff --git a/spec/uploaders/namespace_file_uploader_spec.rb b/spec/uploaders/namespace_file_uploader_spec.rb
index d09725ee4be4c64f793f8817f5a7524ba4593ea8..77401814194a68e3731e609489bf273f08db66aa 100644
--- a/spec/uploaders/namespace_file_uploader_spec.rb
+++ b/spec/uploaders/namespace_file_uploader_spec.rb
@@ -1,18 +1,22 @@
require 'spec_helper'
-IDENTIFIER = %r{\h+/\S+}
-
describe NamespaceFileUploader do
let(:group) { build_stubbed(:group) }
let(:uploader) { described_class.new(group) }
let(:upload) { create(:upload, :namespace_upload, model: group) }
+ let(:identifier) { %r{\h+/\S+} }
subject { uploader }
- it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/namespace/\d+],
- upload_path: IDENTIFIER,
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{IDENTIFIER}]
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[uploads/-/system/namespace/\d+],
+ upload_path: identifier,
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/namespace/\d+/#{identifier}]
+ }
+ end
+ end
context "object_store is REMOTE" do
before do
@@ -21,9 +25,14 @@ describe NamespaceFileUploader do
include_context 'with storage', described_class::Store::REMOTE
- it_behaves_like 'builds correct paths',
- store_dir: %r[namespace/\d+/\h+],
- upload_path: IDENTIFIER
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[namespace/\d+/\h+],
+ upload_path: identifier
+ }
+ end
+ end
end
context '.base_dir' do
diff --git a/spec/uploaders/personal_file_uploader_spec.rb b/spec/uploaders/personal_file_uploader_spec.rb
index 7700b14ce6b9c579195d2ee9e87101eada452f48..2896e9a112defdf7a112e9191c09ba87e4646466 100644
--- a/spec/uploaders/personal_file_uploader_spec.rb
+++ b/spec/uploaders/personal_file_uploader_spec.rb
@@ -1,18 +1,22 @@
require 'spec_helper'
-IDENTIFIER = %r{\h+/\S+}
-
describe PersonalFileUploader do
let(:model) { create(:personal_snippet) }
let(:uploader) { described_class.new(model) }
let(:upload) { create(:upload, :personal_snippet_upload) }
+ let(:identifier) { %r{\h+/\S+} }
subject { uploader }
- it_behaves_like 'builds correct paths',
- store_dir: %r[uploads/-/system/personal_snippet/\d+],
- upload_path: IDENTIFIER,
- absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{IDENTIFIER}]
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[uploads/-/system/personal_snippet/\d+],
+ upload_path: identifier,
+ absolute_path: %r[#{CarrierWave.root}/uploads/-/system/personal_snippet/\d+/#{identifier}]
+ }
+ end
+ end
context "object_store is REMOTE" do
before do
@@ -21,9 +25,14 @@ describe PersonalFileUploader do
include_context 'with storage', described_class::Store::REMOTE
- it_behaves_like 'builds correct paths',
- store_dir: %r[\d+/\h+],
- upload_path: IDENTIFIER
+ it_behaves_like 'builds correct paths' do
+ let(:patterns) do
+ {
+ store_dir: %r[\d+/\h+],
+ upload_path: identifier
+ }
+ end
+ end
end
describe '#to_h' do
diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb
index 082d09d3f16545b40f6afe7d35dc3ce5288462a4..f3f3386382fb13bff5b3686de530d49febd162a0 100644
--- a/spec/validators/url_validator_spec.rb
+++ b/spec/validators/url_validator_spec.rb
@@ -143,4 +143,33 @@ describe UrlValidator do
end
end
end
+
+ context 'when ascii_only is' do
+ let(:url) { 'https://𝕘itⅼαƄ.com/foo/foo.bar'}
+ let(:validator) { described_class.new(attributes: [:link_url], ascii_only: ascii_only) }
+
+ context 'true' do
+ let(:ascii_only) { true }
+
+ it 'prevents unicode characters' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors.empty?).to be false
+ end
+ end
+
+ context 'false (default)' do
+ let(:ascii_only) { false }
+
+ it 'does not prevent unicode characters' do
+ badge.link_url = url
+
+ subject
+
+ expect(badge.errors.empty?).to be true
+ end
+ end
+ end
end
diff --git a/yarn.lock b/yarn.lock
index d7d2b89a881d07715c798f65d1255e90952ca789..041a974b193f04da62b031b57167173cc12c6668 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -616,6 +616,13 @@
lodash "^4.17.10"
to-fast-properties "^2.0.0"
+"@gitlab/csslab@^1.8.0":
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/csslab/-/csslab-1.8.0.tgz#54a2457fdc80f006665f0e578a5532780954ccfa"
+ integrity sha512-RZylRElufH1kwsBQlIDaVcrcXMyD5IEGrU6ABUd8W3LG8/F9jJ4Y3Ys7EPTpK/qFJyx86AutTtFGRxRNlMx85w==
+ dependencies:
+ bootstrap "4.1.3"
+
"@gitlab/eslint-config@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@gitlab/eslint-config/-/eslint-config-1.2.0.tgz#115568a70edabbc024f1bc13ba1ba499a9ba05a9"
@@ -629,15 +636,15 @@
eslint-plugin-promise "^4.0.1"
eslint-plugin-vue "^5.0.0-beta.3"
-"@gitlab/svgs@^1.40.0":
- version "1.41.0"
- resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.41.0.tgz#f80e3a0e259f3550af00685556ea925e471276d3"
- integrity sha512-tKUXyqe54efWBsjQBUcvNF0AvqmE2NI2No3Bnix/gKDRImzIlcgIkM67Y8zoJv1D0w4CO87WcaG5GLpIFIT1Pg==
+"@gitlab/svgs@^1.42.0":
+ version "1.42.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.42.0.tgz#54eb88606bb79b74373a3aa49d8c10557fb1fd7a"
+ integrity sha512-mVm1kyV/M1fTbQcW8Edbk7BPT2syQf+ot9qwFzLFiFXAn3jXTi6xy+DS+0cgoTnglSUsXVl4qcVAQjt8YoOOOQ==
-"@gitlab/ui@^1.14.0":
- version "1.14.0"
- resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.14.0.tgz#f0fd7c0e6c45a36ab3be18d00e2908a8cb405f90"
- integrity sha512-jkBTN8qO41A894kcLo6b/mfLIgL8YNn+ZzjgzEXaZ3PyeQ3mKBdrBoSYkzH556qviroBvk/+3yyZz96VUo08qQ==
+"@gitlab/ui@^1.15.0":
+ version "1.15.0"
+ resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-1.15.0.tgz#288e189cb99de354aeb4598f9ac8cced5f47e139"
+ integrity sha512-Aiv/WABr8lBVJk0eoanSoO07Lr5Nnvuq82IjDnNzcw9enB1DAKvlstC2r9iiMfg1pVgV/uLdDeRFqH9eI1X4Rg==
dependencies:
babel-standalone "^6.26.0"
bootstrap-vue "^2.0.0-rc.11"
|