Commit 26ffa151 authored by GitLab Bot's avatar GitLab Bot

Merge remote-tracking branch 'upstream/master' into ce-to-ee-2018-06-13

# Conflicts:
#	app/assets/javascripts/boards/components/board_sidebar.js
#	app/assets/javascripts/boards/components/issue_card_inner.js
#	app/assets/javascripts/ide/components/repo_commit_section.vue
#	app/assets/javascripts/merge_conflicts/components/inline_conflict_lines.js
#	app/assets/javascripts/merge_conflicts/components/parallel_conflict_lines.js
#	app/assets/javascripts/pipelines/components/graph/stage_column_component.vue
#	app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.vue
#	app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue
#	app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
#	app/assets/javascripts/vue_shared/components/sidebar/toggle_sidebar.vue
#	app/assets/javascripts/vue_shared/components/user_avatar/user_avatar_image.vue
#	doc/user/project/issue_board.md
#	doc/user/project/quick_actions.md
#	spec/services/quick_actions/interpret_service_spec.rb

[ci skip]
parents e74d9872 7b7ba297
...@@ -302,7 +302,7 @@ For guidance on UX implementation at GitLab, please refer to our [Design System] ...@@ -302,7 +302,7 @@ For guidance on UX implementation at GitLab, please refer to our [Design System]
The UX team uses labels to manage their workflow. The UX team uses labels to manage their workflow.
The ~"UX" label on an issue is a signal to the UX team that it will need UX attention. The ~"UX" label on an issue is a signal to the UX team that it will need UX attention.
To better understand the priority by which UX tackles issues, see the [UX section](https://about.gitlab.com/handbook/ux/) of the handbook. To better understand the priority by which UX tackles issues, see the [UX section](https://about.gitlab.com/handbook/engineering/ux) of the handbook.
Once an issue has been worked on and is ready for development, a UXer applies the ~"UX ready" label to that issue. Once an issue has been worked on and is ready for development, a UXer applies the ~"UX ready" label to that issue.
......
...@@ -177,6 +177,7 @@ the stable branch are: ...@@ -177,6 +177,7 @@ the stable branch are:
* Fixes for [regressions](#regressions) * Fixes for [regressions](#regressions)
* Fixes for security issues * Fixes for security issues
* Fixes or improvements to automated QA scenarios * Fixes or improvements to automated QA scenarios
* Documentation updates for changes in the same release
* New or updated translations (as long as they do not touch application code) * New or updated translations (as long as they do not touch application code)
During the feature freeze all merge requests that are meant to go into the During the feature freeze all merge requests that are meant to go into the
......
...@@ -27,7 +27,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({ ...@@ -27,7 +27,10 @@ gl.issueBoards.BoardSidebar = Vue.extend({
assignees, assignees,
removeBtn: gl.issueBoards.RemoveIssueBtn, removeBtn: gl.issueBoards.RemoveIssueBtn,
subscriptions, subscriptions,
<<<<<<< HEAD
weight, weight,
=======
>>>>>>> upstream/master
}, },
props: { props: {
currentUser: { currentUser: {
......
...@@ -12,7 +12,10 @@ window.gl.issueBoards = window.gl.issueBoards || {}; ...@@ -12,7 +12,10 @@ window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.IssueCardInner = Vue.extend({ gl.issueBoards.IssueCardInner = Vue.extend({
components: { components: {
UserAvatarLink, UserAvatarLink,
<<<<<<< HEAD
IssueCardWeight, IssueCardWeight,
=======
>>>>>>> upstream/master
}, },
props: { props: {
issue: { issue: {
......
...@@ -43,6 +43,15 @@ export default { ...@@ -43,6 +43,15 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
activeFileKey: {
type: String,
required: false,
default: null,
},
keyPrefix: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -113,8 +122,9 @@ export default { ...@@ -113,8 +122,9 @@ export default {
<list-item <list-item
:file="file" :file="file"
:action-component="itemActionComponent" :action-component="itemActionComponent"
:key-prefix="title" :key-prefix="keyPrefix"
:staged-list="stagedList" :staged-list="stagedList"
:active-file-key="activeFileKey"
/> />
</li> </li>
</ul> </ul>
......
...@@ -30,6 +30,11 @@ export default { ...@@ -30,6 +30,11 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
activeFileKey: {
type: String,
required: false,
default: null,
},
}, },
computed: { computed: {
iconName() { iconName() {
...@@ -39,6 +44,12 @@ export default { ...@@ -39,6 +44,12 @@ export default {
iconClass() { iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
}, },
fullKey() {
return `${this.keyPrefix}-${this.file.key}`;
},
isActive() {
return this.activeFileKey === this.fullKey;
},
}, },
methods: { methods: {
...mapActions([ ...mapActions([
...@@ -51,7 +62,7 @@ export default { ...@@ -51,7 +62,7 @@ export default {
openFileInEditor() { openFileInEditor() {
return this.openPendingTab({ return this.openPendingTab({
file: this.file, file: this.file,
keyPrefix: this.keyPrefix.toLowerCase(), keyPrefix: this.keyPrefix,
}).then(changeViewer => { }).then(changeViewer => {
if (changeViewer) { if (changeViewer) {
this.updateViewer(viewerTypes.diff); this.updateViewer(viewerTypes.diff);
...@@ -70,7 +81,12 @@ export default { ...@@ -70,7 +81,12 @@ export default {
</script> </script>
<template> <template>
<div class="multi-file-commit-list-item"> <div
:class="{
'is-active': isActive
}"
class="multi-file-commit-list-item"
>
<button <button
type="button" type="button"
class="multi-file-commit-list-path" class="multi-file-commit-list-path"
......
...@@ -6,7 +6,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; ...@@ -6,7 +6,7 @@ import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue';
import CommitFilesList from './commit_sidebar/list.vue'; import CommitFilesList from './commit_sidebar/list.vue';
import EmptyState from './commit_sidebar/empty_state.vue'; import EmptyState from './commit_sidebar/empty_state.vue';
import * as consts from '../stores/modules/commit/constants'; import * as consts from '../stores/modules/commit/constants';
import { activityBarViews } from '../constants'; import { activityBarViews, stageKeys } from '../constants';
export default { export default {
components: { components: {
...@@ -27,11 +27,14 @@ export default { ...@@ -27,11 +27,14 @@ export default {
'unusedSeal', 'unusedSeal',
]), ]),
...mapState('commit', ['commitMessage', 'submitCommitLoading']), ...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges']), ...mapGetters(['lastOpenedFile', 'hasChanges', 'someUncommitedChanges', 'activeFile']),
...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']), ...mapGetters('commit', ['commitButtonDisabled', 'discardDraftButtonDisabled']),
showStageUnstageArea() { showStageUnstageArea() {
return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal); return !!(this.someUncommitedChanges || this.lastCommitMsg || !this.unusedSeal);
}, },
activeFileKey() {
return this.activeFile ? this.activeFile.key : null;
},
}, },
watch: { watch: {
hasChanges() { hasChanges() {
...@@ -44,6 +47,7 @@ export default { ...@@ -44,6 +47,7 @@ export default {
if (this.lastOpenedFile) { if (this.lastOpenedFile) {
this.openPendingTab({ this.openPendingTab({
file: this.lastOpenedFile, file: this.lastOpenedFile,
keyPrefix: this.lastOpenedFile.changed ? stageKeys.unstaged : stageKeys.staged,
}) })
.then(changeViewer => { .then(changeViewer => {
if (changeViewer) { if (changeViewer) {
...@@ -62,6 +66,7 @@ export default { ...@@ -62,6 +66,7 @@ export default {
return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges()); return this.updateCommitAction(consts.COMMIT_TO_NEW_BRANCH).then(() => this.commitChanges());
}, },
}, },
stageKeys,
}; };
</script> </script>
...@@ -86,8 +91,13 @@ export default { ...@@ -86,8 +91,13 @@ export default {
> >
<commit-files-list <commit-files-list
:title="__('Unstaged')" :title="__('Unstaged')"
:key-prefix="$options.stageKeys.unstaged"
:file-list="changedFiles" :file-list="changedFiles"
:action-btn-text="__('Stage all')" :action-btn-text="__('Stage all')"
<<<<<<< HEAD
=======
:active-file-key="activeFileKey"
>>>>>>> upstream/master
class="is-first" class="is-first"
icon-name="unstaged" icon-name="unstaged"
action="stageAllChanges" action="stageAllChanges"
...@@ -95,9 +105,14 @@ export default { ...@@ -95,9 +105,14 @@ export default {
/> />
<commit-files-list <commit-files-list
:title="__('Staged')" :title="__('Staged')"
:key-prefix="$options.stageKeys.staged"
:file-list="stagedFiles" :file-list="stagedFiles"
:action-btn-text="__('Unstage all')" :action-btn-text="__('Unstage all')"
:staged-list="true" :staged-list="true"
<<<<<<< HEAD
=======
:active-file-key="activeFileKey"
>>>>>>> upstream/master
icon-name="staged" icon-name="staged"
action="unstageAllChanges" action="unstageAllChanges"
item-action-component="unstage-button" item-action-component="unstage-button"
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash'; import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { activityBarViews, viewerTypes } from '../constants'; import { activityBarViews, viewerTypes } from '../constants';
import Editor from '../lib/editor'; import Editor from '../lib/editor';
import ExternalLink from './external_link.vue'; import ExternalLink from './external_link.vue';
...@@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue'; ...@@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue';
export default { export default {
components: { components: {
ContentViewer, ContentViewer,
DiffViewer,
ExternalLink, ExternalLink,
}, },
props: { props: {
...@@ -29,9 +31,18 @@ export default { ...@@ -29,9 +31,18 @@ export default {
shouldHideEditor() { shouldHideEditor() {
return this.file && this.file.binary && !this.file.content; return this.file && this.file.binary && !this.file.content;
}, },
showContentViewer() {
return (
(this.shouldHideEditor || this.file.viewMode === 'preview') &&
(this.viewer !== viewerTypes.mr || !this.file.mrChange)
);
},
showDiffViewer() {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
editTabCSS() { editTabCSS() {
return { return {
active: this.file.viewMode === 'edit', active: this.file.viewMode === 'editor',
}; };
}, },
previewTabCSS() { previewTabCSS() {
...@@ -53,7 +64,7 @@ export default { ...@@ -53,7 +64,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) { if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({ this.setFileViewMode({
file: this.file, file: this.file,
viewMode: 'edit', viewMode: 'editor',
}); });
} }
} }
...@@ -62,7 +73,7 @@ export default { ...@@ -62,7 +73,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) { if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({ this.setFileViewMode({
file: this.file, file: this.file,
viewMode: 'edit', viewMode: 'editor',
}); });
} }
}, },
...@@ -197,7 +208,7 @@ export default { ...@@ -197,7 +208,7 @@ export default {
<a <a
href="javascript:void(0);" href="javascript:void(0);"
role="button" role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> @click.prevent="setFileViewMode({ file, viewMode: 'editor' })">
<template v-if="viewer === $options.viewerTypes.edit"> <template v-if="viewer === $options.viewerTypes.edit">
{{ __('Edit') }} {{ __('Edit') }}
</template> </template>
...@@ -222,7 +233,7 @@ export default { ...@@ -222,7 +233,7 @@ export default {
/> />
</div> </div>
<div <div
v-show="!shouldHideEditor && file.viewMode === 'edit'" v-show="!shouldHideEditor && file.viewMode ==='editor'"
ref="editor" ref="editor"
:class="{ :class="{
'is-readonly': isCommitModeActive, 'is-readonly': isCommitModeActive,
...@@ -231,10 +242,18 @@ export default { ...@@ -231,10 +242,18 @@ export default {
> >
</div> </div>
<content-viewer <content-viewer
v-if="shouldHideEditor || file.viewMode === 'preview'" v-if="showContentViewer"
:content="file.content || file.raw" :content="file.content || file.raw"
:path="file.rawPath || file.path" :path="file.rawPath || file.path"
:file-size="file.size" :file-size="file.size"
:project-path="file.projectId"/> :project-path="file.projectId"/>
<diff-viewer
v-if="showDiffViewer"
:diff-mode="file.mrChange.diffMode"
:new-path="file.mrChange.new_path"
:new-sha="currentMergeRequest.sha"
:old-path="file.mrChange.old_path"
:old-sha="currentMergeRequest.baseCommitSha"
:project-path="file.projectId"/>
</div> </div>
</template> </template>
...@@ -21,7 +21,19 @@ export const viewerTypes = { ...@@ -21,7 +21,19 @@ export const viewerTypes = {
diff: 'diff', diff: 'diff',
}; };
export const diffModes = {
replaced: 'replaced',
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
};
export const rightSidebarViews = { export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail', jobsDetail: 'jobs-detail',
}; };
export const stageKeys = {
unstaged: 'unstaged',
staged: 'staged',
};
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import { diffModes } from '../../constants';
export default { export default {
[types.SET_FILE_ACTIVE](state, { path, active }) { [types.SET_FILE_ACTIVE](state, { path, active }) {
...@@ -85,8 +86,19 @@ export default { ...@@ -85,8 +86,19 @@ export default {
}); });
}, },
[types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) {
let diffMode = diffModes.replaced;
if (mrChange.new_file) {
diffMode = diffModes.new;
} else if (mrChange.deleted_file) {
diffMode = diffModes.deleted;
} else if (mrChange.renamed_file) {
diffMode = diffModes.renamed;
}
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
mrChange, mrChange: {
...mrChange,
diffMode,
},
}); });
}, },
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) { [types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
......
...@@ -39,7 +39,7 @@ export const dataStructure = () => ({ ...@@ -39,7 +39,7 @@ export const dataStructure = () => ({
editorColumn: 1, editorColumn: 1,
fileLanguage: '', fileLanguage: '',
eol: '', eol: '',
viewMode: 'edit', viewMode: 'editor',
previewMode: null, previewMode: null,
size: 0, size: 0,
parentPath: null, parentPath: null,
......
/* eslint-disable no-param-reassign, comma-dangle */ /* eslint-disable no-param-reassign */
import Vue from 'vue'; import Vue from 'vue';
import actionsMixin from '../mixins/line_conflict_actions';
import utilsMixin from '../mixins/line_conflict_utils';
((global) => { (global => {
global.mergeConflicts = global.mergeConflicts || {}; global.mergeConflicts = global.mergeConflicts || {};
global.mergeConflicts.inlineConflictLines = Vue.extend({ global.mergeConflicts.inlineConflictLines = Vue.extend({
<<<<<<< HEAD
mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions], mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions],
=======
mixins: [utilsMixin, actionsMixin],
>>>>>>> upstream/master
props: { props: {
file: { file: {
type: Object, type: Object,
......
/* eslint-disable no-param-reassign, comma-dangle */ /* eslint-disable no-param-reassign, comma-dangle */
import Vue from 'vue'; import Vue from 'vue';
import actionsMixin from '../mixins/line_conflict_actions';
import utilsMixin from '../mixins/line_conflict_utils';
((global) => { ((global) => {
global.mergeConflicts = global.mergeConflicts || {}; global.mergeConflicts = global.mergeConflicts || {};
global.mergeConflicts.parallelConflictLines = Vue.extend({ global.mergeConflicts.parallelConflictLines = Vue.extend({
<<<<<<< HEAD
mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions], mixins: [global.mergeConflicts.utils, global.mergeConflicts.actions],
=======
mixins: [utilsMixin, actionsMixin],
>>>>>>> upstream/master
props: { props: {
file: { file: {
type: Object, type: Object,
......
/* eslint-disable no-param-reassign, comma-dangle */
import axios from '../lib/utils/axios_utils'; import axios from '../lib/utils/axios_utils';
((global) => { export default class MergeConflictsService {
global.mergeConflicts = global.mergeConflicts || {}; constructor(options) {
this.conflictsPath = options.conflictsPath;
class mergeConflictsService { this.resolveConflictsPath = options.resolveConflictsPath;
constructor(options) { }
this.conflictsPath = options.conflictsPath;
this.resolveConflictsPath = options.resolveConflictsPath;
}
fetchConflictsData() {
return axios.get(this.conflictsPath);
}
submitResolveConflicts(data) { fetchConflictsData() {
return axios.post(this.resolveConflictsPath, data); return axios.get(this.conflictsPath);
}
} }
global.mergeConflicts.mergeConflictsService = mergeConflictsService; submitResolveConflicts(data) {
})(window.gl || (window.gl = {})); return axios.post(this.resolveConflictsPath, data);
}
}
...@@ -5,7 +5,7 @@ import Vue from 'vue'; ...@@ -5,7 +5,7 @@ import Vue from 'vue';
import Flash from '../flash'; import Flash from '../flash';
import initIssuableSidebar from '../init_issuable_sidebar'; import initIssuableSidebar from '../init_issuable_sidebar';
import './merge_conflict_store'; import './merge_conflict_store';
import './merge_conflict_service'; import MergeConflictsService from './merge_conflict_service';
import './mixins/line_conflict_utils'; import './mixins/line_conflict_utils';
import './mixins/line_conflict_actions'; import './mixins/line_conflict_actions';
import './components/diff_file_editor'; import './components/diff_file_editor';
...@@ -17,7 +17,7 @@ export default function initMergeConflicts() { ...@@ -17,7 +17,7 @@ export default function initMergeConflicts() {
const INTERACTIVE_RESOLVE_MODE = 'interactive'; const INTERACTIVE_RESOLVE_MODE = 'interactive';
const conflictsEl = document.querySelector('#conflicts'); const conflictsEl = document.querySelector('#conflicts');
const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore; const mergeConflictsStore = gl.mergeConflicts.mergeConflictsStore;
const mergeConflictsService = new gl.mergeConflicts.mergeConflictsService({ const mergeConflictsService = new MergeConflictsService({
conflictsPath: conflictsEl.dataset.conflictsPath, conflictsPath: conflictsEl.dataset.conflictsPath,
resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath resolveConflictsPath: conflictsEl.dataset.resolveConflictsPath
}); });
......
/* eslint-disable no-param-reassign, comma-dangle */ export default {
methods: {
((global) => { handleSelected(file, sectionId, selection) {
global.mergeConflicts = global.mergeConflicts || {}; gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection);
},
global.mergeConflicts.actions = { },
methods: { };
handleSelected(file, sectionId, selection) {
gl.mergeConflicts.mergeConflictsStore.handleSelected(file, sectionId, selection);
}
}
};
})(window.gl || (window.gl = {}));
/* eslint-disable no-param-reassign, quote-props, comma-dangle */ export default {
methods: {
((global) => { lineCssClass(line) {
global.mergeConflicts = global.mergeConflicts || {}; return {
head: line.isHead,
global.mergeConflicts.utils = { origin: line.isOrigin,
methods: { match: line.hasMatch,
lineCssClass(line) { selected: line.isSelected,
return { unselected: line.isUnselected,
'head': line.isHead, };
'origin': line.isOrigin, },
'match': line.hasMatch, },
'selected': line.isSelected, };
'unselected': line.isUnselected
};
}
}
};
})(window.gl || (window.gl = {}));
...@@ -56,7 +56,7 @@ export default class MilestoneSelect { ...@@ -56,7 +56,7 @@ export default class MilestoneSelect {
if (issueUpdateURL) { if (issueUpdateURL) {
milestoneLinkTemplate = _.template( milestoneLinkTemplate = _.template(
'<a href="/<%- full_path %>/milestones/<%- iid %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>', '<a href="<%- web_url %>" class="bold has-tooltip" data-container="body" title="<%- remaining %>"><%- title %></a>',
); );
milestoneLinkNoneTemplate = '<span class="no-value">None</span>'; milestoneLinkNoneTemplate = '<span class="no-value">None</span>';
} }
......
...@@ -62,13 +62,13 @@ export default class UsernameValidator { ...@@ -62,13 +62,13 @@ export default class UsernameValidator {
return this.setPendingState(); return this.setPendingState();
} }
if (!this.state.available) {
return this.setUnavailableState();
}
if (!this.state.valid) { if (!this.state.valid) {
return this.setInvalidState(); return this.setInvalidState();
} }
if (!this.state.available) {
return this.setUnavailableState();
}
} }
interceptInvalid(event) { interceptInvalid(event) {
...@@ -89,7 +89,6 @@ export default class UsernameValidator { ...@@ -89,7 +89,6 @@ export default class UsernameValidator {
setAvailabilityState(usernameTaken) { setAvailabilityState(usernameTaken) {
if (usernameTaken) { if (usernameTaken) {
this.state.valid = false;
this.state.available = false; this.state.available = false;
} else { } else {
this.state.available = true; this.state.available = true;
......
...@@ -67,9 +67,13 @@ export default { ...@@ -67,9 +67,13 @@ export default {
<li <li
v-for="(job, index) in jobs" v-for="(job, index) in jobs"
:key="job.id" :key="job.id"
<<<<<<< HEAD
:class="{ :class="{
'left-connector': index === 0 && (!isFirstColumn || hasTriggeredBy) 'left-connector': index === 0 && (!isFirstColumn || hasTriggeredBy)
}" }"
=======
:class="buildConnnectorClass(index)"
>>>>>>> upstream/master
:id="jobId(job)" :id="jobId(job)"
class="build" class="build"
> >
......
...@@ -58,6 +58,12 @@ export default { ...@@ -58,6 +58,12 @@ export default {
:title="timeRemainingTooltip" :title="timeRemainingTooltip"
:class="timeRemainingStatusClass" :class="timeRemainingStatusClass"
class="compare-meter" class="compare-meter"
<<<<<<< HEAD
=======
data-toggle="tooltip"
data-placement="top"
role="timeRemainingDisplay"
>>>>>>> upstream/master
> >
<div <div
:aria-valuenow="timeRemainingPercent" :aria-valuenow="timeRemainingPercent"
......
...@@ -109,6 +109,7 @@ export default { ...@@ -109,6 +109,7 @@ export default {
</template> </template>
<span class="mr-widget-pipeline-graph"> <span class="mr-widget-pipeline-graph">
<<<<<<< HEAD
<span class="stage-cell"> <span class="stage-cell">
<linked-pipelines-mini-list <linked-pipelines-mini-list
v-if="triggeredBy.length" v-if="triggeredBy.length"
...@@ -131,6 +132,19 @@ export default { ...@@ -131,6 +132,19 @@ export default {
v-if="triggered.length" v-if="triggered.length"
:triggered="triggered" :triggered="triggered"
/> />
=======
<span
v-if="hasStages"
class="stage-cell"
>
<div
v-for="(stage, i) in pipeline.details.stages"
:key="i"
class="stage-container dropdown js-mini-pipeline-graph"
>
<pipeline-stage :stage="stage" />
</div>
>>>>>>> upstream/master
</span> </span>
</span> </span>
......
...@@ -32,7 +32,10 @@ export default { ...@@ -32,7 +32,10 @@ export default {
<div class="file-container"> <div class="file-container">
<div class="file-content"> <div class="file-content">
<p class="prepend-top-10 file-info"> <p class="prepend-top-10 file-info">
{{ fileName }} ({{ fileSizeReadable }}) {{ fileName }}
<template v-if="fileSize > 0">
({{ fileSizeReadable }})
</template>
</p> </p>
<a <a
:href="path" :href="path"
......
<script> <script>
import _ from 'underscore';
import { numberToHumanSize } from '../../../../lib/utils/number_utils'; import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default { export default {
...@@ -12,6 +13,10 @@ export default { ...@@ -12,6 +13,10 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
renderInfo: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {
...@@ -26,14 +31,34 @@ export default { ...@@ -26,14 +31,34 @@ export default {
return numberToHumanSize(this.fileSize); return numberToHumanSize(this.fileSize);
}, },
}, },
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
},
mounted() {
// The onImgLoad may have happened before the control was actually mounted
this.onImgLoad();
this.resizeThrottled = _.throttle(this.onImgLoad, 400);
window.addEventListener('resize', this.resizeThrottled, false);
},
methods: { methods: {
onImgLoad() { onImgLoad() {
const contentImg = this.$refs.contentImg; const contentImg = this.$refs.contentImg;
this.isZoomable =
contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height;
this.width = contentImg.naturalWidth; if (contentImg) {
this.height = contentImg.naturalHeight; this.isZoomable =
contentImg.naturalWidth > contentImg.width ||
contentImg.naturalHeight > contentImg.height;
this.width = contentImg.naturalWidth;
this.height = contentImg.naturalHeight;
this.$emit('imgLoaded', {
width: this.width,
height: this.height,
renderedWidth: contentImg.clientWidth,
renderedHeight: contentImg.clientHeight,
});
}
}, },
onImgClick() { onImgClick() {
if (this.isZoomable) this.isZoomed = !this.isZoomed; if (this.isZoomable) this.isZoomed = !this.isZoomed;
...@@ -47,20 +72,22 @@ export default { ...@@ -47,20 +72,22 @@ export default {
<div class="file-content image_file"> <div class="file-content image_file">
<img <img
ref="contentImg" ref="contentImg"
:class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }" :class="{ 'is-zoomable': isZoomable, 'is-zoomed': isZoomed }"
:src="path" :src="path"
:alt="path" :alt="path"
@load="onImgLoad" @load="onImgLoad"
@click="onImgClick"/> @click="onImgClick"/>
<p class="file-info prepend-top-10"> <p
v-if="renderInfo"
class="file-info prepend-top-10">
<template v-if="fileSize>0"> <template v-if="fileSize>0">
{{ fileSizeReadable }} {{ fileSizeReadable }}
</template> </template>
<template v-if="fileSize>0 && width && height"> <template v-if="fileSize>0 && width && height">
- |
</template> </template>
<template v-if="width && height"> <template v-if="width && height">
{{ width }} x {{ height }} W: {{ width }} | H: {{ height }}
</template> </template>
</p> </p>
</div> </div>
......
export const diffModes = {
replaced: 'replaced',
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
};
export const imageViewMode = {
twoup: 'twoup',
swipe: 'swipe',
onion: 'onion',
};
<script>
import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils';
import ImageDiffViewer from './viewers/image_diff_viewer.vue';
import DownloadDiffViewer from './viewers/download_diff_viewer.vue';
export default {
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
newSha: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
oldSha: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
computed: {
viewer() {
if (!this.newPath) return null;
const previewInfo = viewerInformationForPath(this.newPath);
if (!previewInfo) return DownloadDiffViewer;
switch (previewInfo.id) {
case 'image':
return ImageDiffViewer;
default:
return DownloadDiffViewer;
}
},
fullOldPath() {
return `${gon.relative_url_root}/${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`;
},
fullNewPath() {
return `${gon.relative_url_root}/${this.projectPath}/raw/${this.newSha}/${this.newPath}`;
},
},
};
</script>
<template>
<div
v-if="viewer"
class="diff-file preview-container">
<component
:is="viewer"
:diff-mode="diffMode"
:new-path="fullNewPath"
:old-path="fullOldPath"
:project-path="projectPath"
/>
</div>
</template>
<script>
import DownloadViewer from '../../content_viewer/viewers/download_viewer.vue';
import { diffModes } from '../constants';
export default {
components: {
DownloadViewer,
},
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
diffModes,
};
</script>
<template>
<div class="diff-file-container">
<div class="diff-viewer">
<div
v-if="diffMode === $options.diffModes.replaced"
class="two-up view row">
<div class="col-sm-6 deleted">
<download-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
<div class="col-sm-6 added">
<download-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
</div>
<div
v-else-if="diffMode === $options.diffModes.new"
class="added">
<download-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
<div
v-else
class="deleted">
<download-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
</div>
</div>
</template>
<script>
import { pixeliseValue } from '../../../lib/utils/dom_utils';
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
onionMaxWidth: undefined,
onionMaxHeight: undefined,
onionOldImgInfo: null,
onionNewImgInfo: null,
onionDraggerPos: 0,
onionOpacity: 1,
dragging: false,
};
},
computed: {
onionMaxPixelWidth() {
return pixeliseValue(this.onionMaxWidth);
},
onionMaxPixelHeight() {
return pixeliseValue(this.onionMaxHeight);
},
onionDraggerPixelPos() {
return pixeliseValue(this.onionDraggerPos);
},
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.stopDrag);
this.$refs.dragger.removeEventListener('mousedown', this.startDrag);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left;
const dragTrackWidth =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
let leftValue = left;
if (leftValue < 0) leftValue = 0;
if (leftValue > dragTrackWidth) leftValue = dragTrackWidth;
this.onionOpacity = left / dragTrackWidth;
this.onionDraggerPos = leftValue;
},
startDrag() {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
},
prepareOnionSkin() {
if (this.onionOldImgInfo && this.onionNewImgInfo) {
this.onionMaxWidth = Math.max(
this.onionOldImgInfo.renderedWidth,
this.onionNewImgInfo.renderedWidth,
);
this.onionMaxHeight = Math.max(
this.onionOldImgInfo.renderedHeight,
this.onionNewImgInfo.renderedHeight,
);
this.onionOpacity = 1;
this.onionDraggerPos =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
document.body.addEventListener('mouseup', this.stopDrag);
}
},
onionNewImgLoaded(imgInfo) {
this.onionNewImgInfo = imgInfo;
this.prepareOnionSkin();
},
onionOldImgLoaded(imgInfo) {
this.onionOldImgInfo = imgInfo;
this.prepareOnionSkin();
},
},
};
</script>
<template>
<div class="onion-skin view">
<div
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
'user-select': dragging === true ? 'none' : '',
}"
class="onion-skin-frame">
<div
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}"
class="frame deleted">
<image-viewer
key="onionOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="onionOldImgLoaded"
/>
</div>
<div
ref="addedFrame"
:style="{
'opacity': onionOpacity,
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}"
class="added frame">
<image-viewer
key="onionNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="onionNewImgLoaded"
/>
</div>
<div class="controls">
<div class="transparent"></div>
<div
ref="dragTrack"
class="drag-track"
@mousedown="startDrag"
@mouseup="stopDrag">
<div
ref="dragger"
:style="{ 'left': onionDraggerPixelPos }"
class="dragger">
</div>
</div>
<div class="opaque"></div>
</div>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { pixeliseValue } from '../../../lib/utils/dom_utils';
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
dragging: false,
swipeOldImgInfo: null,
swipeNewImgInfo: null,
swipeMaxWidth: undefined,
swipeMaxHeight: undefined,
swipeBarPos: 1,
swipeWrapWidth: undefined,
};
},
computed: {
swipeMaxPixelWidth() {
return pixeliseValue(this.swipeMaxWidth);
},
swipeMaxPixelHeight() {
return pixeliseValue(this.swipeMaxHeight);
},
swipeWrapPixelWidth() {
return pixeliseValue(this.swipeWrapWidth);
},
swipeBarPixelPos() {
return pixeliseValue(this.swipeBarPos);
},
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
document.body.removeEventListener('mouseup', this.stopDrag);
document.body.removeEventListener('mousemove', this.dragMove);
},
mounted() {
window.addEventListener('resize', this.resize, false);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left;
const spaceLeft = 20;
const { clientWidth } = this.$refs.swipeFrame;
if (leftValue <= 0) {
leftValue = 0;
} else if (leftValue > clientWidth - spaceLeft) {
leftValue = clientWidth - spaceLeft;
}
this.swipeWrapWidth = this.swipeMaxWidth - leftValue;
this.swipeBarPos = leftValue;
},
startDrag() {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
},
prepareSwipe() {
if (this.swipeOldImgInfo && this.swipeNewImgInfo) {
// Add 2 for border width
this.swipeMaxWidth =
Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2;
this.swipeWrapWidth = this.swipeMaxWidth;
this.swipeMaxHeight =
Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2;
document.body.addEventListener('mouseup', this.stopDrag);
}
},
swipeNewImgLoaded(imgInfo) {
this.swipeNewImgInfo = imgInfo;
this.prepareSwipe();
},
swipeOldImgLoaded(imgInfo) {
this.swipeOldImgInfo = imgInfo;
this.prepareSwipe();
},
resize: _.throttle(function throttledResize() {
this.swipeBarPos = 0;
}, 400),
},
};
</script>
<template>
<div class="swipe view">
<div
ref="swipeFrame"
:style="{
'width': swipeMaxPixelWidth,
'height': swipeMaxPixelHeight,
}"
class="swipe-frame">
<div class="frame deleted">
<image-viewer
key="swipeOldImg"
ref="swipeOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="swipeOldImgLoaded"
/>
</div>
<div
ref="swipeWrap"
:style="{
'width': swipeWrapPixelWidth,
'height': swipeMaxPixelHeight,
}"
class="swipe-wrap">
<div class="frame added">
<image-viewer
key="swipeNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="swipeNewImgLoaded"
/>
</div>
</div>
<span
ref="swipeBar"
:style="{ 'left': swipeBarPixelPos }"
class="swipe-bar"
@mousedown="startDrag"
@mouseup="stopDrag">
<span class="top-handle"></span>
<span class="bottom-handle"></span>
</span>
</div>
</div>
</template>
<script>
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div class="two-up view row">
<div class="col-sm-6 frame deleted">
<image-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
<div class="col-sm-6 frame added">
<image-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
</div>
</template>
<script>
import ImageViewer from '../../content_viewer/viewers/image_viewer.vue';
import TwoUpViewer from './image_diff/two_up_viewer.vue';
import SwipeViewer from './image_diff/swipe_viewer.vue';
import OnionSkinViewer from './image_diff/onion_skin_viewer.vue';
import { diffModes, imageViewMode } from '../constants';
export default {
components: {
ImageViewer,
TwoUpViewer,
SwipeViewer,
OnionSkinViewer,
},
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
mode: imageViewMode.twoup,
};
},
methods: {
changeMode(newMode) {
this.mode = newMode;
},
},
diffModes,
imageViewMode,
};
</script>
<template>
<div class="diff-file-container">
<div
v-if="diffMode === $options.diffModes.replaced"
class="diff-viewer">
<div class="image js-replaced-image">
<two-up-viewer
v-if="mode === $options.imageViewMode.twoup"
v-bind="$props"/>
<swipe-viewer
v-else-if="mode === $options.imageViewMode.swipe"
v-bind="$props"/>
<onion-skin-viewer
v-else-if="mode === $options.imageViewMode.onion"
v-bind="$props"/>
</div>
<div class="view-modes">
<ul class="view-modes-menu">
<li
:class="{
active: mode === $options.imageViewMode.twoup
}"
@click="changeMode($options.imageViewMode.twoup)">
{{ s__('ImageDiffViewer|2-up') }}
</li>
<li
:class="{
active: mode === $options.imageViewMode.swipe
}"
@click="changeMode($options.imageViewMode.swipe)">
{{ s__('ImageDiffViewer|Swipe') }}
</li>
<li
:class="{
active: mode === $options.imageViewMode.onion
}"
@click="changeMode($options.imageViewMode.onion)">
{{ s__('ImageDiffViewer|Onion skin') }}
</li>
</ul>
</div>
<div class="note-container"></div>
</div>
<div
v-else-if="diffMode === $options.diffModes.new"
class="diff-viewer added">
<image-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
<div
v-else
class="diff-viewer deleted">
<image-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
</div>
</template>
export function pixeliseValue(val) {
return val ? `${val}px` : '';
}
export default {};
...@@ -41,7 +41,10 @@ export default { ...@@ -41,7 +41,10 @@ export default {
class="sidebar-collapsed-icon" class="sidebar-collapsed-icon"
data-placement="left" data-placement="left"
data-container="body" data-container="body"
<<<<<<< HEAD
data-boundary="viewport" data-boundary="viewport"
=======
>>>>>>> upstream/master
@click="handleClick" @click="handleClick"
> >
<i <i
......
...@@ -34,7 +34,10 @@ export default { ...@@ -34,7 +34,10 @@ export default {
class="btn btn-blank gutter-toggle btn-sidebar-action" class="btn btn-blank gutter-toggle btn-sidebar-action"
data-container="body" data-container="body"
data-placement="left" data-placement="left"
<<<<<<< HEAD
data-boundary="viewport" data-boundary="viewport"
=======
>>>>>>> upstream/master
@click="toggle" @click="toggle"
> >
<i <i
......
...@@ -99,5 +99,9 @@ export default { ...@@ -99,5 +99,9 @@ export default {
:data-placement="tooltipPlacement" :data-placement="tooltipPlacement"
:title="tooltipText" :title="tooltipText"
class="avatar" class="avatar"
<<<<<<< HEAD
=======
data-boundary="window"
>>>>>>> upstream/master
/> />
</template> </template>
...@@ -400,3 +400,51 @@ span.idiff { ...@@ -400,3 +400,51 @@ span.idiff {
color: $common-gray-light; color: $common-gray-light;
border: 1px solid $common-gray-light; border: 1px solid $common-gray-light;
} }
.preview-container {
height: 100%;
overflow: auto;
.file-container {
background-color: $gray-darker;
display: flex;
height: 100%;
align-items: center;
justify-content: center;
text-align: center;
.file-content {
padding: $gl-padding;
max-width: 100%;
max-height: 100%;
img {
max-width: 90%;
max-height: 70vh;
}
.is-zoomable {
cursor: pointer;
cursor: zoom-in;
&.is-zoomed {
cursor: pointer;
cursor: zoom-out;
max-width: none;
max-height: none;
margin-right: $gl-padding;
}
}
}
.file-info {
font-size: $label-font-size;
color: $diff-image-info-color;
}
}
.md-previewer {
padding: $gl-padding;
}
}
...@@ -186,6 +186,7 @@ ...@@ -186,6 +186,7 @@
overflow-y: hidden; overflow-y: hidden;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
display: flex; display: flex;
flex-wrap: nowrap;
&::-webkit-scrollbar { &::-webkit-scrollbar {
display: none; display: none;
......
...@@ -257,6 +257,8 @@ ...@@ -257,6 +257,8 @@
} }
.scrolling-tabs-container { .scrolling-tabs-container {
position: relative;
.merge-request-tabs-container & { .merge-request-tabs-container & {
overflow: hidden; overflow: hidden;
} }
......
...@@ -58,7 +58,7 @@ table { ...@@ -58,7 +58,7 @@ table {
display: none; display: none;
} }
table, &,
tbody, tbody,
td { td {
display: block; display: block;
......
...@@ -189,8 +189,22 @@ ...@@ -189,8 +189,22 @@
img { img {
border: 1px solid $white-light; border: 1px solid $white-light;
background-image: linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%), background-image: linear-gradient(
linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%); 45deg,
$border-color 25%,
transparent 25%,
transparent 75%,
$border-color 75%,
$border-color 100%
),
linear-gradient(
45deg,
$border-color 25%,
transparent 25%,
transparent 75%,
$border-color 75%,
$border-color 100%
);
background-size: 10px 10px; background-size: 10px 10px;
background-position: 0 0, 5px 5px; background-position: 0 0, 5px 5px;
max-width: 100%; max-width: 100%;
...@@ -395,6 +409,69 @@ ...@@ -395,6 +409,69 @@
.line_content { .line_content {
white-space: pre-wrap; white-space: pre-wrap;
} }
.diff-file-container {
.frame.deleted {
border: 0;
background-color: inherit;
.image_file img {
border: 1px solid $deleted;
}
}
.frame.added {
border: 0;
background-color: inherit;
.image_file img {
border: 1px solid $added;
}
}
.swipe.view,
.onion-skin.view {
.swipe-wrap {
top: 0;
right: 0;
}
.frame.deleted {
top: 0;
right: 0;
}
.swipe-bar {
top: 0;
.top-handle {
top: -14px;
left: -7px;
}
.bottom-handle {
bottom: -14px;
left: -7px;
}
}
.file-container {
display: inline-block;
.file-content {
padding: 0;
img {
max-width: none;
}
}
}
}
.onion-skin.view .controls {
bottom: -25px;
}
}
} }
.file-content .diff-file { .file-content .diff-file {
...@@ -536,7 +613,7 @@ ...@@ -536,7 +613,7 @@
margin-right: 0; margin-right: 0;
border-color: $white-light; border-color: $white-light;
cursor: pointer; cursor: pointer;
transition: all .1s ease-out; transition: all 0.1s ease-out;
@for $i from 1 through 4 { @for $i from 1 through 4 {
&:nth-child(#{$i}) { &:nth-child(#{$i}) {
...@@ -563,7 +640,7 @@ ...@@ -563,7 +640,7 @@
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
padding: 0; padding: 0;
transition: transform .1s ease-out; transition: transform 0.1s ease-out;
z-index: 100; z-index: 100;
.collapse-icon { .collapse-icon {
...@@ -708,11 +785,35 @@ ...@@ -708,11 +785,35 @@
width: 100%; width: 100%;
height: 10px; height: 10px;
background-color: $white-light; background-color: $white-light;
background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), background-image: linear-gradient(
linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), 45deg,
linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), transparent,
linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%); transparent 73%,
background-position: 5px 5px,0 5px,0 5px,5px 5px; $diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
225deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
135deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
-45deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
);
background-position: 5px 5px, 0 5px, 0 5px, 5px 5px;
background-size: 10px 10px; background-size: 10px 10px;
background-repeat: repeat; background-repeat: repeat;
} }
...@@ -750,11 +851,16 @@ ...@@ -750,11 +851,16 @@
.frame.click-to-comment { .frame.click-to-comment {
position: relative; position: relative;
cursor: image-url('illustrations/image_comment_light_cursor.svg') cursor: image-url('illustrations/image_comment_light_cursor.svg')
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor // Retina cursor
cursor: -webkit-image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) cursor: -webkit-image-set(
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; image-url('illustrations/image_comment_light_cursor.svg') 1x,
image-url('illustrations/image_comment_light_cursor@2x.svg') 2x
)
$image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator { .comment-indicator {
position: absolute; position: absolute;
...@@ -840,7 +946,7 @@ ...@@ -840,7 +946,7 @@
.diff-notes-collapse, .diff-notes-collapse,
.note, .note,
.discussion-reply-holder, { .discussion-reply-holder {
display: none; display: none;
} }
......
...@@ -670,6 +670,7 @@ ...@@ -670,6 +670,7 @@
.merge-request-tabs { .merge-request-tabs {
display: flex; display: flex;
flex-wrap: nowrap;
margin-bottom: 0; margin-bottom: 0;
padding: 0; padding: 0;
} }
......
...@@ -354,12 +354,6 @@ ...@@ -354,12 +354,6 @@
min-width: 200px; min-width: 200px;
} }
.deploy-keys {
.scrolling-tabs-container {
position: relative;
}
}
.deploy-key { .deploy-key {
// Ensure that the fingerprint does not overflow on small screens // Ensure that the fingerprint does not overflow on small screens
.fingerprint { .fingerprint {
......
...@@ -335,7 +335,6 @@ ...@@ -335,7 +335,6 @@
img { img {
max-width: 90%; max-width: 90%;
max-height: 90%;
} }
.isZoomable { .isZoomable {
...@@ -553,6 +552,10 @@ ...@@ -553,6 +552,10 @@
} }
.multi-file-commit-list-item { .multi-file-commit-list-item {
&.is-active {
background-color: $white-normal;
}
.multi-file-discard-btn { .multi-file-discard-btn {
display: none; display: none;
margin-top: -2px; margin-top: -2px;
......
...@@ -292,8 +292,10 @@ class ApplicationController < ActionController::Base ...@@ -292,8 +292,10 @@ class ApplicationController < ActionController::Base
return unless current_user return unless current_user
return if current_user.terms_accepted? return if current_user.terms_accepted?
message = _("Please accept the Terms of Service before continuing.")
if sessionless_user? if sessionless_user?
render_403 access_denied!(message)
else else
# Redirect to the destination if the request is a get. # Redirect to the destination if the request is a get.
# Redirect to the source if it was a post, so the user can re-submit after # Redirect to the source if it was a post, so the user can re-submit after
...@@ -304,7 +306,7 @@ class ApplicationController < ActionController::Base ...@@ -304,7 +306,7 @@ class ApplicationController < ActionController::Base
URI(request.referer).path if request.referer URI(request.referer).path if request.referer
end end
flash[:notice] = _("Please accept the Terms of Service before continuing.") flash[:notice] = message
redirect_to terms_path(redirect: redirect_path), status: :found redirect_to terms_path(redirect: redirect_path), status: :found
end end
end end
......
...@@ -562,6 +562,17 @@ module QuickActions ...@@ -562,6 +562,17 @@ module QuickActions
end end
end end
desc 'Make issue confidential.'
explanation do
'Makes this issue confidential'
end
condition do
issuable.is_a?(Issue) && current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
command :confidential do
@updates[:confidential] = true
end
def extract_users(params) def extract_users(params)
return [] if params.nil? return [] if params.nil?
......
...@@ -73,6 +73,15 @@ module ObjectStorage ...@@ -73,6 +73,15 @@ module ObjectStorage
upload.id) upload.id)
end end
def exclusive_lease_key
# For FileUploaders, model may have many uploaders. In that case
# we want to use exclusive key per upload, not per model to allow
# parallel migration
key_object = upload || model
"object_storage_migrate:#{key_object.class}:#{key_object.id}"
end
private private
def current_upload_satisfies?(paths, model) def current_upload_satisfies?(paths, model)
...@@ -316,6 +325,10 @@ module ObjectStorage ...@@ -316,6 +325,10 @@ module ObjectStorage
super super
end end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
private private
def schedule_background_upload? def schedule_background_upload?
...@@ -382,10 +395,6 @@ module ObjectStorage ...@@ -382,10 +395,6 @@ module ObjectStorage
end end
end end
def exclusive_lease_key
"object_storage_migrate:#{model.class}:#{model.id}"
end
def with_exclusive_lease def with_exclusive_lease
lease_key = exclusive_lease_key lease_key = exclusive_lease_key
uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: 1.hour.to_i).try_obtain
......
...@@ -12,9 +12,9 @@ ...@@ -12,9 +12,9 @@
- if can?(current_user, :award_emoji, awardable) - if can?(current_user, :award_emoji, awardable)
.award-menu-holder.js-award-holder .award-menu-holder.js-award-holder
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button', %button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
'aria-label': 'Add reaction', 'aria-label': _('Add reaction'),
class: ("js-user-authored" if user_authored), class: ("js-user-authored" if user_authored),
data: { title: 'Add reaction', placement: "bottom" } } data: { title: _('Add reaction'), placement: "bottom" } }
%span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face') %span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
%span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley') %span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
%span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile') %span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
......
- message = local_assigns.fetch(:message) - message = local_assigns.fetch(:message, nil)
- content_for(:title, 'Access Denied') - content_for(:title, 'Access Denied')
= image_tag('illustrations/error-403.svg', alt: '403', lazy: false) = image_tag('illustrations/error-403.svg', alt: '403', lazy: false)
......
...@@ -32,26 +32,25 @@ ...@@ -32,26 +32,25 @@
.scrolling-tabs-container.inner-page-scroll-tabs.is-smaller .scrolling-tabs-container.inner-page-scroll-tabs.is-smaller
.fade-left= icon('angle-left') .fade-left= icon('angle-left')
.fade-right= icon('angle-right') .fade-right= icon('angle-right')
.nav-links.scrolling-tabs.nav.nav-tabs %ul.merge-request-tabs.nav-tabs.nav.nav-links.scrolling-tabs
%ul.merge-request-tabs.nav-tabs.nav %li.notes-tab
%li.notes-tab = tab_link_for @merge_request, :show, force_link: @commit.present? do
= tab_link_for @merge_request, :show, force_link: @commit.present? do Discussion
Discussion %span.badge.badge-pill= @merge_request.related_notes.user.count
%span.badge.badge-pill= @merge_request.related_notes.user.count - if @merge_request.source_project
- if @merge_request.source_project %li.commits-tab
%li.commits-tab = tab_link_for @merge_request, :commits do
= tab_link_for @merge_request, :commits do Commits
Commits %span.badge.badge-pill= @commits_count
%span.badge.badge-pill= @commits_count - if @pipelines.any?
- if @pipelines.any? %li.pipelines-tab
%li.pipelines-tab = tab_link_for @merge_request, :pipelines do
= tab_link_for @merge_request, :pipelines do Pipelines
Pipelines %span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size
%span.badge.badge-pill.js-pipelines-mr-count= @pipelines.size %li.diffs-tab
%li.diffs-tab = tab_link_for @merge_request, :diffs do
= tab_link_for @merge_request, :diffs do Changes
Changes %span.badge.badge-pill= @merge_request.diff_size
%span.badge.badge-pill= @merge_request.diff_size
- if has_vue_discussions_cookie? - if has_vue_discussions_cookie?
#js-vue-discussion-counter #js-vue-discussion-counter
......
---
title: Expose visibility via Snippets API
merge_request: 19620
author: Jan Beckmann
type: added
---
title: 'Fix username validation order on signup, resolves #45575'
merge_request: 19610
author: Jan Beckmann
type: fixed
---
title: Make quick commands case insensitive
merge_request: 19614
author: Jan Beckmann
type: fixed
---
title: Add /confidential quick action
merge_request:
author: Jan Beckmann
type: added
---
title: Use upload ID for creating lease key for file uploaders.
merge_request:
author:
type: fixed
---
title: Add support for verifying remote uploads, artifacts, and LFS objects in check rake tasks
merge_request: 19501
author:
type: added
---
title: Adjust SQL and transaction Prometheus buckets
merge_request:
author:
type: other
---
title: Web IDE supports now Image + Download Diff Viewing
merge_request: 18768
author:
type: added
...@@ -78,9 +78,10 @@ Example output: ...@@ -78,9 +78,10 @@ Example output:
## Uploaded Files Integrity ## Uploaded Files Integrity
Various types of file can be uploaded to a GitLab installation by users. Various types of files can be uploaded to a GitLab installation by users.
Checksums are generated and stored in the database upon upload, and integrity These integrity checks can detect missing files. Additionally, for locally
checks using those checksums can be run. These checks also detect missing files. stored files, checksums are generated and stored in the database upon upload,
and these checks will verify them against current files.
Currently, integrity checks are supported for the following types of file: Currently, integrity checks are supported for the following types of file:
......
...@@ -49,6 +49,7 @@ Example response: ...@@ -49,6 +49,7 @@ Example response:
"title": "test", "title": "test",
"file_name": "add.rb", "file_name": "add.rb",
"description": "Ruby test snippet", "description": "Ruby test snippet",
"visibility": "private",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -99,6 +100,7 @@ Example response: ...@@ -99,6 +100,7 @@ Example response:
"title": "This is a snippet", "title": "This is a snippet",
"file_name": "test.txt", "file_name": "test.txt",
"description": "Hello World snippet", "description": "Hello World snippet",
"visibility": "internal",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -150,6 +152,7 @@ Example response: ...@@ -150,6 +152,7 @@ Example response:
"title": "test", "title": "test",
"file_name": "add.rb", "file_name": "add.rb",
"description": "description of snippet", "description": "description of snippet",
"visibility": "internal",
"author": { "author": {
"id": 1, "id": 1,
"username": "john_smith", "username": "john_smith",
...@@ -238,7 +241,8 @@ Example response: ...@@ -238,7 +241,8 @@ Example response:
"raw_url": "http://localhost:3000/snippets/48/raw", "raw_url": "http://localhost:3000/snippets/48/raw",
"title": "Minus similique nesciunt vel fugiat qui ullam sunt.", "title": "Minus similique nesciunt vel fugiat qui ullam sunt.",
"updated_at": "2016-11-25T16:53:34.479Z", "updated_at": "2016-11-25T16:53:34.479Z",
"web_url": "http://localhost:3000/snippets/48" "web_url": "http://localhost:3000/snippets/48",
"visibility": "public"
} }
] ]
``` ```
......
...@@ -45,6 +45,8 @@ the `author` field. GitLab team members **should not**. ...@@ -45,6 +45,8 @@ the `author` field. GitLab team members **should not**.
a changelog entry regardless of these guidelines if the contributor wants one. a changelog entry regardless of these guidelines if the contributor wants one.
Example: "Fixed a typo on the search results page. (Jane Smith)" Example: "Fixed a typo on the search results page. (Jane Smith)"
- Performance improvements **should** have a changelog entry. - Performance improvements **should** have a changelog entry.
- Any change that introduces a database migration **must** have a
changelog entry.
## Writing good changelog entries ## Writing good changelog entries
......
# Testing # Overview of Frontend Testing
> TODO: Add content ## Types of tests in our codebase
* **RSpec**
* **[Ruby unit tests](#ruby-unit-tests-spec-rb)** for models, controllers, helpers, etc. (`/spec/**/*.rb`)
* **[Full feature tests](#full-feature-tests-spec-features-rb)** (`/spec/features/**/*.rb`)
* **[Karma](#karma-tests-spec-javascripts-js)** (`/spec/javascripts/**/*.js`)
* ~~Spinach~~ — These have been removed from our codebase in May 2018. (`/features/`)
## RSpec: Ruby unit tests `/spec/**/*.rb`
These tests are meant to unit test the ruby models, controllers and helpers.
### When do we write/update these tests?
Whenever we create or modify any Ruby models, controllers or helpers we add/update corresponding tests.
---
## RSpec: Full feature tests `/spec/features/**/*.rb`
Full feature tests will load a full app environment and allow us to test things like rendering DOM, interacting with links and buttons, testing the outcome of those interactions through multiple pages if necessary. These are also called end-to-end tests but should not be confused with QA end-to-end tests (`package-and-qa` manual pipeline job).
### When do we write/update these tests?
When we add a new feature, we write at least two tests covering the success and the failure scenarios.
### Relevant notes
A `:js` flag is added to the test to make sure the full environment is loaded.
```
scenario 'successfully', :js do
sign_in(create(:admin))
end
```
The steps of each test are written using capybara methods ([documentation](http://www.rubydoc.info/gems/capybara/2.15.1)).
Bear in mind <abbr title="XMLHttpRequest">XHR</abbr> calls might require you to use `wait_for_requests` in between steps, like so:
```rspec
find('.form-control').native.send_keys(:enter)
wait_for_requests
expect(page).not_to have_selector('.card')
```
---
## Karma tests `/spec/javascripts/**/*.js`
These are the more frontend-focused, at the moment. They're **faster** than `rspec` and make for very quick testing of frontend components.
### When do we write/update these tests?
When we add/update a method/action/mutation to Vue or Vuex, we write karma tests to ensure the logic we wrote doesn't break. We should, however, refrain from writing tests that double-test Vue's internal features.
### Relevant notes
Karma tests are run against a virtual DOM.
To populate the DOM, we can use fixtures to fake the generation of HTML instead of having Rails do that.
Be sure to check the [best practices for karma tests](../../testing_guide/frontend_testing.html#best-practices).
### Vue and Vuex
Test as much as possible without double-testing Vue's internal features, as mentioned above.
Make sure to test computedProperties, mutations, actions. Run the action and test that the proper mutations are committed.
Also check these [notes on testing Vue components](../../fe_guide/vue.html#testing-vue-components).
#### Vuex Helper: `testAction`
We have a helper available to make testing actions easier, as per [official documentation](https://vuex.vuejs.org/en/testing.html):
```
testAction(
actions.actionName, // action
{ }, // params to be passed to action
state, // state
[
{ type: types.MUTATION},
{ type: types.MUTATION_1, payload: {}},
], // mutations committed
[
{ type: 'actionName', payload: {}},
{ type: 'actionName1', payload: {}},
] // actions dispatched
done,
);
```
Check an example in [spec/javascripts/ide/stores/actions_spec.jsspec/javascripts/ide/stores/actions_spec.js](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/spec/javascripts/ide/stores/actions_spec.js).
#### Vue Helper: `mountComponent`
To make mounting a Vue component easier and more readable, we have a few helpers available in `spec/helpers/vue_mount_component_helper`.
* `createComponentWithStore`
* `mountComponentWithStore`
Examples of usage:
```
beforeEach(() => {
vm = createComponentWithStore(Component, store);
vm.$store.state.currentBranchId = 'master';
vm.$mount();
},
```
```
beforeEach(() => {
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
props: { badge },
});
},
```
Don't forget to clean up:
```
afterEach(() => {
vm.$destroy();
});
```
...@@ -17,112 +17,197 @@ Depending on your operating system, you will need to use a shell of your prefere ...@@ -17,112 +17,197 @@ Depending on your operating system, you will need to use a shell of your prefere
Git is usually preinstalled on Mac and Linux. Git is usually preinstalled on Mac and Linux.
Type the following command and then press enter: Type the following command and then press enter:
```
```bash
git --version git --version
``` ```
You should receive a message that will tell you which Git version you have on your computer. If you don’t receive a "Git version" message, it means that you need to [download Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git). You should receive a message that tells you which Git version you have on your computer. If you don’t receive a "Git version" message, it means that you need to [download Git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git).
If Git doesn't automatically download, there's an option on the website to [download manually](https://git-scm.com/downloads). Then follow the steps on the installation window. If Git doesn't automatically download, there's an option on the website to [download manually](https://git-scm.com/downloads). Then follow the steps on the installation window.
After you are finished installing, open a new shell and type "git --version" again to verify that it was correctly installed. After you are finished installing Git, open a new shell and type `git --version` again to verify that it was correctly installed.
## Add your Git username and set your email ## Add your Git username and set your email
It is important to configure your Git username and email address as every Git commit will use this information to identify you as the author. It is important to configure your Git username and email address, since every Git commit will use this information to identify you as the author.
On your shell, type the following command to add your username: On your shell, type the following command to add your username:
```
```bash
git config --global user.name "YOUR_USERNAME" git config --global user.name "YOUR_USERNAME"
``` ```
Then verify that you have the correct username: Then verify that you have the correct username:
```
```bash
git config --global user.name git config --global user.name
``` ```
To set your email address, type the following command: To set your email address, type the following command:
```
```bash
git config --global user.email "your_email_address@example.com" git config --global user.email "your_email_address@example.com"
``` ```
To verify that you entered your email correctly, type: To verify that you entered your email correctly, type:
```
```bash
git config --global user.email git config --global user.email
``` ```
You'll need to do this only once as you are using the `--global` option. It tells Git to always use this information for anything you do on that system. If you want to override this with a different username or email address for specific projects, you can run the command without the `--global` option when you’re in that project. You'll need to do this only once, since you are using the `--global` option. It tells Git to always use this information for anything you do on that system. If you want to override this with a different username or email address for specific projects, you can run the command without the `--global` option when you’re in that project.
## Check your information ## Check your information
To view the information that you entered, type: To view the information that you entered, along with other global options, type:
```
```bash
git config --global --list git config --global --list
``` ```
## Basic Git commands ## Basic Git commands
### Go to the master branch to pull the latest changes from there ### Go to the master branch to pull the latest changes from there
``` ```bash
git checkout master git checkout master
``` ```
### Download the latest changes in the project ### Download the latest changes in the project
This is for you to work on an up-to-date copy (it is important to do every time you work on a project), while you setup tracking branches.
This is for you to work on an up-to-date copy (it is important to do this every time you start working on a project), while you set up tracking branches. You pull from remote repositories to get all the changes made by users since the last time you cloned or pulled the project. Later, you can push your local commits to the remote repositories.
```bash
git pull REMOTE NAME-OF-BRANCH
``` ```
git pull REMOTE NAME-OF-BRANCH -u
When you first clone a repository, REMOTE is typically "origin". This is where the repository came from, and it indicates the SSH or HTTPS URL of the repository on the remote server. NAME-OF-BRANCH is usually "master", but it may be any existing branch.
### View your remote repositories
To view your remote repositories, type:
```bash
git remote -v
``` ```
(REMOTE: origin) (NAME-OF-BRANCH: could be "master" or an existing branch)
### Create a branch ### Create a branch
Spaces won't be recognized, so you will need to use a hyphen or underscore.
``` To create a branch, type the following (spaces won't be recognized in the branch name, so you will need to use a hyphen or underscore):
```bash
git checkout -b NAME-OF-BRANCH git checkout -b NAME-OF-BRANCH
``` ```
### Work on a branch that has already been created ### Work on an existing branch
```
To switch to an existing branch, so you can work on it:
```bash
git checkout NAME-OF-BRANCH git checkout NAME-OF-BRANCH
``` ```
### View the changes you've made ### View the changes you've made
It's important to be aware of what's happening and what's the status of your changes.
``` It's important to be aware of what's happening and the status of your changes. When you add, change, or delete files/folders, Git knows about it. To check the status of your changes:
```bash
git status git status
``` ```
### Add changes to commit ### View differences
You'll see your changes in red when you type "git status".
To view the differences between your local, unstaged changes and the repository versions that you cloned or pulled, type:
```bash
git diff
```
### Add and commit local changes
You'll see your local changes in red when you type `git status`. These changes may be new, modified, or deleted files/folders. Use `git add` to stage a local file/folder for committing. Then use `git commit` to commit the staged files:
```bash
git add FILE OR FOLDER
git commit -m "COMMENT TO DESCRIBE THE INTENTION OF THE COMMIT"
``` ```
git add CHANGES IN RED
git commit -m "DESCRIBE THE INTENTION OF THE COMMIT" ### Add all changes to commit
To add and commit all local changes in one command:
```bash
git add .
git commit -m "COMMENT TO DESCRIBE THE INTENTION OF THE COMMIT"
``` ```
NOTE: **Note:**
The `.` character typically means _all_ in Git.
### Send changes to gitlab.com ### Send changes to gitlab.com
```
To push all local commits to the remote repository:
```bash
git push REMOTE NAME-OF-BRANCH git push REMOTE NAME-OF-BRANCH
``` ```
### Delete all changes in the Git repository, but leave unstaged things For example, to push your local commits to the _master_ branch of the _origin_ remote:
```bash
git push origin master
``` ```
### Delete all changes in the Git repository
To delete all local changes in the repository that have not been added to the staging area, and leave unstaged files/folders, type:
```bash
git checkout . git checkout .
``` ```
### Delete all changes in the Git repository, including untracked files ### Delete all untracked changes in the Git repository
```
```bash
git clean -f git clean -f
``` ```
### Unstage all changes that have been added to the staging area
To undo the most recent add, but not committed, files/folders:
```bash
git reset .
```
### Undo most recent commit
To undo the most recent commit, type:
```bash
git reset HEAD~1
```
This leaves the files and folders unstaged in your local repository.
CAUTION: **Warning:**
A Git commit is mostly irreversible, particularly if you already pushed it to the remote repository. Although you can undo a commit, the best option is to avoid the situation altogether.
### Merge created branch with master branch ### Merge created branch with master branch
You need to be in the created branch. You need to be in the created branch.
```
```bash
git checkout NAME-OF-BRANCH git checkout NAME-OF-BRANCH
git merge master git merge master
``` ```
### Merge master branch with created branch ### Merge master branch with created branch
You need to be in the master branch. You need to be in the master branch.
```
```bash
git checkout master git checkout master
git merge NAME-OF-BRANCH git merge NAME-OF-BRANCH
``` ```
...@@ -334,7 +334,11 @@ Issue Board, that is create/delete lists and drag issues around. ...@@ -334,7 +334,11 @@ Issue Board, that is create/delete lists and drag issues around.
## Group Issue Boards ## Group Issue Boards
<<<<<<< HEAD
> Introduced in GitLab 10.6 > Introduced in GitLab 10.6
=======
> Introduced in [GitLab 10.6](https://about.gitlab.com/2018/03/22/gitlab-10-6-released/#single-group-issue-board-in-core-and-free)
>>>>>>> upstream/master
Group Issue Board is analogous to project-level issue board and it is accessible at the group Group Issue Board is analogous to project-level issue board and it is accessible at the group
navigation level. A group-level issue board allows you to view all issues from all projects in that group navigation level. A group-level issue board allows you to view all issues from all projects in that group
...@@ -342,6 +346,7 @@ navigation level. A group-level issue board allows you to view all issues from a ...@@ -342,6 +346,7 @@ navigation level. A group-level issue board allows you to view all issues from a
boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only boards. When updating milestones and labels for an issue through the sidebar update mechanism, again only
group-level objects are available. group-level objects are available.
<<<<<<< HEAD
## Assignee Lists **[PREMIUM]** ## Assignee Lists **[PREMIUM]**
> Introduced in GitLab 11.0 Premium > Introduced in GitLab 11.0 Premium
...@@ -364,6 +369,9 @@ When dragging issues between lists, different behavior occurs depending on the s ...@@ -364,6 +369,9 @@ When dragging issues between lists, different behavior occurs depending on the s
| From Closed | Issue reopened | - | Issue reopened<br/>`B` added | Issue reopened<br/>`Bob` assigned | | From Closed | Issue reopened | - | Issue reopened<br/>`B` added | Issue reopened<br/>`Bob` assigned |
| From label `A` list | `A` removed | Issue closed | `A` removed<br/>`B` added | `Bob` assigned | | From label `A` list | `A` removed | Issue closed | `A` removed<br/>`B` added | `Bob` assigned |
| From assignee `Alice` list | `Alice` unassigned | Issue closed | `B` added | `Alice` unassigned<br/>`Bob` assigned | | From assignee `Alice` list | `Alice` unassigned | Issue closed | `B` added | `Alice` unassigned<br/>`Bob` assigned |
=======
One group issue board per group was made available in GitLab 10.6 Core after multiple group issue boards were originally introduced in [GitLab 10.0 Premium](https://about.gitlab.com/2017/09/22/gitlab-10-0-released/#group-issue-boards).
>>>>>>> upstream/master
## Features per tier ## Features per tier
......
...@@ -45,6 +45,10 @@ do. ...@@ -45,6 +45,10 @@ do.
| `/tableflip` | Append the comment with `(╯°□°)╯︵ ┻━┻` | | `/tableflip` | Append the comment with `(╯°□°)╯︵ ┻━┻` |
| `/shrug` | Append the comment with `¯\_(ツ)_/¯` | | `/shrug` | Append the comment with `¯\_(ツ)_/¯` |
| <code>/copy_metadata #issue &#124; !merge_request</code> | Copy labels and milestone from other issue or merge request | | <code>/copy_metadata #issue &#124; !merge_request</code> | Copy labels and milestone from other issue or merge request |
<<<<<<< HEAD
Note: In GitLab Starter every issue can have more than one assignee, so commands `/assign`, `/unassign` and `/reassign` Note: In GitLab Starter every issue can have more than one assignee, so commands `/assign`, `/unassign` and `/reassign`
support multiple assignees. support multiple assignees.
=======
| `/confidential` | Makes the issue confidential |
>>>>>>> upstream/master
...@@ -42,5 +42,26 @@ list. ...@@ -42,5 +42,26 @@ list.
An additional review mode is available when you open a merge request, which An additional review mode is available when you open a merge request, which
shows you a preview of the merge request diff if you commit your changes. 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.
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
of all jobs for the most recent pipeline and job traces for the current commit
can be accessed by clicking the **Pipelines** button in the top right.
The pipeline status is also shown at all times in the status bar in the bottom
left.
## Switching merge requests
> [Introduced in](https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/19318) [GitLab Core][ce] 11.0.
Switching between your authored and assigned merge requests can be done without
leaving the Web IDE. Click the project name in the top left to open a list of
merge requests. You will need to commit or discard all your changes before
switching to a different merge request.
[ce]: https://about.gitlab.com/pricing/ [ce]: https://about.gitlab.com/pricing/
[ee]: https://about.gitlab.com/pricing/ [ee]: https://about.gitlab.com/pricing/
...@@ -362,7 +362,7 @@ module API ...@@ -362,7 +362,7 @@ module API
end end
class Snippet < Grape::Entity class Snippet < Grape::Entity
expose :id, :title, :file_name, :description expose :id, :title, :file_name, :description, :visibility
expose :author, using: Entities::UserBasic expose :author, using: Entities::UserBasic
expose :updated_at, :created_at expose :updated_at, :created_at
expose :project_id expose :project_id
...@@ -418,6 +418,10 @@ module API ...@@ -418,6 +418,10 @@ module API
expose :state, :created_at, :updated_at expose :state, :created_at, :updated_at
expose :due_date expose :due_date
expose :start_date expose :start_date
expose :web_url do |milestone, _options|
Gitlab::UrlBuilder.build(milestone)
end
end end
class IssueBasic < ProjectEntity class IssueBasic < ProjectEntity
......
...@@ -65,7 +65,7 @@ module Banzai ...@@ -65,7 +65,7 @@ module Banzai
# We don't support IID lookups for group milestones, because IIDs can # We don't support IID lookups for group milestones, because IIDs can
# clash between group and project milestones. # clash between group and project milestones.
if project.group && !params[:iid] if project.group && !params[:iid]
finder_params[:group_ids] = [project.group.id] finder_params[:group_ids] = project.group.self_and_ancestors.select(:id)
end end
MilestonesFinder.new(finder_params).find_by(params) MilestonesFinder.new(finder_params).find_by(params)
......
...@@ -179,6 +179,8 @@ module Gitlab ...@@ -179,6 +179,8 @@ module Gitlab
end end
def list_commits_by_oid(oids) def list_commits_by_oid(oids)
return [] if oids.empty?
request = Gitaly::ListCommitsByOidRequest.new(repository: @gitaly_repo, oid: oids) request = Gitaly::ListCommitsByOidRequest.new(repository: @gitaly_repo, oid: oids)
response = GitalyClient.call(@repository.storage, :commit_service, :list_commits_by_oid, request, timeout: GitalyClient.medium_timeout) response = GitalyClient.call(@repository.storage, :commit_service, :list_commits_by_oid, request, timeout: GitalyClient.medium_timeout)
......
...@@ -20,7 +20,7 @@ module Gitlab ...@@ -20,7 +20,7 @@ module Gitlab
define_histogram :gitlab_sql_duration_seconds do define_histogram :gitlab_sql_duration_seconds do
docstring 'SQL time' docstring 'SQL time'
base_labels Transaction::BASE_LABELS base_labels Transaction::BASE_LABELS
buckets [0.001, 0.01, 0.1, 1.0, 10.0] buckets [0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
end end
def current_transaction def current_transaction
......
...@@ -149,7 +149,7 @@ module Gitlab ...@@ -149,7 +149,7 @@ module Gitlab
define_histogram :gitlab_transaction_duration_seconds do define_histogram :gitlab_transaction_duration_seconds do
docstring 'Transaction duration' docstring 'Transaction duration'
base_labels BASE_LABELS base_labels BASE_LABELS
buckets [0.001, 0.01, 0.1, 1.0, 10.0] buckets [0.1, 0.25, 0.5, 1.0, 2.5, 5.0]
end end
define_histogram :gitlab_transaction_allocated_memory_bytes do define_histogram :gitlab_transaction_allocated_memory_bytes do
......
...@@ -39,7 +39,7 @@ module Gitlab ...@@ -39,7 +39,7 @@ module Gitlab
content.delete!("\r") content.delete!("\r")
content.gsub!(commands_regex) do content.gsub!(commands_regex) do
if $~[:cmd] if $~[:cmd]
commands << [$~[:cmd], $~[:arg]].reject(&:blank?) commands << [$~[:cmd].downcase, $~[:arg]].reject(&:blank?)
'' ''
else else
$~[0] $~[0]
...@@ -102,14 +102,14 @@ module Gitlab ...@@ -102,14 +102,14 @@ module Gitlab
# /close # /close
^\/ ^\/
(?<cmd>#{Regexp.union(names)}) (?<cmd>#{Regexp.new(Regexp.union(names).source, Regexp::IGNORECASE)})
(?: (?:
[ ] [ ]
(?<arg>[^\n]*) (?<arg>[^\n]*)
)? )?
(?:\n|$) (?:\n|$)
) )
}mx }mix
end end
def perform_substitutions(content, commands) def perform_substitutions(content, commands)
...@@ -120,7 +120,7 @@ module Gitlab ...@@ -120,7 +120,7 @@ module Gitlab
end end
substitution_definitions.each do |substitution| substitution_definitions.each do |substitution|
match_data = substitution.match(content) match_data = substitution.match(content.downcase)
if match_data if match_data
command = [substitution.name.to_s] command = [substitution.name.to_s]
command << match_data[1] unless match_data[1].empty? command << match_data[1] unless match_data[1].empty?
......
...@@ -15,7 +15,7 @@ module Gitlab ...@@ -15,7 +15,7 @@ module Gitlab
return unless content return unless content
all_names.each do |a_name| all_names.each do |a_name|
content.gsub!(%r{/#{a_name} ?(.*)$}, execute_block(action_block, context, '\1')) content.gsub!(%r{/#{a_name} ?(.*)$}i, execute_block(action_block, context, '\1'))
end end
content content
end end
......
...@@ -26,6 +26,8 @@ module Gitlab ...@@ -26,6 +26,8 @@ module Gitlab
project_snippet_url(object.project, object) project_snippet_url(object.project, object)
when Snippet when Snippet
snippet_url(object) snippet_url(object)
when Milestone
milestone_url(object)
else else
raise NotImplementedError.new("No URL builder defined for #{object.class}") raise NotImplementedError.new("No URL builder defined for #{object.class}")
end end
......
...@@ -7,13 +7,15 @@ module Gitlab ...@@ -7,13 +7,15 @@ module Gitlab
@batch_size = batch_size @batch_size = batch_size
@start = start @start = start
@finish = finish @finish = finish
fix_google_api_logger
end end
# Yields a Range of IDs and a Hash of failed verifications (object => error) # Yields a Range of IDs and a Hash of failed verifications (object => error)
def run_batches(&blk) def run_batches(&blk)
relation.in_batches(of: batch_size, start: start, finish: finish) do |relation| # rubocop: disable Cop/InBatches all_relation.in_batches(of: batch_size, start: start, finish: finish) do |batch| # rubocop: disable Cop/InBatches
range = relation.first.id..relation.last.id range = batch.first.id..batch.last.id
failures = run_batch(relation) failures = run_batch_for(batch)
yield(range, failures) yield(range, failures)
end end
...@@ -29,24 +31,56 @@ module Gitlab ...@@ -29,24 +31,56 @@ module Gitlab
private private
def run_batch(relation) def run_batch_for(batch)
relation.map { |upload| verify(upload) }.compact.to_h batch.map { |upload| verify(upload) }.compact.to_h
end end
def verify(object) def verify(object)
local?(object) ? verify_local(object) : verify_remote(object)
rescue => err
failure(object, err.inspect)
end
def verify_local(object)
expected = expected_checksum(object) expected = expected_checksum(object)
actual = actual_checksum(object) actual = actual_checksum(object)
raise 'Checksum missing' unless expected.present? return failure(object, 'Checksum missing') unless expected.present?
raise 'Checksum mismatch' unless expected == actual return failure(object, 'Checksum mismatch') unless expected == actual
success
end
# We don't calculate checksum for remote objects, so just check existence
def verify_remote(object)
return failure(object, 'Remote object does not exist') unless remote_object_exists?(object)
success
end
def success
nil nil
rescue => err end
[object, err]
def failure(object, message)
[object, message]
end
# It's already set to Logger::INFO, but acts as if it is set to
# Logger::DEBUG, and this fixes it...
def fix_google_api_logger
if Object.const_defined?('Google::Apis')
Google::Apis.logger.level = Logger::INFO
end
end end
# This should return an ActiveRecord::Relation suitable for calling #in_batches on # This should return an ActiveRecord::Relation suitable for calling #in_batches on
def relation def all_relation
raise NotImplementedError.new
end
# Should return true if the object is stored locally
def local?(_object)
raise NotImplementedError.new raise NotImplementedError.new
end end
...@@ -59,6 +93,11 @@ module Gitlab ...@@ -59,6 +93,11 @@ module Gitlab
def actual_checksum(_object) def actual_checksum(_object)
raise NotImplementedError.new raise NotImplementedError.new
end end
# Be sure to perform a hard check of the remote object (don't just check DB value)
def remote_object_exists?(object)
raise NotImplementedError.new
end
end end
end end
end end
...@@ -13,10 +13,14 @@ module Gitlab ...@@ -13,10 +13,14 @@ module Gitlab
private private
def relation def all_relation
::Ci::JobArtifact.all ::Ci::JobArtifact.all
end end
def local?(artifact)
artifact.local_store?
end
def expected_checksum(artifact) def expected_checksum(artifact)
artifact.file_sha256 artifact.file_sha256
end end
...@@ -24,6 +28,10 @@ module Gitlab ...@@ -24,6 +28,10 @@ module Gitlab
def actual_checksum(artifact) def actual_checksum(artifact)
Digest::SHA256.file(artifact.file.path).hexdigest Digest::SHA256.file(artifact.file.path).hexdigest
end end
def remote_object_exists?(artifact)
artifact.file.file.exists?
end
end end
end end
end end
...@@ -11,8 +11,12 @@ module Gitlab ...@@ -11,8 +11,12 @@ module Gitlab
private private
def relation def all_relation
LfsObject.with_files_stored_locally LfsObject.all
end
def local?(lfs_object)
lfs_object.local_store?
end end
def expected_checksum(lfs_object) def expected_checksum(lfs_object)
...@@ -22,6 +26,10 @@ module Gitlab ...@@ -22,6 +26,10 @@ module Gitlab
def actual_checksum(lfs_object) def actual_checksum(lfs_object)
LfsObject.calculate_oid(lfs_object.file.path) LfsObject.calculate_oid(lfs_object.file.path)
end end
def remote_object_exists?(lfs_object)
lfs_object.file.file.exists?
end
end end
end end
end end
...@@ -45,7 +45,7 @@ module Gitlab ...@@ -45,7 +45,7 @@ module Gitlab
return unless verbose? return unless verbose?
failures.each do |object, error| failures.each do |object, error|
say " - #{verifier.describe(object)}: #{error.inspect}".color(:red) say " - #{verifier.describe(object)}: #{error}".color(:red)
end end
end end
end end
......
...@@ -11,8 +11,12 @@ module Gitlab ...@@ -11,8 +11,12 @@ module Gitlab
private private
def relation def all_relation
Upload.with_files_stored_locally Upload.all
end
def local?(upload)
upload.local?
end end
def expected_checksum(upload) def expected_checksum(upload)
...@@ -22,6 +26,10 @@ module Gitlab ...@@ -22,6 +26,10 @@ module Gitlab
def actual_checksum(upload) def actual_checksum(upload)
Upload.hexdigest(upload.absolute_path) Upload.hexdigest(upload.absolute_path)
end end
def remote_object_exists?(upload)
upload.build_uploader.file.exists?
end
end end
end end
end end
...@@ -8,8 +8,8 @@ msgid "" ...@@ -8,8 +8,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: gitlab 1.0.0\n" "Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2018-06-11 09:18+0200\n" "POT-Creation-Date: 2018-06-12 18:57+1000\n"
"PO-Revision-Date: 2018-06-11 09:18+0200\n" "PO-Revision-Date: 2018-06-12 18:57+1000\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n" "Language: \n"
...@@ -311,6 +311,9 @@ msgstr "" ...@@ -311,6 +311,9 @@ msgstr ""
msgid "Add new directory" msgid "Add new directory"
msgstr "" msgstr ""
msgid "Add reaction"
msgstr ""
msgid "Add todo" msgid "Add todo"
msgstr "" msgstr ""
......
...@@ -102,19 +102,7 @@ module QA ...@@ -102,19 +102,7 @@ module QA
def perform(&block) def perform(&block)
visit(url) visit(url)
yield if block_given? yield.tap { clear! } if block_given?
rescue
raise if block.nil?
# RSpec examples will take care of screenshots on their own
#
unless block.binding.receiver.is_a?(RSpec::Core::ExampleGroup)
screenshot_and_save_page
end
raise
ensure
clear! if block_given?
end end
## ##
......
...@@ -458,6 +458,8 @@ describe ApplicationController do ...@@ -458,6 +458,8 @@ describe ApplicationController do
end end
context 'for sessionless users' do context 'for sessionless users' do
render_views
before do before do
sign_out user sign_out user
end end
...@@ -468,6 +470,14 @@ describe ApplicationController do ...@@ -468,6 +470,14 @@ describe ApplicationController do
expect(response).to have_gitlab_http_status(403) expect(response).to have_gitlab_http_status(403)
end end
it 'renders the error message when the format was html' do
get :index,
private_token: create(:personal_access_token, user: user).token,
format: :html
expect(response.body).to have_content /accept the terms of service/i
end
it 'renders a 200 when the sessionless user accepted the terms' do it 'renders a 200 when the sessionless user accepted the terms' do
accept_terms(user) accept_terms(user)
......
...@@ -227,6 +227,42 @@ feature 'Issues > User uses quick actions', :js do ...@@ -227,6 +227,42 @@ feature 'Issues > User uses quick actions', :js do
end end
end end
describe 'make issue confidential' do
let(:issue) { create(:issue, project: project) }
let(:original_issue) { create(:issue, project: project) }
context 'when the current user can update issues' do
it 'does not create a note, and marks the issue as confidential' do
add_note("/confidential")
expect(page).not_to have_content "/confidential"
expect(page).to have_content 'Commands applied'
expect(page).to have_content "made the issue confidential"
expect(issue.reload).to be_confidential
end
end
context 'when the current user cannot update the issue' do
let(:guest) { create(:user) }
before do
project.add_guest(guest)
gitlab_sign_out
sign_in(guest)
visit project_issue_path(project, issue)
end
it 'does not create a note, and does not mark the issue as confidential' do
add_note("/confidential")
expect(page).not_to have_content 'Commands applied'
expect(page).not_to have_content "made the issue confidential"
expect(issue.reload).not_to be_confidential
end
end
end
describe 'move the issue to another project' do describe 'move the issue to another project' do
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
......
...@@ -13,7 +13,8 @@ ...@@ -13,7 +13,8 @@
"created_at": { "type": "date" }, "created_at": { "type": "date" },
"updated_at": { "type": "date" }, "updated_at": { "type": "date" },
"start_date": { "type": "date" }, "start_date": { "type": "date" },
"due_date": { "type": "date" } "due_date": { "type": "date" },
"web_url": { "type": "string" }
}, },
"required": [ "required": [
"id", "iid", "title", "description", "state", "id", "iid", "title", "description", "state",
......
...@@ -8,6 +8,7 @@ ...@@ -8,6 +8,7 @@
"title": { "type": "string" }, "title": { "type": "string" },
"file_name": { "type": ["string", "null"] }, "file_name": { "type": ["string", "null"] },
"description": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] },
"visibility": { "type": "string" },
"web_url": { "type": "string" }, "web_url": { "type": "string" },
"created_at": { "type": "date" }, "created_at": { "type": "date" },
"updated_at": { "type": "date" }, "updated_at": { "type": "date" },
......
require 'spec_helper' require "spec_helper"
describe StorageHelper do describe StorageHelper do
describe '#storage_counter' do describe "#storage_counter" do
it 'formats bytes to one decimal place' do it "formats bytes to one decimal place" do
expect(helper.storage_counter(1.23.megabytes)).to eq '1.2 MB' expect(helper.storage_counter(1.23.megabytes)).to eq("1.2 MB")
end end
it 'does not add decimals for sizes < 1 MB' do it "does not add decimals for sizes < 1 MB" do
expect(helper.storage_counter(23.5.kilobytes)).to eq '24 KB' expect(helper.storage_counter(23.5.kilobytes)).to eq("24 KB")
end end
it 'does not add decimals for zeroes' do it "does not add decimals for zeroes" do
expect(helper.storage_counter(2.megabytes)).to eq '2 MB' expect(helper.storage_counter(2.megabytes)).to eq("2 MB")
end end
it 'uses commas as thousands separator' do it "uses commas as thousands separator" do
expect(helper.storage_counter(100_000_000_000_000_000)).to eq '90,949.5 TB' if Gitlab.rails5?
expect(helper.storage_counter(100_000_000_000_000_000_000_000)).to eq("86,736.2 EB")
else
expect(helper.storage_counter(100_000_000_000_000_000)).to eq("90,949.5 TB")
end
end end
end end
end end
...@@ -19,6 +19,7 @@ describe('Multi-file editor commit sidebar list item', () => { ...@@ -19,6 +19,7 @@ describe('Multi-file editor commit sidebar list item', () => {
vm = createComponentWithStore(Component, store, { vm = createComponentWithStore(Component, store, {
file: f, file: f,
actionComponent: 'stage-button', actionComponent: 'stage-button',
activeFileKey: `staged-${f.key}`,
}).$mount(); }).$mount();
}); });
...@@ -89,4 +90,20 @@ describe('Multi-file editor commit sidebar list item', () => { ...@@ -89,4 +90,20 @@ describe('Multi-file editor commit sidebar list item', () => {
}); });
}); });
}); });
describe('is active', () => {
it('does not add active class when dont keys match', () => {
expect(vm.$el.classList).not.toContain('is-active');
});
it('adds active class when keys match', done => {
vm.keyPrefix = 'staged';
vm.$nextTick(() => {
expect(vm.$el.classList).toContain('is-active');
done();
});
});
});
}); });
...@@ -17,6 +17,8 @@ describe('Multi-file editor commit sidebar list', () => { ...@@ -17,6 +17,8 @@ describe('Multi-file editor commit sidebar list', () => {
action: 'stageAllChanges', action: 'stageAllChanges',
actionBtnText: 'stage all', actionBtnText: 'stage all',
itemActionComponent: 'stage-button', itemActionComponent: 'stage-button',
activeFileKey: 'staged-testing',
keyPrefix: 'staged',
}); });
vm.$store.state.rightPanelCollapsed = false; vm.$store.state.rightPanelCollapsed = false;
......
...@@ -56,7 +56,7 @@ describe('RepoCommitSection', () => { ...@@ -56,7 +56,7 @@ describe('RepoCommitSection', () => {
vm.$store.state.entries[f.path] = f; vm.$store.state.entries[f.path] = f;
}); });
return vm.$mount(); return vm;
} }
beforeEach(done => { beforeEach(done => {
...@@ -64,6 +64,10 @@ describe('RepoCommitSection', () => { ...@@ -64,6 +64,10 @@ describe('RepoCommitSection', () => {
vm = createComponent(); vm = createComponent();
spyOn(vm, 'openPendingTab').and.callThrough();
vm.$mount();
spyOn(service, 'getTreeData').and.returnValue( spyOn(service, 'getTreeData').and.returnValue(
Promise.resolve({ Promise.resolve({
headers: { headers: {
...@@ -98,6 +102,7 @@ describe('RepoCommitSection', () => { ...@@ -98,6 +102,7 @@ describe('RepoCommitSection', () => {
store.state.noChangesStateSvgPath = 'nochangessvg'; store.state.noChangesStateSvgPath = 'nochangessvg';
store.state.committedStateSvgPath = 'svg'; store.state.committedStateSvgPath = 'svg';
vm.$destroy();
vm = createComponentWithStore(Component, store).$mount(); vm = createComponentWithStore(Component, store).$mount();
expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes'); expect(vm.$el.querySelector('.js-empty-state').textContent.trim()).toContain('No changes');
...@@ -176,5 +181,12 @@ describe('RepoCommitSection', () => { ...@@ -176,5 +181,12 @@ describe('RepoCommitSection', () => {
expect(store.state.openFiles.length).toBe(1); expect(store.state.openFiles.length).toBe(1);
expect(store.state.openFiles[0].pending).toBe(true); expect(store.state.openFiles[0].pending).toBe(true);
}); });
it('calls openPendingTab', () => {
expect(vm.openPendingTab).toHaveBeenCalledWith({
file: vm.lastOpenedFile,
keyPrefix: 'unstaged',
});
});
}); });
}); });
...@@ -152,6 +152,53 @@ describe('IDE store file mutations', () => { ...@@ -152,6 +152,53 @@ describe('IDE store file mutations', () => {
expect(localFile.mrChange.diff).toBe('ABC'); expect(localFile.mrChange.diff).toBe('ABC');
}); });
it('has diffMode replaced by default', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
},
});
expect(localFile.mrChange.diffMode).toBe('replaced');
});
it('has diffMode new', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
new_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('new');
});
it('has diffMode deleted', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
deleted_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('deleted');
});
it('has diffMode renamed', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
renamed_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('renamed');
});
}); });
describe('DISCARD_FILE_CHANGES', () => { describe('DISCARD_FILE_CHANGES', () => {
......
...@@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures'; ...@@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures';
export const TEST_HOST = 'http://test.host'; export const TEST_HOST = 'http://test.host';
export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`; export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`;
export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/green_box.png`;
export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/red_box.png`;
...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
describe('ContentViewer', () => { describe('ContentViewer', () => {
let vm; let vm;
...@@ -41,12 +42,12 @@ describe('ContentViewer', () => { ...@@ -41,12 +42,12 @@ describe('ContentViewer', () => {
it('renders image preview', done => { it('renders image preview', done => {
createComponent({ createComponent({
path: 'test.jpg', path: GREEN_BOX_IMAGE_URL,
fileSize: 1024, fileSize: 1024,
}); });
setTimeout(() => { setTimeout(() => {
expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe('test.jpg'); expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL);
done(); done();
}); });
...@@ -59,9 +60,8 @@ describe('ContentViewer', () => { ...@@ -59,9 +60,8 @@ describe('ContentViewer', () => {
}); });
setTimeout(() => { setTimeout(() => {
expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain( expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('test.abc');
'test.abc (1.00 KiB)', expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('(1.00 KiB)');
);
expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download'); expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download');
done(); done();
......
import Vue from 'vue';
import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
describe('DiffViewer', () => {
let vm;
function createComponent(props) {
const DiffViewer = Vue.extend(diffViewer);
vm = mountComponent(DiffViewer, props);
}
afterEach(() => {
vm.$destroy();
});
it('renders image diff', done => {
window.gon = {
relative_url_root: '',
};
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
newSha: 'ABC',
oldPath: RED_BOX_IMAGE_URL,
oldSha: 'DEF',
projectPath: '',
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
`//raw/DEF/${RED_BOX_IMAGE_URL}`,
);
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
`//raw/ABC/${GREEN_BOX_IMAGE_URL}`,
);
done();
});
});
it('renders fallback download diff display', done => {
createComponent({
diffMode: 'replaced',
newPath: 'test.abc',
newSha: 'ABC',
oldPath: 'testold.abc',
oldSha: 'DEF',
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain(
'testold.abc',
);
expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain(
'Download',
);
expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc');
expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain(
'Download',
);
done();
});
});
});
import Vue from 'vue';
import imageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
describe('ImageDiffViewer', () => {
let vm;
function createComponent(props) {
const ImageDiffViewer = Vue.extend(imageDiffViewer);
vm = mountComponent(ImageDiffViewer, props);
}
const triggerEvent = (eventName, el = vm.$el, clientX = 0) => {
const event = document.createEvent('MouseEvents');
event.initMouseEvent(
eventName,
true,
true,
window,
1,
clientX,
0,
clientX,
0,
false,
false,
false,
false,
0,
null,
);
el.dispatchEvent(event);
};
const dragSlider = (sliderElement, dragPixel = 20) => {
triggerEvent('mousedown', sliderElement);
triggerEvent('mousemove', document.body, dragPixel);
triggerEvent('mouseup', document.body);
};
afterEach(() => {
vm.$destroy();
});
it('renders image diff for replaced', done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
GREEN_BOX_IMAGE_URL,
);
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
RED_BOX_IMAGE_URL,
);
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up');
expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe(
'Swipe',
);
expect(vm.$el.querySelector('.view-modes-menu li:nth-child(3)').textContent.trim()).toBe(
'Onion skin',
);
done();
});
});
it('renders image diff for new', done => {
createComponent({
diffMode: 'new',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: '',
});
setTimeout(() => {
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
GREEN_BOX_IMAGE_URL,
);
done();
});
});
it('renders image diff for deleted', done => {
createComponent({
diffMode: 'deleted',
newPath: '',
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
RED_BOX_IMAGE_URL,
);
done();
});
});
describe('swipeMode', () => {
beforeEach(done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
done();
});
});
it('switches to Swipe Mode', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('Swipe');
done();
});
});
it('drag handler is working', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('1px');
expect(vm.$el.querySelector('.top-handle')).not.toBeNull();
dragSlider(vm.$el.querySelector('.swipe-bar'), 40);
vm.$nextTick(() => {
expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('-20px');
done();
});
});
});
});
describe('onionSkin', () => {
beforeEach(done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
done();
});
});
it('switches to Onion Skin Mode', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe(
'Onion skin',
);
done();
});
});
it('has working drag handler', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dragger').style.left).toBe('100px');
dragSlider(vm.$el.querySelector('.dragger'));
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dragger').style.left).toBe('20px');
expect(vm.$el.querySelector('.added.frame').style.opacity).toBe('0.2');
done();
});
});
});
});
});
import * as domUtils from '~/vue_shared/components/lib/utils/dom_utils';
describe('domUtils', () => {
describe('pixeliseValue', () => {
it('should add px to a given Number', () => {
expect(domUtils.pixeliseValue(12)).toEqual('12px');
});
it('should not add px to 0', () => {
expect(domUtils.pixeliseValue(0)).toEqual('');
});
});
});
...@@ -3,7 +3,8 @@ require 'spec_helper' ...@@ -3,7 +3,8 @@ require 'spec_helper'
describe Banzai::Filter::MilestoneReferenceFilter do describe Banzai::Filter::MilestoneReferenceFilter do
include FilterSpecHelper include FilterSpecHelper
let(:group) { create(:group, :public) } let(:parent_group) { create(:group, :public) }
let(:group) { create(:group, :public, parent: parent_group) }
let(:project) { create(:project, :public, group: group) } let(:project) { create(:project, :public, group: group) }
it 'requires project context' do it 'requires project context' do
...@@ -340,6 +341,13 @@ describe Banzai::Filter::MilestoneReferenceFilter do ...@@ -340,6 +341,13 @@ describe Banzai::Filter::MilestoneReferenceFilter do
expect(doc.css('a')).to be_empty expect(doc.css('a')).to be_empty
end end
it 'supports parent group references', :nested_groups do
milestone.update!(group: parent_group)
doc = reference_filter("See #{reference}")
expect(doc.css('a').first.text).to eq(milestone.name)
end
end end
context 'group context' do context 'group context' do
......
...@@ -421,6 +421,16 @@ describe Gitlab::Git::Commit, seed_helper: true do ...@@ -421,6 +421,16 @@ describe Gitlab::Git::Commit, seed_helper: true do
end end
end end
describe '#batch_by_oid' do
context 'when oids is empty' do
it 'makes no Gitaly request' do
expect(Gitlab::GitalyClient).not_to receive(:call)
described_class.batch_by_oid(repository, [])
end
end
end
shared_examples 'extracting commit signature' do shared_examples 'extracting commit signature' do
context 'when the commit is signed' do context 'when the commit is signed' do
let(:commit_id) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' } let(:commit_id) { '0b4bc9a49b562e85de7cc9e834518ea6828729b9' }
......
...@@ -182,6 +182,14 @@ describe Gitlab::QuickActions::Extractor do ...@@ -182,6 +182,14 @@ describe Gitlab::QuickActions::Extractor do
expect(msg).to eq "hello\nworld" expect(msg).to eq "hello\nworld"
end end
it 'extracts command case insensitive' do
msg = %(hello\n/PoWer @user.name %9.10 ~"bar baz.2"\nworld)
msg, commands = extractor.extract_commands(msg)
expect(commands).to eq [['power', '@user.name %9.10 ~"bar baz.2"']]
expect(msg).to eq "hello\nworld"
end
it 'does not extract noop commands' do it 'does not extract noop commands' do
msg = %(hello\nworld\n/reopen\n/noop_command) msg = %(hello\nworld\n/reopen\n/noop_command)
msg, commands = extractor.extract_commands(msg) msg, commands = extractor.extract_commands(msg)
...@@ -206,6 +214,14 @@ describe Gitlab::QuickActions::Extractor do ...@@ -206,6 +214,14 @@ describe Gitlab::QuickActions::Extractor do
expect(msg).to eq "hello\nworld\nthis is great? SHRUG" expect(msg).to eq "hello\nworld\nthis is great? SHRUG"
end end
it 'extracts and performs substitution commands case insensitive' do
msg = %(hello\nworld\n/reOpen\n/sHRuG this is great?)
msg, commands = extractor.extract_commands(msg)
expect(commands).to eq [['reopen'], ['shrug', 'this is great?']]
expect(msg).to eq "hello\nworld\nthis is great? SHRUG"
end
it 'extracts and performs substitution commands with comments' do it 'extracts and performs substitution commands with comments' do
msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.) msg = %(hello\nworld\n/reopen\n/substitution wow this is a thing.)
msg, commands = extractor.extract_commands(msg) msg, commands = extractor.extract_commands(msg)
......
...@@ -22,6 +22,31 @@ describe Gitlab::UrlBuilder do ...@@ -22,6 +22,31 @@ describe Gitlab::UrlBuilder do
end end
end end
context 'when passing a Milestone' do
let(:group) { create(:group) }
let(:project) { create(:project, :public, namespace: group) }
context 'belonging to a project' do
it 'returns a proper URL' do
milestone = create(:milestone, project: project)
url = described_class.build(milestone)
expect(url).to eq "#{Settings.gitlab['url']}/#{milestone.project.full_path}/milestones/#{milestone.iid}"
end
end
context 'belonging to a group' do
it 'returns a proper URL' do
milestone = create(:milestone, group: group)
url = described_class.build(milestone)
expect(url).to eq "#{Settings.gitlab['url']}/groups/#{milestone.group.full_path}/-/milestones/#{milestone.iid}"
end
end
end
context 'when passing a MergeRequest' do context 'when passing a MergeRequest' do
it 'returns a proper URL' do it 'returns a proper URL' do
merge_request = build_stubbed(:merge_request, iid: 42) merge_request = build_stubbed(:merge_request, iid: 42)
......
...@@ -21,15 +21,38 @@ describe Gitlab::Verify::JobArtifacts do ...@@ -21,15 +21,38 @@ describe Gitlab::Verify::JobArtifacts do
FileUtils.rm_f(artifact.file.path) FileUtils.rm_f(artifact.file.path)
expect(failures.keys).to contain_exactly(artifact) expect(failures.keys).to contain_exactly(artifact)
expect(failure).to be_a(Errno::ENOENT) expect(failure).to include('No such file or directory')
expect(failure.to_s).to include(artifact.file.path) expect(failure).to include(artifact.file.path)
end end
it 'fails artifacts with a mismatched checksum' do it 'fails artifacts with a mismatched checksum' do
File.truncate(artifact.file.path, 0) File.truncate(artifact.file.path, 0)
expect(failures.keys).to contain_exactly(artifact) expect(failures.keys).to contain_exactly(artifact)
expect(failure.to_s).to include('Checksum mismatch') expect(failure).to include('Checksum mismatch')
end
context 'with remote files' do
let(:file) { double(:file) }
before do
stub_artifacts_object_storage
artifact.update!(file_store: ObjectStorage::Store::REMOTE)
expect(CarrierWave::Storage::Fog::File).to receive(:new).and_return(file)
end
it 'passes artifacts in object storage that exist' do
expect(file).to receive(:exists?).and_return(true)
expect(failures).to eq({})
end
it 'fails artifacts in object storage that do not exist' do
expect(file).to receive(:exists?).and_return(false)
expect(failures.keys).to contain_exactly(artifact)
expect(failure).to include('Remote object does not exist')
end
end end
end end
end end
...@@ -21,30 +21,37 @@ describe Gitlab::Verify::LfsObjects do ...@@ -21,30 +21,37 @@ describe Gitlab::Verify::LfsObjects do
FileUtils.rm_f(lfs_object.file.path) FileUtils.rm_f(lfs_object.file.path)
expect(failures.keys).to contain_exactly(lfs_object) expect(failures.keys).to contain_exactly(lfs_object)
expect(failure).to be_a(Errno::ENOENT) expect(failure).to include('No such file or directory')
expect(failure.to_s).to include(lfs_object.file.path) expect(failure).to include(lfs_object.file.path)
end end
it 'fails LFS objects with a mismatched oid' do it 'fails LFS objects with a mismatched oid' do
File.truncate(lfs_object.file.path, 0) File.truncate(lfs_object.file.path, 0)
expect(failures.keys).to contain_exactly(lfs_object) expect(failures.keys).to contain_exactly(lfs_object)
expect(failure.to_s).to include('Checksum mismatch') expect(failure).to include('Checksum mismatch')
end end
context 'with remote files' do context 'with remote files' do
let(:file) { double(:file) }
before do before do
stub_lfs_object_storage stub_lfs_object_storage
lfs_object.update!(file_store: ObjectStorage::Store::REMOTE)
expect(CarrierWave::Storage::Fog::File).to receive(:new).and_return(file)
end end
it 'skips LFS objects in object storage' do it 'passes LFS objects in object storage that exist' do
local_failure = create(:lfs_object) expect(file).to receive(:exists?).and_return(true)
create(:lfs_object, :object_storage)
expect(failures).to eq({})
end
failures = {} it 'fails LFS objects in object storage that do not exist' do
described_class.new(batch_size: 10).run_batches { |_, failed| failures.merge!(failed) } expect(file).to receive(:exists?).and_return(false)
expect(failures.keys).to contain_exactly(local_failure) expect(failures.keys).to contain_exactly(lfs_object)
expect(failure).to include('Remote object does not exist')
end end
end end
end end
......
...@@ -23,37 +23,44 @@ describe Gitlab::Verify::Uploads do ...@@ -23,37 +23,44 @@ describe Gitlab::Verify::Uploads do
FileUtils.rm_f(upload.absolute_path) FileUtils.rm_f(upload.absolute_path)
expect(failures.keys).to contain_exactly(upload) expect(failures.keys).to contain_exactly(upload)
expect(failure).to be_a(Errno::ENOENT) expect(failure).to include('No such file or directory')
expect(failure.to_s).to include(upload.absolute_path) expect(failure).to include(upload.absolute_path)
end end
it 'fails uploads with a mismatched checksum' do it 'fails uploads with a mismatched checksum' do
upload.update!(checksum: 'something incorrect') upload.update!(checksum: 'something incorrect')
expect(failures.keys).to contain_exactly(upload) expect(failures.keys).to contain_exactly(upload)
expect(failure.to_s).to include('Checksum mismatch') expect(failure).to include('Checksum mismatch')
end end
it 'fails uploads with a missing precalculated checksum' do it 'fails uploads with a missing precalculated checksum' do
upload.update!(checksum: '') upload.update!(checksum: '')
expect(failures.keys).to contain_exactly(upload) expect(failures.keys).to contain_exactly(upload)
expect(failure.to_s).to include('Checksum missing') expect(failure).to include('Checksum missing')
end end
context 'with remote files' do context 'with remote files' do
let(:file) { double(:file) }
before do before do
stub_uploads_object_storage(AvatarUploader) stub_uploads_object_storage(AvatarUploader)
upload.update!(store: ObjectStorage::Store::REMOTE)
expect(CarrierWave::Storage::Fog::File).to receive(:new).and_return(file)
end end
it 'skips uploads in object storage' do it 'passes uploads in object storage that exist' do
local_failure = create(:upload) expect(file).to receive(:exists?).and_return(true)
create(:upload, :object_storage)
expect(failures).to eq({})
end
failures = {} it 'fails uploads in object storage that do not exist' do
described_class.new(batch_size: 10).run_batches { |_, failed| failures.merge!(failed) } expect(file).to receive(:exists?).and_return(false)
expect(failures.keys).to contain_exactly(local_failure) expect(failures.keys).to contain_exactly(upload)
expect(failure).to include('Remote object does not exist')
end end
end end
end end
......
...@@ -20,6 +20,7 @@ describe API::Snippets do ...@@ -20,6 +20,7 @@ describe API::Snippets do
private_snippet.id) private_snippet.id)
expect(json_response.last).to have_key('web_url') expect(json_response.last).to have_key('web_url')
expect(json_response.last).to have_key('raw_url') expect(json_response.last).to have_key('raw_url')
expect(json_response.last).to have_key('visibility')
end end
it 'hides private snippets from regular user' do it 'hides private snippets from regular user' do
...@@ -112,6 +113,7 @@ describe API::Snippets do ...@@ -112,6 +113,7 @@ describe API::Snippets do
expect(json_response['title']).to eq(snippet.title) expect(json_response['title']).to eq(snippet.title)
expect(json_response['description']).to eq(snippet.description) expect(json_response['description']).to eq(snippet.description)
expect(json_response['file_name']).to eq(snippet.file_name) expect(json_response['file_name']).to eq(snippet.file_name)
expect(json_response['visibility']).to eq(snippet.visibility)
end end
it 'returns 404 for invalid snippet id' do it 'returns 404 for invalid snippet id' do
...@@ -142,6 +144,7 @@ describe API::Snippets do ...@@ -142,6 +144,7 @@ describe API::Snippets do
expect(json_response['title']).to eq(params[:title]) expect(json_response['title']).to eq(params[:title])
expect(json_response['description']).to eq(params[:description]) expect(json_response['description']).to eq(params[:description])
expect(json_response['file_name']).to eq(params[:file_name]) expect(json_response['file_name']).to eq(params[:file_name])
expect(json_response['visibility']).to eq(params[:visibility])
end end
it 'returns 400 for missing parameters' do it 'returns 400 for missing parameters' do
......
...@@ -343,6 +343,14 @@ describe QuickActions::InterpretService do ...@@ -343,6 +343,14 @@ describe QuickActions::InterpretService do
end end
end end
shared_examples 'confidential command' do
it 'marks issue as confidential if content contains /confidential' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(confidential: true)
end
end
shared_examples 'shrug command' do shared_examples 'shrug command' do
it 'appends ¯\_(ツ)_/¯ to the comment' do it 'appends ¯\_(ツ)_/¯ to the comment' do
new_content, _ = service.execute(content, issuable) new_content, _ = service.execute(content, issuable)
...@@ -817,6 +825,7 @@ describe QuickActions::InterpretService do ...@@ -817,6 +825,7 @@ describe QuickActions::InterpretService do
let(:issuable) { issue } let(:issuable) { issue }
end end
<<<<<<< HEAD
context 'issuable weights licensed' do context 'issuable weights licensed' do
before do before do
stub_licensed_features(issue_weights: true) stub_licensed_features(issue_weights: true)
...@@ -849,6 +858,11 @@ describe QuickActions::InterpretService do ...@@ -849,6 +858,11 @@ describe QuickActions::InterpretService do
expect(updates).to be_empty expect(updates).to be_empty
end end
=======
it_behaves_like 'confidential command' do
let(:content) { '/confidential' }
let(:issuable) { issue }
>>>>>>> upstream/master
end end
context '/copy_metadata command' do context '/copy_metadata command' do
...@@ -995,6 +1009,11 @@ describe QuickActions::InterpretService do ...@@ -995,6 +1009,11 @@ describe QuickActions::InterpretService do
let(:issuable) { issue } let(:issuable) { issue }
end end
it_behaves_like 'empty command' do
let(:content) { '/confidential' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do it_behaves_like 'empty command' do
let(:content) { '/duplicate #{issue.to_reference}' } let(:content) { '/duplicate #{issue.to_reference}' }
let(:issuable) { issue } let(:issuable) { issue }
......
...@@ -76,10 +76,8 @@ shared_examples "migrates" do |to_store:, from_store: nil| ...@@ -76,10 +76,8 @@ shared_examples "migrates" do |to_store:, from_store: nil|
end end
context 'when migrate! is occupied by another process' do context 'when migrate! is occupied by another process' do
let(:exclusive_lease_key) { "object_storage_migrate:#{subject.model.class}:#{subject.model.id}" }
before do before do
@uuid = Gitlab::ExclusiveLease.new(exclusive_lease_key, timeout: 1.hour.to_i).try_obtain @uuid = Gitlab::ExclusiveLease.new(subject.exclusive_lease_key, timeout: 1.hour.to_i).try_obtain
end end
it 'does not execute migrate!' do it 'does not execute migrate!' do
...@@ -95,7 +93,7 @@ shared_examples "migrates" do |to_store:, from_store: nil| ...@@ -95,7 +93,7 @@ shared_examples "migrates" do |to_store:, from_store: nil|
end end
after do after do
Gitlab::ExclusiveLease.cancel(exclusive_lease_key, @uuid) Gitlab::ExclusiveLease.cancel(subject.exclusive_lease_key, @uuid)
end end
end end
......
...@@ -344,6 +344,18 @@ describe ObjectStorage do ...@@ -344,6 +344,18 @@ describe ObjectStorage do
expect { uploader.use_file }.to raise_error(ObjectStorage::ExclusiveLeaseTaken) expect { uploader.use_file }.to raise_error(ObjectStorage::ExclusiveLeaseTaken)
end end
end end
it 'can still migrate other files of the same model' do
uploader2 = uploader_class.new(object, :file)
uploader2.upload = create(:upload)
uploader.upload = create(:upload)
when_file_is_in_use do
expect(uploader2).to receive(:unsafe_migrate!)
uploader2.migrate!(described_class::Store::REMOTE)
end
end
end end
describe '#fog_credentials' do describe '#fog_credentials' do
......
require 'spec_helper'
describe 'errors/access_denied' do
it 'does not fail to render when there is no message provided' do
expect { render }.not_to raise_error
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment